A real prompt queue for Claude Code: /after <task> runs it after the current task, without steering. See, reorder and remove queued tasks from a band and a…

A prompt queue for Claude Code, built as a Claude Code mod. Keep typing while Claude works: /after <task> runs it when the current task is done, without steering it.
❯ /before
╭─ After ──────────────────────────────────╮
│ ✓ Queued #1 · next up │
│ │
│ ▶ refactor the parser │
│ + 1 fix the lint errors first ← new │
│ 2 add tests for the parser │
│ 3 update the README │
╰──────────────────────────────────────────╯
after ▶ refactor the parser then 1 fix the lint errors first 2 add tests… 3 update… ✎ edit
❯ _
/after opens the queue to edit it with the keyboard./after tests, write a README explaining them goes right after the tests task; /after remove the tests one removes it. Haiku figures it out.Anything you type while Claude is working gets injected into the current turn (steering). That is great for "use Vitest, not Jest", and bad for "when you're done, update the README": Claude changes plans mid-task, mixes priorities and fills its context with work it should not do yet.
Rule of thumb:
/after …) is work for later./plugin marketplace add midudev/after-for-claude-code
/plugin install after@after-for-claude-code
Mods ship inside plugins, so that is all. Requires a Claude Code version with mods (2.1.289 or newer).
Upgrading from 0.1? After 0.1 put
after.mdandbefore.mdin~/.claude/commands. Delete them: the mod registers/afterand/beforeitself, and After tells you at startup if those files are still there. TheafterCLI for a second terminal is gone too; the pane replaces it.
| Command | What it does |
|---|---|
/after <task> | Queue a task. It runs when Claude finishes (right away if Claude is idle). |
/before <task> | Queue a task at the front, so it runs next. |
/after #2 <task> · /before #2 <task> | Insert right after / before task #2. |
/after | Open the queue to pick, edit, reorder and remove tasks with the keyboard. |
/after list | Print the queue in the transcript. |
/after rm 2 · rm 2-4 · rm last · rm all | Remove tasks. |
/after edit 2 <text> | Rewrite a task. |
/after mv 3 1 · mv 3 last · top 3 | Reorder. |
/after pause · /after resume | Hold the queue / let it run again. |
/after undo | Undo the last change. |
/after clear | Empty the queue. |
/after help | All of the above. |
/after and /before run the moment you press Enter, even while Claude is working: a card shows up at once, and the task waits for its turn. The card draws the part of the queue that changed: + a new task, ↕ a moved one, ✎ an edited one, − a removed one (struck out, where it was), ▶ what Claude is on, and ✦ Haiku on the border when Haiku understood the command.
When what you type could refer to something already queued, After asks Claude Haiku (through Claude Code's own model client, so there is no API key to set up) and falls back to local matching if Haiku does not answer:
/after tests, write a README explaining them → inserted right after the tests task
/before deploy bump the version → inserted right before the deploy task
/after remove adding tests → removes the tests task
/after quita lo del readme → Spanish works too
/after move the readme to the top → reorders
/after -- remove the old API → "--" queues the text as-is, no interpretation
Removing a task that already started is refused instead of being queued as a new task, so "remove the tests" can never turn into "delete the test files".
While something is queued, a one-line band above the prompt shows the queue: its after tag is cyan while Claude works, green while a queued task runs (▶) and yellow when paused, followed by the next tasks. Bare /after (or ✎ edit on the band) opens the queue with the keyboard on it: docked beside the transcript in fullscreen, above the prompt otherwise.
⏸ paused · queued tasks wait until you resume
✓ refactor the parser
❯ 1 add tests for the parser ← new
2 bump the version
3 deploy to staging
+: a task, or rm 2 · mv 3 1 · remove the tests one
e: edit d: delete k: up j: down a: add p: resume u: undo clear all
↑ ↓ pick a task (❯). Enter or e loads it into the input to rewrite it, d removes it, k and j move it up and down.a jumps to the input, which takes a new task or anything /after understands, plain language included.p pauses or resumes, u undoes. Esc closes it; ✎ edit or /after brings it back.Everything applies at once, even while Claude is working. If Claude is idle, a task you add starts on its own.
After is one hooks module (hooks/register.tsx) running inside Claude Code. Nothing patches Claude Code and nothing runs outside it.
/after never reaches the model. The mod registers /after and /before as slash commands that run immediately, even mid-turn. A command answers with a short reference (#lq2x9k-3) and a ui.render hook on CommandOutput draws the card it points to in its place: the model only ever reads the reference. A session.append hook strips the command's arguments from the record the model would see, so queued tasks stay out of Claude's context until their turn.turn.complete (an answered turn, not Esc or an error) the mod pops the next task and submits it with $.prompt.submit as your own message. A plain-language command still being resolved when the turn ends is waited for, so a /after remove … typed mid-turn lands first.$.model.complete. The session's own client, no subprocess and no API key. Plain tasks never ask the model; only text that may refer to the queue does.$.state (the band and the pane redraw from it, and it survives a hot reload) and saved in $.store, so a resumed session finds its queue, and its cards. /clear keeps the queue.hooks/ register.tsx (the mod) · register.test.tsx (claude plugin test)
types/ index.d.ts (the $.state contract)
src/
├── core/ queue.js (pure operations)
├── commands/ parse.js (/after grammar) · apply.js (intents → state) · execute.js
├── smart/ text.js (fuzzy matching) · heuristics.js · protocol.js (Haiku contract) · resolve.js
└── ui/ card.js (what a command prints) · render.js · format.js
Everything under src/ is plain, dependency-free JavaScript with no Node APIs, shared by the mod and the unit tests.
/config lists After's options (or set them under pluginConfigs.after.options in settings.json):
| Option | Default | |
|---|---|---|
Plain-language commands (smart) | on | Off uses local matching only, never the model. |
Model (model) | haiku | Model for plain-language commands. |
Where the queue shows (display) | band | band above the prompt, status as one line under it, or none. |
/after pause to hold it./after <task> starts the task right away. To build a queue first, /after pause, add your tasks, then /after resume.npm run test:node # unit tests for src/ (node --test, no dependencies)
npm run test:mod # the mod against the engine: claude plugin test .
npm run validate # claude plugin validate .
npm run typecheck # tsc against the types Claude Code lays in .claude-plugin/types
claude --plugin-dir . # try it from source; edits hot-reload
npm run eval # the real model against the plain-language cases (costs a few cents)
.claude-plugin/types/ is written by Claude Code the first time it loads the mod from this folder (claude --plugin-dir .), which npm run typecheck needs.
MIT © Miguel Ángel Durán (midudev)
hooks/register.tsx 639 lines1// After as a Claude Code mod: one hooks module, no subprocesses.
2//
3// - /after and /before are slash commands of the mod. They run at once, even mid-turn, and
4// answer with a card drawn in the transcript. The model only reads a reference to the
5// card, and the command's record is scrubbed, so queued tasks never reach Claude before
6// their turn (no steering).
7// - Bare /after opens the queue in a pane to pick, edit, reorder and remove tasks.
8// - When a turn ends, the next task is handed to Claude with $.prompt.submit.
9// - The queue lives in $.state (the band and the pane redraw from it) and in $.store (so a
10// resumed session finds it).
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, EventOf, Register, RenderElement } from 'claude-code'
13
14import { applyIntent, applyOps } from '../src/commands/apply.js'
15import { executeCommand } from '../src/commands/execute.js'
16import { parsePrompt } from '../src/commands/parse.js'
17import { canStart, createState, giveBack, markIdle, normalizeState, takeNext } from '../src/core/queue.js'
18import { createModelResolver } from '../src/smart/resolve.js'
19import { buildCard } from '../src/ui/card.js'
20import type { Card, Row } from '../src/ui/card.js'
21import { plural, shorten } from '../src/ui/format.js'
22import { describeStatus, renderMessage } from '../src/ui/render.js'
23import type { AfterDraft, AfterNote, AfterQueue, AfterTask } from '../types'
24
25type Engine = EngineInterface
26type Command = 'after' | 'before'
27type RenderOf<C extends string> = Extract<EventOf['ui.render'], { component: C }>
28type Next<C extends string> = (e: RenderOf<C>) => Promise<RenderElement>
29type Outcome = Omit<ReturnType<typeof applyOps>, 'state'> & { state: AfterQueue }
30type Op =
31 | { type: 'add'; text: string; where: object }
32 | { type: 'remove'; ids: number[] }
33 | { type: 'move'; id: number; where: object }
34 | { type: 'edit'; id: number; text: string }
35 | { type: 'clear' }
36
37const PANE = 'after'
38const STORE_PREFIX = 'queue:'
39const STORE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
40const CARD_PREFIX = 'card:'
41const CARD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
42const MODEL_TIMEOUT_MS = 25_000
43
44const QUEUE_REF = { plugin: 'after', key: 'queue' } as const
45const NOTE_REF = { plugin: 'after', key: 'note' } as const
46const DRAFT_REF = { plugin: 'after', key: 'draft' } as const
47const PANE_OPEN_REF = { plugin: 'after', key: 'paneOpen' } as const
48const SELECTED_REF = { plugin: 'after', key: 'selected' } as const
49const QUEUE = atom(QUEUE_REF, null)
50const NOTE = atom(NOTE_REF, null)
51const DRAFT = atom(DRAFT_REF, { text: '', editId: null })
52const PANE_OPEN = atom(PANE_OPEN_REF, false)
53const SELECTED = atom(SELECTED_REF, null)
54
55const EMPTY_DRAFT: AfterDraft = { text: '', editId: null }
56const STATUS_COLOR: Record<string, string | undefined> = { green: 'green', yellow: 'yellow', red: 'red', cyan: 'cyan', dim: undefined }
57const NOTE_COLOR: Record<AfterNote['kind'], string | undefined> = { ok: 'green', info: undefined, error: 'red' }
58const TONE_COLOR: Record<Card['tone'], string | undefined> = { ok: 'green', info: undefined, error: 'red', paused: 'yellow' }
59const CHANGE_COLOR: Record<string, string> = { new: 'green', starting: 'green', moved: 'yellow', edited: 'cyan', removed: 'red' }
60const PANE_TAG: Record<string, string> = { new: '← new', moved: '← moved', edited: '← edited' }
61// The row a slash command leaves in the transcript, which the model reads on its next request
62const COMMAND_RECORD = /<command-name>\/(?:after:)?(?:after|before)<\/command-name>/
63const COMMAND_ARGS = /<command-args>[\s\S]*?<\/command-args>/
64// What a command answers: a reference to the card it drew ("#lq2x9k-3")
65const CARD_REF = /#([0-9a-z]+-[0-9a-z]+)\s*$/
66
67// The queue as this module last wrote it. $.state publishes it to the drawings and survives a
68// hot reload; $.store keeps it across restarts. Every write goes through `exclusive`.
69let current: AfterQueue | null = null
70let chain: Promise<unknown> = Promise.resolve()
71// A /clear starts a new conversation (and session id) in the same process: the queue stays
72let carryOver = false
73// Plain-language commands waiting for the model. A turn that ends meanwhile waits for them,
74// so "/after remove the readme one" typed mid-turn lands before the queue moves on.
75let resolving = 0
76let kickPending = false
77let settings = { smart: true, model: 'haiku', display: 'band' }
78// The cards drawn in the transcript, by reference; $.store keeps them for a resumed session
79const cards = new Map<string, Card | null>()
80let cardCount = 0
81
82export const register: Register = (on, options) => {
83 settings = {
84 smart: options.smart !== false,
85 model: String(options.model || 'haiku'),
86 display: String(options.display || 'band')
87 }
88
89 on('session.start', async ($, e, next) => {
90 await $.command.register({
91 name: 'after',
92 description: 'Queue a task to run after the current one, without steering it · no task: edit the queue',
93 argumentHint: '[task] | rm <n> | mv <n> <m> | edit <n> <text> | pause | resume | undo | clear | list | help',
94 immediate: true
95 })
96 await $.command.register({
97 name: 'before',
98 description: 'Queue a task at the front so it runs next',
99 argumentHint: '[task] | #<n> <task> to insert before #n',
100 immediate: true
101 })
102 await exclusive(() => load($))
103 await prune($)
104 await warnAboutShims($)
105 return next(e)
106 })
107
108 on('session.end', async ($, e, next) => {
109 if (e.reason === 'clear') carryOver = true
110 return next(e)
111 })
112
113 on('turn.start', async ($, e, next) => {
114 await commit($, state => ({ state: state.busy ? state : { ...state, busy: true } }))
115 return next(e)
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 const result = await next(e)
120 if (e.agentId === undefined) await finishTurn($, e.reason === 'answer')
121 return result
122 })
123
124 on('command.run', { command: 'after' }, ($, e) => slashCommand($, 'after', e.args))
125 on('command.run', { command: 'before' }, ($, e) => slashCommand($, 'before', e.args))
126
127 // The command's record would show Claude what was queued before its turn: keep the
128 // command, drop its arguments
129 on('session.append', { door: 'command' }, ($, e, next) => {
130 const isAfter = e.message.content.some(block => typeof block.text === 'string' && COMMAND_RECORD.test(block.text))
131 if (!isAfter) return next(e)
132 const content = e.message.content.map(block => typeof block.text === 'string'
133 ? { ...block, text: block.text.replace(COMMAND_ARGS, '<command-args></command-args>') }
134 : block)
135 return next({ ...e, message: { ...e.message, content } })
136 })
137
138 on('ui.render', { component: 'CommandOutput' }, ($, e, next) => drawCommandOutput($, e, next))
139 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawBand($, e, next))
140 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e))
141 // The pane's selection follows its focus ring: the arrows, Tab or a click on a task
142 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
143 const id = e.element?.startsWith('row-') ? Number(e.element.slice(4)) : null
144 if (id !== null && Number.isInteger(id)) await $.state.set(SELECTED_REF, id)
145 return next(e)
146 })
147 on('ui.close', { id: PANE }, async ($, e, next) => {
148 await $.state.set(PANE_OPEN_REF, false)
149 await $.state.set(DRAFT_REF, EMPTY_DRAFT)
150 return next(e)
151 })
152}
153
154// ── The queue ──────────────────────────────────────────────────────────────────
155
156function exclusive<T> (work: () => Promise<T>): Promise<T> {
157 const run = chain.then(work, work)
158 chain = run.catch(() => {})
159 return run
160}
161
162const storeKey = (sessionId: string | null) => `${STORE_PREFIX}${sessionId}`
163
164/** The queue of the session running now. Call inside `exclusive`. */
165async function load ($: Engine): Promise<AfterQueue> {
166 const sessionId = await $.session.id()
167 if (current?.sessionId === sessionId) return current
168 if (current && carryOver) {
169 carryOver = false
170 await $.store.delete(storeKey(current.sessionId))
171 return publish($, { ...current, sessionId })
172 }
173 const held = await read($, QUEUE)
174 if (held?.sessionId === sessionId) return (current = held)
175
176 const now = await $.clock.now()
177 const stored = await $.store.get(storeKey(sessionId))
178 // A queue from a previous run of this session: Claude is not working on anything now
179 const state = stored
180 ? markIdle(normalizeState(stored, { sessionId }), { now, status: 'stopped' })
181 : createState({ sessionId, cwd: await $.session.cwd(), now })
182 return publish($, state as AfterQueue)
183}
184
185async function publish ($: Engine, state: AfterQueue): Promise<AfterQueue> {
186 current = state
187 await $.state.set(QUEUE_REF, state)
188 await $.store.set(storeKey(state.sessionId), state)
189 if (settings.display === 'status') $.ui.status(statusLine(state))
190 return state
191}
192
193/** Applies `apply(state, now)` and saves the state it returns. */
194function commit<T extends { state: AfterQueue }> ($: Engine, apply: (state: AfterQueue, now: number) => T): Promise<T> {
195 return exclusive(async () => {
196 const state = await load($)
197 const now = await $.clock.now()
198 const outcome = apply(state, now)
199 if (outcome.state === state) return outcome
200 return { ...outcome, state: await publish($, { ...outcome.state, updatedAt: now }) }
201 })
202}
203
204async function prune ($: Engine) {
205 const now = await $.clock.now()
206 for (const key of await $.store.keys()) {
207 if (key.startsWith(CARD_PREFIX)) {
208 // A card's reference starts with the time it was drawn
209 const drawnAt = Number.parseInt(key.slice(CARD_PREFIX.length).split('-')[0] ?? '', 36)
210 if (!(now - drawnAt <= CARD_MAX_AGE_MS)) await $.store.delete(key)
211 continue
212 }
213 if (!key.startsWith(STORE_PREFIX)) continue
214 const state = (await $.store.get(key)) as Partial<AfterQueue> | undefined
215 const isEmpty = !state?.items?.length && !state?.running
216 if (isEmpty && now - (state?.updatedAt ?? 0) > STORE_MAX_AGE_MS) await $.store.delete(key)
217 }
218}
219
220/** After 0.1 put /after and /before in ~/.claude/commands; a file there would shadow the mod. */
221async function warnAboutShims ($: Engine) {
222 const shadowing = (await $.command.list())
223 .filter(command => command.source === 'user' && (command.name === 'after' || command.name === 'before'))
224 if (shadowing.length === 0) return
225 const files = shadowing.map(command => `${command.name}.md`).join(' and ')
226 $.ui.log(`After: delete ${files} from ~/.claude/commands (left by After 0.1) so /after reaches the mod`)
227}
228
229// ── Running tasks ──────────────────────────────────────────────────────────────
230
231async function finishTurn ($: Engine, isAnswered: boolean) {
232 // Esc, a refusal or an API error stop the queue until the next turn ends normally
233 const { state, finished } = await commit($, (state, now) => ({
234 state: markIdle(state, { now, status: isAnswered ? 'done' : 'stopped' }) as AfterQueue,
235 finished: state.running
236 }))
237 if (!isAnswered) return
238 if (finished && state.items.length === 0 && settings.display !== 'none') $.ui.toast('After · all queued tasks done')
239 startNext($)
240}
241
242/** Hands the next task to Claude if it is idle and the queue may run. */
243function startNext ($: Engine) {
244 if (resolving > 0) {
245 kickPending = true
246 return
247 }
248 kickPending = false
249 void commit($, (state, now) => canStart(state) ? takeNext(state, now) : { state, item: null })
250 .then(({ item }: { item: AfterTask | null }) => item && submit($, item))
251 .catch(error => debug($, error))
252}
253
254/** Work nobody awaits reports its failures to the debug log (claude --debug). */
255function debug ($: Engine, error: unknown) {
256 try {
257 $.ui.log(`After: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
258 } catch {}
259}
260
261async function submit ($: Engine, item: AfterTask) {
262 // The user typed it with /after, so Claude reads it as their message, bare
263 let reason: string | undefined
264 try {
265 reason = (await $.prompt.submit({ text: item.text, asUser: true })).drop
266 } catch (error) {
267 reason = error instanceof Error ? error.message : String(error)
268 }
269 if (reason === undefined) return
270 await commit($, state => ({ state: giveBack(state, item.id) as AfterQueue }))
271 $.ui.log(`✗ After could not start “${shorten(item.text, 48)}”: ${reason}\n The queue is paused · /after resume to try again`)
272}
273
274// ── Commands ───────────────────────────────────────────────────────────────────
275
276async function track<T> (work: () => Promise<T>): Promise<T> {
277 resolving++
278 try {
279 return await work()
280 } finally {
281 resolving--
282 }
283}
284
285function resolverFor ($: Engine) {
286 if (!settings.smart) return null
287 return createModelResolver(async ({ system, prompt }: { system: string; prompt: string }) => {
288 const reply = await $.model.complete({ model: settings.model, system, prompt, maxTokens: 1024, effort: 'low', timeoutMs: MODEL_TIMEOUT_MS })
289 return reply.isAnswered ? reply.text : null
290 })
291}
292
293async function slashCommand ($: Engine, command: Command, args: string) {
294 let card: Card
295 try {
296 const outcome = await runCommand($, command, args)
297 if (outcome.view === 'watch') {
298 if (outcome.kick || kickPending) startNext($)
299 // The pane is the answer: nothing to print
300 return {}
301 }
302 card = buildCard(outcome, { idle: !outcome.state.busy })
303 if (outcome.kick || kickPending) startNext($)
304 } catch (error) {
305 card = { tone: 'error', icon: '✗', title: `After could not handle that: ${error instanceof Error ? error.message : String(error)}`, rows: [] }
306 }
307 // The row the transcript keeps, and the model reads on its next request: only a
308 // reference to the card, never the queue
309 return { text: `#${await keepCard($, card)}` }
310}
311
312async function keepCard ($: Engine, card: Card): Promise<string> {
313 const ref = `${(await $.clock.now()).toString(36)}-${(++cardCount).toString(36)}`
314 cards.set(ref, card)
315 await $.store.set(`${CARD_PREFIX}${ref}`, card).catch(error => debug($, error))
316 return ref
317}
318
319async function findCard ($: Engine, text: string): Promise<Card | null | undefined> {
320 const ref = CARD_REF.exec(text)?.[1]
321 if (!ref) return undefined
322 if (!cards.has(ref)) cards.set(ref, ((await $.store.get(`${CARD_PREFIX}${ref}`)) as Card | undefined) ?? null)
323 return cards.get(ref)
324}
325
326/** One /after or /before command, from the prompt or from the pane's input. */
327async function runCommand ($: Engine, command: Command, args: string): Promise<Outcome> {
328 const queue = {
329 read: () => exclusive(() => load($)),
330 commit: (apply: (state: AfterQueue, now: number) => Outcome) => commit($, apply)
331 }
332 const outcome: Outcome = await executeCommand({ queue, command, args, resolver: resolverFor($), track })
333 await remember($, outcome)
334 if (outcome.view === 'watch') await openPane($)
335 return outcome
336}
337
338/** Concrete, id-based operations from the pane's buttons. */
339async function runOps ($: Engine, ops: Op[]) {
340 const outcome: Outcome = await commit($, (state, now) => applyOps(state, ops, { now }))
341 await remember($, outcome)
342 if (outcome.kick) startNext($)
343}
344
345async function runIntent ($: Engine, type: 'pause' | 'resume' | 'undo') {
346 const outcome: Outcome = await commit($, (state, now) => applyIntent(state, { type }, { now }))
347 await remember($, outcome)
348 if (outcome.kick) startNext($)
349}
350
351async function remember ($: Engine, outcome: Outcome) {
352 const note: AfterNote | null = outcome.message
353 ? { ...outcome.message, via: outcome.via, note: outcome.note, marks: outcome.marks }
354 : null
355 await $.state.set(NOTE_REF, note)
356}
357
358// ── The card ───────────────────────────────────────────────────────────────────
359
360/**
361 * What /after and /before answered, drawn as a card in place of the reference the
362 * transcript keeps:
363 *
364 * ╭─ After ──────────────────────────── ✦ Haiku ─╮
365 * │ ✓ Queued #1 · right before #2 │
366 * │ │
367 * │ ▶ run the tests │
368 * │ + 1 run the tests again ← new │
369 * │ 2 what is this project about? │
370 * ╰──────────────────────────────────────────────╯
371 */
372async function drawCommandOutput ($: Engine, e: RenderOf<'CommandOutput'>, next: Next<'CommandOutput'>) {
373 if (!/^(?:after:)?(?:after|before)$/.test(e.props.command)) return next(e)
374 const card = await findCard($, e.props.text)
375 if (card === undefined) return next(e)
376 const { Box, Text } = $.ui.resolve(e)
377 if (card === null) return <Text dimColor>After · this card is no longer available</Text>
378
379 const width = Math.max(1, ...card.rows.map(row => row.label.length))
380 const badge = card.badge === 'haiku'
381 ? <Text color="magenta">{' ✦ Haiku '}</Text>
382 : card.badge === 'local' ? <Text dimColor>{' ✦ matched locally '}</Text> : null
383
384 return (
385 <Box flexDirection="column">
386 <Box flexDirection="column" borderStyle="round" borderColor={card.tone === 'error' ? 'red' : undefined}
387 borderDimColor={card.tone !== 'error'} paddingX={1} minWidth={44}>
388 <Text wrap="truncate-end">
389 <Text color={TONE_COLOR[card.tone]} dimColor={card.tone === 'info'} bold>{card.icon}</Text>
390 <Text bold={card.tone !== 'info'}>{` ${card.title}`}</Text>
391 {card.detail ? <Text dimColor>{` · ${card.detail}`}</Text> : null}
392 </Text>
393 {card.rows.length > 0 && <Text> </Text>}
394 {card.rows.map(row => drawRow(Box, Text, row, width, Boolean(card.focus)))}
395 {(card.sections ?? []).map((section, i) => {
396 const column = Math.max(...section.lines.map(([command]) => command.length))
397 return (
398 <Box key={`s${i}`} flexDirection="column" marginTop={1}>
399 {section.heading && <Text bold>{section.heading}</Text>}
400 {section.lines.map(([command, description]) => (
401 <Text key={command} wrap="truncate-end">
402 <Text color="cyan">{` ${command.padEnd(column)}`}</Text>
403 <Text dimColor>{` ${description}`}</Text>
404 </Text>
405 ))}
406 </Box>
407 )
408 })}
409 {card.hint && <Box marginTop={1}><Text dimColor wrap="truncate-end">{card.hint}</Text></Box>}
410 </Box>
411 <Box position="absolute" top={0} left={2}><Text bold>{' After '}</Text></Box>
412 {badge && <Box position="absolute" top={0} right={2}>{badge}</Box>}
413 </Box>
414 )
415}
416
417type Elements = ReturnType<Engine['ui']['resolve']>
418
419function drawRow (Box: Elements['Box'], Text: Elements['Text'], row: Row, width: number, focus: boolean) {
420 const color = row.change ? CHANGE_COLOR[row.change] : undefined
421 const labelColor = color ?? (row.kind === 'running' ? 'green' : row.kind === 'item' && !focus ? 'cyan' : undefined)
422 const isDim = row.kind === 'done' || row.kind === 'stopped' || row.kind === 'more' || row.kind === 'removed' ||
423 (focus && !row.change && row.kind === 'item')
424 return (
425 <Box key={row.key} flexDirection="row" justifyContent="space-between" gap={2}>
426 <Text wrap="truncate-end">
427 <Text color={color}>{`${row.marker ?? ' '} `}</Text>
428 <Text color={labelColor} dimColor={isDim && !color} bold={Boolean(row.change)}>{row.label.padStart(width)}</Text>
429 <Text dimColor={isDim} bold={Boolean(row.change) && row.change !== 'removed'} strikethrough={row.kind === 'removed'}>
430 {` ${row.text}`}
431 </Text>
432 </Text>
433 {row.tag ? <Text color={color} dimColor={!color}>{row.tag}</Text> : null}
434 </Box>
435 )
436}
437
438// ── The band and the pane ──────────────────────────────────────────────────────
439
440async function openPane ($: Engine) {
441 await $.state.set(PANE_OPEN_REF, true)
442 await $.ui.open({ id: PANE, title: 'After', focus: true, closeOnEscape: true })
443}
444
445function statusLine (state: AfterQueue): string | undefined {
446 if (state.items.length === 0 && !state.running) return undefined
447 const count = state.items.length ? `${plural(state.items.length, 'task')} queued` : 'last queued task running'
448 return `After · ${count}${state.paused ? ' · paused' : ''}`
449}
450
451/**
452 * The band above the prompt, one line: a tag in the queue's colour, what the spinner above
453 * does not already say, and what comes next.
454 *
455 * after 1 review the README 2 update the changelog +1 more ✎ edit
456 * after ▶ review the README then 1 update the changelog ✎ edit
457 */
458async function drawBand ($: Engine, e: RenderOf<'AbovePrompt'>, next: Next<'AbovePrompt'>) {
459 if (settings.display !== 'band' || e.props.hasSurvey) return next(e)
460 const state = await read($, QUEUE)
461 if (!state || (state.items.length === 0 && !state.running) || (await read($, PANE_OPEN))) return next(e)
462
463 const { Box, Button, Text } = $.ui.resolve(e)
464 const width = Math.max(32, e.props.bodyColumns)
465 const tagColor = state.paused ? 'yellow' : state.running ? 'green' : state.busy ? 'cyan' : undefined
466 const lead = state.paused
467 ? { color: 'yellow', text: 'paused' }
468 : state.running
469 ? { color: 'green', text: `▶ ${shorten(state.running.text, Math.max(12, Math.floor(width * 0.3)))}` }
470 : state.busy ? null : { color: undefined, text: 'idle · /after resume' }
471 const count = Math.min(3, state.items.length, Math.max(1, Math.floor((width - 40) / 18)))
472 const more = state.items.length > count ? `+${state.items.length - count} more` : ''
473 // The tag, the edit button, the gaps, then what the lead and "+n more" take
474 const room = width - 18 - (lead ? lead.text.length + 8 : 0) - (more ? more.length + 2 : 0)
475 const each = Math.max(8, Math.floor(room / Math.max(1, count)) - 4)
476
477 return (
478 <Box flexDirection="row" justifyContent="space-between" width={width}>
479 <Box flexDirection="row" columnGap={2} flexShrink={1}>
480 <Text key="tag" color={tagColor} inverse bold>{' after '}</Text>
481 {lead && <Text key="lead" color={lead.color} dimColor={!lead.color} wrap="truncate-end">{lead.text}</Text>}
482 {state.items.length === 0 && <Text dimColor>nothing else queued</Text>}
483 {state.running && !state.paused && state.items.length > 0 && <Text dimColor>then</Text>}
484 {state.items.slice(0, count).map((item, i) => (
485 <Text key={`next-${item.id}`} wrap="truncate-end">
486 <Text color="cyan" bold>{`${i + 1} `}</Text>
487 <Text>{shorten(item.text, each)}</Text>
488 </Text>
489 ))}
490 {more && <Text dimColor>{more}</Text>}
491 </Box>
492 <Button key="view" label="✎ edit" plain dimColor onPress={() => openPane($)} />
493 </Box>
494 )
495}
496
497/**
498 * The queue to work on, what bare /after opens: the arrows pick a task, Enter or e edits it,
499 * d removes it, k and j move it; the input queues a task or runs any /after command.
500 */
501async function drawPane ($: Engine, e: RenderOf<'Pane'>) {
502 const elements = $.ui.resolve(e)
503 const { Box, Button, Text } = elements
504 const Input = 'Input' in elements ? elements.Input : undefined
505 const state = (await read($, QUEUE)) ?? (createState({}) as AfterQueue)
506 const note = await read($, NOTE)
507 const draft = await read($, DRAFT)
508 const selectedId = await read($, SELECTED)
509 const status = describeStatus(state)
510 const editing = draft.editId === null ? -1 : state.items.findIndex(item => item.id === draft.editId)
511 const selected = state.items.find(item => item.id === selectedId) ?? state.items[0] ?? null
512 const digits = String(state.items.length).length
513 // "❯ 12 " before a task, its tag after it
514 const room = (tag?: string) => Math.max(12, e.props.bodyColumns - digits - 3 - (tag ? tag.length + 2 : 0))
515 const edit = (id?: number) => Input && editTask($, id)
516
517 return (
518 <Box flexDirection="column">
519 <Text color={STATUS_COLOR[status.tone]} dimColor={status.tone === 'dim'} wrap="truncate-end">{status.text}</Text>
520 <Text> </Text>
521 {state.done.slice(0, 2).reverse().map(task => (
522 <Text key={`done-${task.id}`} dimColor wrap="truncate-end">
523 {` ${task.status === 'stopped' ? '■' : '✓'} ${' '.repeat(digits - 1)}${shorten(task.text, room())}`}
524 </Text>
525 ))}
526 {state.running && (
527 <Text wrap="truncate-end">
528 <Text color="green">{` ▶ ${' '.repeat(digits - 1)}`}</Text>
529 <Text>{shorten(state.running.text, room('running'))}</Text>
530 <Text color="green" dimColor>{' running'}</Text>
531 </Text>
532 )}
533 {state.items.length === 0 && <Text dimColor> Nothing queued · type a task below</Text>}
534 {state.items.map((item, i) => {
535 const isSelected = item.id === selected?.id
536 const isEditing = i === editing
537 const mark = note?.marks[String(item.id)]
538 const tag = isEditing ? '✎ editing' : mark ? PANE_TAG[mark] : undefined
539 const color = isEditing ? 'cyan' : mark ? CHANGE_COLOR[mark] : undefined
540 return (
541 <Box key={`line-${item.id}`} flexDirection="row">
542 <Text color="cyan" bold>{isSelected ? '❯ ' : ' '}</Text>
543 <Text color={isSelected ? 'cyan' : undefined} dimColor={!isSelected} bold={isSelected}>
544 {`${String(i + 1).padStart(digits)} `}
545 </Text>
546 <Button key={`row-${item.id}`} label={shorten(item.text, room(tag))} plain
547 autoFocus={isSelected && editing < 0 ? true : undefined} onPress={() => edit(item.id)} />
548 {tag && <Text color={color}>{` ${tag}`}</Text>}
549 </Box>
550 )
551 })}
552 {Input && (
553 <Box marginTop={1}>
554 <Input
555 key="task"
556 label={editing >= 0 ? `✎ #${editing + 1}` : '+'}
557 placeholder="a task, or rm 2 · mv 3 1 · remove the tests one"
558 value={draft.text}
559 submitLabel={editing >= 0 ? 'save' : 'queue'}
560 autoFocus={state.items.length === 0 ? true : undefined}
561 onSubmit={(value: string) => void submitDraft($, value, draft.editId).catch(error => debug($, error))}
562 />
563 </Box>
564 )}
565 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
566 {selected && Input && <Button key="edit" label="edit" hotkey="e" plain onPress={() => edit()} />}
567 {selected && <Button key="delete" label="delete" hotkey="d" plain onPress={() => removeSelected($)} />}
568 {selected && <Button key="up" label="up" hotkey="k" plain onPress={() => moveSelected($, -1)} />}
569 {selected && <Button key="down" label="down" hotkey="j" plain onPress={() => moveSelected($, 1)} />}
570 {Input && <Button key="add" label="add" hotkey="a" plain onPress={() => focusPane($, 'task')} />}
571 <Button key="pause" label={state.paused ? 'resume' : 'pause'} hotkey="p" plain
572 onPress={() => runIntent($, state.paused ? 'resume' : 'pause')} />
573 <Button key="undo" label="undo" hotkey="u" plain onPress={() => runIntent($, 'undo')} />
574 {state.items.length > 1 && <Button key="clear" label="clear all" plain dimColor onPress={() => runOps($, [{ type: 'clear' }])} />}
575 {editing >= 0 && <Button key="cancel" label="cancel edit" plain dimColor onPress={() => update($, DRAFT, () => EMPTY_DRAFT)} />}
576 </Box>
577 {note && (
578 <Text color={NOTE_COLOR[note.kind]} dimColor={note.kind === 'info'} wrap="truncate-end">
579 {renderMessage(note, { via: note.via, note: note.note }).split('\n').slice(0, 2).join(' ')}
580 </Text>
581 )}
582 </Box>
583 )
584}
585
586/** The task the pane has picked, read when a key acts on it (the drawing may lag). */
587async function picked ($: Engine) {
588 const state = (await read($, QUEUE)) ?? (createState({}) as AfterQueue)
589 const id = await read($, SELECTED)
590 const index = Math.max(0, state.items.findIndex(item => item.id === id))
591 return { items: state.items, index, item: state.items[index] ?? null }
592}
593
594/** Loads a task (the picked one by default) into the input to rewrite it. */
595async function editTask ($: Engine, id?: number) {
596 const { items, item: current } = await picked($)
597 const item = id === undefined ? current : items.find(other => other.id === id)
598 if (!item) return
599 await $.state.set(SELECTED_REF, item.id)
600 await update($, DRAFT, () => ({ text: item.text, editId: item.id }))
601 await focusPane($, 'task')
602}
603
604async function removeSelected ($: Engine) {
605 const { items, index, item } = await picked($)
606 if (!item) return
607 const neighbour = items[index + 1] ?? items[index - 1] ?? null
608 await runOps($, [{ type: 'remove', ids: [item.id] }])
609 await $.state.set(SELECTED_REF, neighbour?.id ?? null)
610 await focusPane($, neighbour ? `row-${neighbour.id}` : 'task')
611}
612
613async function moveSelected ($: Engine, by: -1 | 1) {
614 const { items, index, item } = await picked($)
615 const to = index + by
616 if (!item || to < 0 || to >= items.length) return
617 await $.state.set(SELECTED_REF, item.id)
618 await runOps($, [{ type: 'move', id: item.id, where: { kind: 'index', index: to } }])
619}
620
621/** Moves the pane's focus ring, when the pane holds the keys; otherwise nothing to do. */
622async function focusPane ($: Engine, key: string) {
623 await $.ui.focus({ requestId: PANE, key }).catch(() => {})
624}
625
626/** Enter in the pane's input: save an edit, or run the text as an /after command. */
627async function submitDraft ($: Engine, value: string, editId: number | null) {
628 const text = value.trim()
629 await update($, DRAFT, () => EMPTY_DRAFT)
630 if (!text) return
631 if (editId !== null) {
632 await runOps($, [{ type: 'edit', id: editId, text }])
633 return focusPane($, `row-${editId}`)
634 }
635 const typed = parsePrompt(text)
636 const outcome = await runCommand($, (typed?.command ?? 'after') as Command, typed ? typed.args : text)
637 if (outcome.kick || kickPending) startNext($)
638}
639src/commands/apply.js 195 lines1// Applies parsed intents and resolved operations to a queue state.
2// Pure: returns the next state plus everything the UI needs to explain what happened.
3import * as queue from '../core/queue.js'
4import { plural, shorten } from '../ui/format.js'
5
6const quote = (text) => `“${shorten(text, 48)}”`
7
8const ok = (text) => ({ kind: 'ok', text })
9const info = (text) => ({ kind: 'info', text })
10const fail = (text) => ({ kind: 'error', text })
11
12/**
13 * Outcome shape shared by every command:
14 * - state: the next state (unchanged when nothing happened)
15 * - message: { kind: 'ok' | 'info' | 'error', text } or null
16 * - marks: { [id]: 'new' | 'moved' | 'edited' } to highlight rows
17 * - removed: tasks that left the queue
18 * - kick: whether the queue should start if Claude is idle
19 * - view: 'queue' | 'help' | 'watch'
20 * - action: the intent behind a change that is not an operation ('pause' | 'resume' | 'undo')
21 * - changes: what each operation did, for the card: { type: 'add', id, where, anchor? }
22 * | { type: 'remove' | 'clear', count } | { type: 'move', id, from } | { type: 'edit', id }
23 * - before: the pending tasks before the change, so removed rows show where they were
24 */
25const outcome = (state, message, extra = {}) => ({
26 state, message, marks: {}, removed: [], kick: false, view: 'queue', via: null, note: '',
27 action: null, changes: [], before: state.items, ...extra
28})
29
30function idAt (state, position) {
31 const item = queue.itemAt(state, position)
32 if (item) return item.id
33 throw new queue.QueueError(state.items.length === 0
34 ? 'The queue is empty'
35 : `There is no #${position} (the queue has ${plural(state.items.length, 'task')})`)
36}
37
38/** Converts user-facing positions into id-based placements. */
39function toPlacement (state, where) {
40 switch (where.kind) {
41 case 'afterPos': return { kind: 'after', id: idAt(state, where.position) }
42 case 'beforePos': return { kind: 'before', id: idAt(state, where.position) }
43 case 'index':
44 if (where.position < 1) throw new queue.QueueError('Positions start at 1')
45 return { kind: 'index', index: where.position - 1 }
46 default: return where
47 }
48}
49
50function resolveRefs (state, refs) {
51 const count = state.items.length
52 if (count === 0) throw new queue.QueueError('The queue is empty')
53 const positions = new Set()
54 for (const ref of refs) {
55 if (ref.all) state.items.forEach((_, i) => positions.add(i + 1))
56 else if (ref.last) positions.add(count)
57 else for (let p = ref.from; p <= ref.to; p++) positions.add(p)
58 }
59 return [...positions].map(position => idAt(state, position))
60}
61
62function describeAdd (state, item, where, anchorSource) {
63 const position = queue.positionOf(state, item.id)
64 const base = `Queued #${position} · ${shorten(item.text)}`
65 if (where.kind === 'after' || where.kind === 'before') {
66 const anchor = queue.findItem(anchorSource, where.id) ?? queue.findItem(state, where.id)
67 return `${base}\n ${where.kind === 'after' ? 'right after' : 'right before'} ${quote(anchor?.text ?? `#${where.id}`)}`
68 }
69 if (where.kind === 'start' && state.items.length > 1) return `${base} (next up)`
70 return base
71}
72
73/**
74 * Applies concrete, id-based operations atomically: if one fails, nothing changes.
75 * ops: { type: 'add', text, where } | { type: 'remove', ids } | { type: 'move', id, where }
76 * | { type: 'edit', id, text } | { type: 'clear' }
77 */
78export function applyOps (state, ops, { now = Date.now(), via = null, note = '' } = {}) {
79 // Understood, but nothing to do (e.g. "remove the tests" after the tests task already ran)
80 if (ops.length === 0) return outcome(state, info(note || 'Nothing to change'))
81
82 let next = state
83 const lines = []
84 const marks = {}
85 const removed = []
86 const changes = []
87 let kick = false
88
89 try {
90 for (const op of ops) {
91 switch (op.type) {
92 case 'add': {
93 const result = queue.addItem(next, op.text, op.where, now)
94 next = result.state
95 marks[result.item.id] = 'new'
96 kick = true
97 changes.push({ type: 'add', id: result.item.id, where: op.where.kind, anchor: op.where.id })
98 lines.push(describeAdd(next, result.item, op.where, state))
99 break
100 }
101 case 'remove': {
102 const result = queue.removeItems(next, op.ids)
103 next = result.state
104 removed.push(...result.removed)
105 changes.push({ type: 'remove', count: result.removed.length })
106 lines.push(result.removed.length === 1
107 ? `Removed ${quote(result.removed[0].text)}`
108 : `Removed ${plural(result.removed.length, 'task')}`)
109 break
110 }
111 case 'move': {
112 const from = queue.positionOf(next, op.id)
113 const result = queue.moveItem(next, op.id, op.where)
114 next = result.state
115 marks[op.id] = 'moved'
116 changes.push({ type: 'move', id: op.id, from })
117 lines.push(`Moved ${quote(result.item.text)} to #${queue.positionOf(next, op.id)}`)
118 break
119 }
120 case 'edit': {
121 const result = queue.editItem(next, op.id, op.text)
122 next = result.state
123 marks[op.id] = 'edited'
124 changes.push({ type: 'edit', id: op.id })
125 lines.push(`Edited #${queue.positionOf(next, op.id)} · ${shorten(result.item.text)}`)
126 break
127 }
128 case 'clear': {
129 const result = queue.clearItems(next)
130 next = result.state
131 removed.push(...result.removed)
132 changes.push({ type: 'clear', count: result.removed.length })
133 lines.push(`Cleared ${plural(result.removed.length, 'task')}`)
134 break
135 }
136 default:
137 throw new queue.QueueError(`Unknown operation "${op.type}"`)
138 }
139 }
140 } catch (error) {
141 if (error instanceof queue.QueueError) return outcome(state, fail(error.message))
142 throw error
143 }
144
145 if (removed.length > 0) lines.push(`/after undo to bring ${removed.length === 1 ? 'it' : 'them'} back`)
146 return outcome(queue.withUndo(state, next), ok(lines.join('\n')), { marks, removed, kick, via, note, changes, before: state.items })
147}
148
149/** Applies a deterministic intent from parseArgs (everything except `smart`). */
150export function applyIntent (state, intent, { now = Date.now() } = {}) {
151 try {
152 switch (intent.type) {
153 case 'show': return outcome(state, null)
154 case 'help': return outcome(state, null, { view: 'help' })
155 case 'watch': return outcome(state, null, { view: 'watch' })
156
157 case 'add':
158 return applyOps(state, [{ type: 'add', text: intent.text, where: toPlacement(state, intent.where) }], { now })
159 case 'remove':
160 return applyOps(state, [{ type: 'remove', ids: resolveRefs(state, intent.refs) }], { now })
161 case 'edit':
162 return applyOps(state, [{ type: 'edit', id: idAt(state, intent.position), text: intent.text }], { now })
163 case 'move': {
164 const id = idAt(state, intent.position)
165 return applyOps(state, [{ type: 'move', id, where: toPlacement(state, intent.to) }], { now })
166 }
167 case 'clear':
168 if (state.items.length === 0) return outcome(state, info('The queue is already empty'))
169 return applyOps(state, [{ type: 'clear' }], { now })
170
171 case 'pause':
172 if (state.paused) return outcome(state, info('Already paused · /after resume to continue'))
173 return outcome(queue.withUndo(state, queue.setPaused(state, true)),
174 ok('Paused · queued tasks will wait until /after resume'), { action: 'pause' })
175 case 'resume':
176 if (!state.paused) {
177 return outcome(state, info(state.items.length ? 'Running' : 'Nothing queued'), { kick: state.items.length > 0 })
178 }
179 return outcome(queue.withUndo(state, queue.setPaused(state, false)), ok('Resumed'), { kick: true, action: 'resume' })
180
181 case 'undo': {
182 const result = queue.undo(state)
183 return result.ok
184 ? outcome(result.state, ok('Undid the last change'), { action: 'undo' })
185 : outcome(state, info('Nothing to undo'))
186 }
187 default:
188 return outcome(state, fail(`Unknown command "${intent.type}"`))
189 }
190 } catch (error) {
191 if (error instanceof queue.QueueError) return outcome(state, fail(error.message))
192 throw error
193 }
194}
195src/commands/execute.js 39 lines1// Runs one /after or /before command against a queue. Storage-agnostic: the mod passes
2// the session's queue, the tests an in-memory one, so both behave exactly the same.
3import { isSmartCandidate, resolveSmart, startedTasks } from '../smart/resolve.js'
4import { applyIntent, applyOps } from './apply.js'
5import { parseArgs } from './parse.js'
6
7const untracked = (work) => work()
8
9/**
10 * Resolves to the outcome of applyIntent/applyOps, with the saved state.
11 * - queue: { read() → state, commit(apply(state, now) → outcome) → outcome }
12 * - resolver: optional model resolver for plain-language commands
13 * - track(work): wraps the slow path (asking the model, then saving), so the caller can
14 * hold the queue until a command typed before the turn ended has landed
15 *
16 * @template T
17 * @param {{
18 * queue: { read: () => Promise<any>, commit: (apply: (state: any, now: number) => any) => Promise<T> },
19 * command: string,
20 * args: string,
21 * resolver?: ((request: any) => Promise<any>) | null,
22 * track?: (work: () => Promise<T>) => Promise<T>
23 * }} options
24 * @returns {Promise<T>}
25 */
26export async function executeCommand ({ queue, command, args, resolver = null, track = untracked }) {
27 const intent = parseArgs(command, args)
28 if (intent.type !== 'smart') return queue.commit((state, now) => applyIntent(state, intent, { now }))
29
30 const snapshot = await queue.read()
31 const started = startedTasks(snapshot)
32 const run = async () => {
33 // Operations are id-based, so they still apply if the queue moved while the model thought
34 const resolution = await resolveSmart({ command: intent.command, text: intent.text, items: snapshot.items, started, resolver })
35 return queue.commit((state, now) => applyOps(state, resolution.ops, { now, via: resolution.via, note: resolution.note }))
36 }
37 return resolver && isSmartCandidate(intent.text, snapshot.items, started) ? track(run) : run()
38}
39src/commands/parse.js 129 lines1// Deterministic parsing of /after and /before. Anything that is not an explicit
2// command becomes a `smart` intent, resolved later (heuristics or Haiku).
3
4// Matches "/after …", "/before …" and namespaced forms like "/after:before …"
5const PROMPT_RE = /^\s*\/(?:[\w.-]+:)?(after|before)(?:\s+([\s\S]*))?$/i
6
7const words = (...list) => new Set(list)
8const SHOW = words('list', 'ls', 'show', 'status', 'queue')
9const HELP = words('help', '-h', '--help', '?')
10const CLEAR = words('clear', 'reset', 'flush', 'empty')
11const PAUSE = words('pause', 'hold', 'stop')
12const RESUME = words('resume', 'go', 'start', 'unpause', 'play')
13const UNDO = words('undo')
14const WATCH = words('watch', 'view', 'tui', 'ui', 'live', 'edit')
15const REMOVE = words('rm', 'remove', 'del', 'delete', 'drop')
16const EDIT = words('edit', 'set')
17const MOVE = words('mv', 'move')
18const TOP = words('top', 'first', 'prioritize')
19
20const START_WORDS = /^(?:first|top|start|front|next|primero|arriba)$/i
21const END_WORDS = /^(?:last|end|bottom|final|[uú]ltimo|abajo)$/i
22
23/** Detects a /after or /before command in a raw prompt. */
24export function parsePrompt (prompt) {
25 const match = PROMPT_RE.exec(String(prompt ?? ''))
26 if (!match) return null
27 return { command: match[1].toLowerCase(), args: (match[2] ?? '').trim() }
28}
29
30/**
31 * Queue references: "2", "2,4", "2-4", "2..4", "last", "all".
32 * Returns [{ from, to } | { last: true } | { all: true }] or null if something is not a reference.
33 */
34export function parseRefs (text) {
35 const parts = String(text).toLowerCase().split(/[\s,]+/).filter(Boolean)
36 if (parts.length === 0) return null
37 const refs = []
38 for (const part of parts) {
39 let match
40 if ((match = /^#?(\d+)$/.exec(part))) {
41 refs.push({ from: Number(match[1]), to: Number(match[1]) })
42 } else if ((match = /^#?(\d+)(?:-|\.\.)#?(\d+)$/.exec(part))) {
43 const [a, b] = [Number(match[1]), Number(match[2])]
44 refs.push({ from: Math.min(a, b), to: Math.max(a, b) })
45 } else if (END_WORDS.test(part)) {
46 refs.push({ last: true })
47 } else if (/^(?:first|primero)$/.test(part)) {
48 refs.push({ from: 1, to: 1 })
49 } else if (/^(?:all|everything|todo|todas|todos)$/.test(part)) {
50 refs.push({ all: true })
51 } else {
52 return null
53 }
54 }
55 return refs
56}
57
58/** "3", "#3", "first", "last", "after 2", "before 2" → a position-based placement. */
59function parseTarget (text) {
60 const value = text.trim().replace(/^(?:to|a|al)\s+/i, '')
61 let match
62 if ((match = /^#?(\d+)$/.exec(value))) return { kind: 'index', position: Number(match[1]) }
63 if ((match = /^(after|before|despu[eé]s de|antes de)\s+#?(\d+)$/i.exec(value))) {
64 const kind = /^(after|despu)/i.test(match[1]) ? 'afterPos' : 'beforePos'
65 return { kind, position: Number(match[2]) }
66 }
67 if (START_WORDS.test(value)) return { kind: 'start' }
68 if (END_WORDS.test(value)) return { kind: 'end' }
69 return null
70}
71
72/** Turns the text after /after or /before into an intent. */
73export function parseArgs (command, rawArgs = '') {
74 const args = String(rawArgs).trim()
75 // Bare /after opens the queue to edit it
76 if (!args) return { type: 'watch' }
77
78 // "--" forces the rest to be queued literally, no interpretation at all
79 if (args === '--' || args.startsWith('-- ')) {
80 const text = args.slice(2).trim()
81 if (!text) return { type: 'show' }
82 return { type: 'add', text, where: { kind: command === 'before' ? 'start' : 'end' } }
83 }
84
85 const head = args.split(/\s+/, 1)[0]
86 const word = head.toLowerCase()
87 const rest = args.slice(head.length).trim()
88
89 if (!rest) {
90 if (SHOW.has(word)) return { type: 'show' }
91 if (HELP.has(word)) return { type: 'help' }
92 if (CLEAR.has(word)) return { type: 'clear' }
93 if (PAUSE.has(word)) return { type: 'pause' }
94 if (RESUME.has(word)) return { type: 'resume' }
95 if (UNDO.has(word)) return { type: 'undo' }
96 if (WATCH.has(word)) return { type: 'watch' }
97 }
98
99 if (REMOVE.has(word)) {
100 const refs = parseRefs(rest)
101 if (refs) return { type: 'remove', refs }
102 }
103
104 if (EDIT.has(word)) {
105 const match = /^#?(\d+)[:.)]?\s+([\s\S]+)$/.exec(rest)
106 if (match) return { type: 'edit', position: Number(match[1]), text: match[2].trim() }
107 }
108
109 if (MOVE.has(word)) {
110 const match = /^#?(\d+)\s+([\s\S]+)$/.exec(rest)
111 const to = match && parseTarget(match[2])
112 if (to) return { type: 'move', position: Number(match[1]), to }
113 }
114
115 if (TOP.has(word)) {
116 const match = /^#?(\d+)$/.exec(rest)
117 if (match) return { type: 'move', position: Number(match[1]), to: { kind: 'start' } }
118 }
119
120 // Explicit anchor: "/after #2 run lint" or "/before 3: bump the version"
121 const anchor = /^#(\d+)\s+([\s\S]+)$/.exec(args) ?? /^(\d+)[:)]\s+([\s\S]+)$/.exec(args)
122 if (anchor) {
123 const kind = command === 'before' ? 'beforePos' : 'afterPos'
124 return { type: 'add', text: anchor[2].trim(), where: { kind, position: Number(anchor[1]) } }
125 }
126
127 return { type: 'smart', command, text: args }
128}
129src/core/queue.js 172 lines1// Pure queue operations. Every function returns a new state and never mutates its input,
2// which keeps them trivial to test and safe to run inside a store transaction.
3
4export const STATE_VERSION = 1
5const DONE_LIMIT = 10
6const UNDO_LIMIT = 20
7
8export class QueueError extends Error {
9 constructor (message) {
10 super(message)
11 this.name = 'QueueError'
12 }
13}
14
15/** @param {{ sessionId?: string | null, cwd?: string | null, now?: number }} [options] */
16export function createState ({ sessionId = null, cwd = null, now = Date.now() } = {}) {
17 return {
18 version: STATE_VERSION,
19 sessionId,
20 cwd,
21 items: [], // pending tasks, in run order: { id, text, createdAt }
22 running: null, // task handed to Claude and not finished yet
23 done: [], // most recent finished tasks, newest first
24 paused: false,
25 nextId: 1,
26 undo: [], // stack of { items, paused } snapshots
27 busy: false, // Claude is in the middle of a turn
28 createdAt: now,
29 updatedAt: now
30 }
31}
32
33/**
34 * Fills in fields missing from older or hand-edited state files.
35 * @param {any} raw
36 * @param {{ sessionId?: string | null, cwd?: string | null, now?: number }} [defaults]
37 */
38export function normalizeState (raw, defaults = {}) {
39 const base = createState(defaults)
40 if (!raw || typeof raw !== 'object') return base
41 const state = { ...base, ...raw }
42 state.items = Array.isArray(raw.items) ? raw.items.filter(isItem) : []
43 state.done = Array.isArray(raw.done) ? raw.done.filter(isItem) : []
44 state.undo = Array.isArray(raw.undo) ? raw.undo : []
45 state.running = isItem(raw.running) ? raw.running : null
46 const maxId = [...state.items, ...state.done, state.running].filter(Boolean)
47 .reduce((max, item) => Math.max(max, item.id), 0)
48 state.nextId = Math.max(Number(raw.nextId) || 1, maxId + 1)
49 return state
50}
51
52const isItem = (item) => Boolean(item) && Number.isInteger(item.id) && typeof item.text === 'string'
53
54const cleanText = (text) => String(text ?? '').trim()
55
56export const findItem = (state, id) => state.items.find(item => item.id === id) ?? null
57
58/** 1-based position of an item in the pending list, or -1. */
59export const positionOf = (state, id) => state.items.findIndex(item => item.id === id) + 1
60
61/** Item at a 1-based position, or null. */
62export const itemAt = (state, position) => state.items[position - 1] ?? null
63
64/**
65 * Turns a placement into an insertion index for `items`.
66 * where: { kind: 'end' | 'start' } | { kind: 'index', index } | { kind: 'after' | 'before', id }
67 */
68export function insertionIndex (items, where = { kind: 'end' }) {
69 switch (where.kind) {
70 case 'start': return 0
71 case 'end': return items.length
72 case 'index': return Math.min(Math.max(0, where.index), items.length)
73 case 'after':
74 case 'before': {
75 const index = items.findIndex(item => item.id === where.id)
76 if (index === -1) throw new QueueError(`Task #${where.id} is no longer in the queue`)
77 return where.kind === 'after' ? index + 1 : index
78 }
79 default: throw new QueueError(`Unknown placement "${where.kind}"`)
80 }
81}
82
83export function addItem (state, text, where = { kind: 'end' }, now = Date.now()) {
84 const value = cleanText(text)
85 if (!value) throw new QueueError('A task needs some text')
86 const item = { id: state.nextId, text: value, createdAt: now }
87 const items = [...state.items]
88 items.splice(insertionIndex(items, where), 0, item)
89 return { state: { ...state, items, nextId: state.nextId + 1 }, item }
90}
91
92export function removeItems (state, ids) {
93 const wanted = new Set(ids)
94 const removed = state.items.filter(item => wanted.has(item.id))
95 if (removed.length === 0) throw new QueueError('Nothing to remove')
96 return { state: { ...state, items: state.items.filter(item => !wanted.has(item.id)) }, removed }
97}
98
99export function moveItem (state, id, where) {
100 const item = findItem(state, id)
101 if (!item) throw new QueueError(`Task #${id} is no longer in the queue`)
102 if ((where.kind === 'after' || where.kind === 'before') && where.id === id) {
103 throw new QueueError('A task cannot be moved relative to itself')
104 }
105 const rest = state.items.filter(other => other.id !== id)
106 rest.splice(insertionIndex(rest, where), 0, item)
107 return { state: { ...state, items: rest }, item }
108}
109
110export function editItem (state, id, text) {
111 const value = cleanText(text)
112 if (!value) throw new QueueError('A task needs some text')
113 const previous = findItem(state, id)
114 if (!previous) throw new QueueError(`Task #${id} is no longer in the queue`)
115 const item = { ...previous, text: value }
116 return {
117 state: { ...state, items: state.items.map(other => other.id === id ? item : other) },
118 item,
119 previous
120 }
121}
122
123export function clearItems (state) {
124 return { state: { ...state, items: [] }, removed: state.items }
125}
126
127export const setPaused = (state, paused) => ({ ...state, paused: Boolean(paused) })
128
129/** Pops the next task and marks it as running. */
130export function takeNext (state, now = Date.now()) {
131 const [item, ...items] = state.items
132 if (!item) return { state, item: null }
133 return { state: { ...state, items, running: { ...item, startedAt: now }, busy: true }, item }
134}
135
136/** Claude finished (or abandoned) its turn: archive the running task, if any. */
137export function markIdle (state, { now = Date.now(), status = 'done' } = {}) {
138 if (!state.running) return state.busy ? { ...state, busy: false } : state
139 const finished = { ...state.running, finishedAt: now, status }
140 return {
141 ...state,
142 busy: false,
143 running: null,
144 done: [finished, ...state.done].slice(0, DONE_LIMIT)
145 }
146}
147
148/** Stores `previous` so the change that produced `next` can be undone. */
149export function withUndo (previous, next) {
150 const snapshot = { items: previous.items, paused: previous.paused }
151 return { ...next, undo: [snapshot, ...previous.undo].slice(0, UNDO_LIMIT) }
152}
153
154export function undo (state) {
155 const [snapshot, ...rest] = state.undo
156 if (!snapshot) return { state, ok: false }
157 // Tasks that already started running cannot come back from the past
158 const runningId = state.running?.id
159 const items = snapshot.items.filter(item => item.id !== runningId)
160 return { state: { ...state, items, paused: snapshot.paused, undo: rest }, ok: true }
161}
162
163/** Whether the next task may be handed to Claude now. */
164export const canStart = (state) => !state.busy && !state.running && !state.paused && state.items.length > 0
165
166/** A task Claude Code could not start goes back to the front, and the queue holds. */
167export function giveBack (state, id) {
168 if (state.running?.id !== id) return state
169 const { startedAt: _, ...item } = state.running
170 return { ...state, items: [item, ...state.items], running: null, busy: false, paused: true }
171}
172src/smart/resolve.js 80 lines1import { defaultOps, needsSmart, resolveHeuristically, taskAfterReference, trimConnector } from './heuristics.js'
2import { SYSTEM, buildUserMessage, parseReply, parseResponse } from './protocol.js'
3import { normalize } from './text.js'
4
5/**
6 * Builds the model-backed resolver from `complete({ system, prompt })`, any function that
7 * resolves to the model's reply text (or null). The mod passes one over `$.model.complete`
8 * (the session's own client: no API key, no subprocess); the eval script one over `claude -p`.
9 */
10export function createModelResolver (complete) {
11 return async ({ command, text, items, started = [] }) => {
12 const reply = await complete({ system: SYSTEM, prompt: buildUserMessage({ command, text, items, started }) })
13 const response = parseReply(reply)
14 const ops = parseResponse(response, items)
15 return ops ? { ops, note: typeof response.note === 'string' ? response.note.trim() : '' } : null
16 }
17}
18
19/** Whether resolving this text may need the model (so callers can mark the work as slow). */
20export const isSmartCandidate = (text, items, started = []) => needsSmart(text, items, started)
21
22/** The running task and a few recently finished ones, newest first. */
23export const startedTasks = (state) => [
24 ...(state.running ? [{ ...state.running, status: 'running' }] : []),
25 ...state.done.slice(0, 5)
26]
27
28const isDefaultAdd = (ops, command, text) =>
29 ops.length === 1 && ops[0].type === 'add' && ops[0].text === text.trim() &&
30 ops[0].where.kind === (command === 'before' ? 'start' : 'end')
31
32/**
33 * A relative add's text should hold neither the words that point at its anchor nor the
34 * connector after them. When Haiku placed the task but kept the whole command as its text
35 * ("/before decirme de que va el proyecto ejecuta los tests"), the reference is still there.
36 */
37function tidy (ops, text, items) {
38 return ops.map(op => {
39 if (op.type !== 'add' || (op.where.kind !== 'after' && op.where.kind !== 'before')) return op
40 const anchor = items.find(item => item.id === op.where.id)
41 const isWhole = normalize(op.text) === normalize(text)
42 const task = (isWhole && anchor && taskAfterReference(op.text, anchor)) || op.text
43 return { ...op, text: trimConnector(task) }
44 })
45}
46
47/**
48 * Haiku may anchor a task to the queued one it resembles ("ejecuta otra vez los tests" next to
49 * the tests task) rather than the one the leading words point at. When the local split reads
50 * the same task the same way, its anchor is the literal one.
51 */
52function literalAnchor (ops, local) {
53 const [mine] = local.via === 'heuristics' ? local.ops : []
54 const [theirs] = ops
55 const agrees = ops.length === 1 && mine?.type === 'add' && theirs.type === 'add' &&
56 mine.text === theirs.text && mine.where.kind === theirs.where.kind
57 return agrees ? [{ ...theirs, where: mine.where }] : ops
58}
59
60/**
61 * Turns natural language into queue operations.
62 * Plain tasks are added instantly; only text that may refer to queued (or already started)
63 * tasks reaches Haiku, and local heuristics take over when Haiku is disabled or fails.
64 * Resolves to { ops, via: 'haiku' | 'heuristics' | null, note? }; `via` is null when the
65 * result is simply "add the text", so the UI has nothing to explain.
66 */
67export async function resolveSmart ({ command, text, items, started = [], resolver }) {
68 if (!needsSmart(text, items, started)) return { ops: defaultOps(command, text), via: null }
69 const local = resolveHeuristically({ command, text, items, started })
70 const answer = resolver ? await resolver({ command, text, items, started }).catch(() => null) : null
71 if (!answer) return local
72
73 if (isDefaultAdd(answer.ops, command, text)) {
74 // Haiku read it as a plain task, but a clear local match says it is about the queue:
75 // trust the match ("remove the beta one" must never become a task)
76 return local.via ? local : { ops: answer.ops, via: null }
77 }
78 return { ...answer, ops: literalAnchor(tidy(answer.ops, text, items), local), via: 'haiku' }
79}
80src/ui/card.js 265 lines1// What /after and /before print in the transcript: a card with what happened and the part of
2// the queue it touched, as plain data. The mod draws it (hooks/register.tsx).
3//
4// ╭─ After ───────────────────────────── ✦ Haiku ─╮
5// │ ✓ Queued #1 · right before #2 │
6// │ │
7// │ ▶ run the tests │
8// │ + 1 run the tests again ← new │
9// │ 2 what is this project about? │
10// ╰───────────────────────────────────────────────╯
11import { takeNext } from '../core/queue.js'
12import { plural, shorten } from './format.js'
13
14const TEXT_MAX = 56
15const MAX_ROWS = 9
16const MAX_DONE = 2
17
18export const HINT = '/after <task> · rm <n> · mv <n> <m> · undo · help'
19
20/**
21 * @typedef {'new' | 'starting' | 'moved' | 'edited' | 'removed'} Change
22 * @typedef {{
23 * key: string,
24 * kind: 'done' | 'stopped' | 'running' | 'item' | 'removed' | 'more',
25 * label: string,
26 * text: string,
27 * marker?: string,
28 * tag?: string,
29 * change?: Change
30 * }} Row
31 * @typedef {{
32 * tone: 'ok' | 'info' | 'error' | 'paused',
33 * icon: string,
34 * title: string,
35 * detail?: string,
36 * badge?: 'haiku' | 'local',
37 * rows: Row[],
38 * focus?: boolean,
39 * sections?: { heading?: string, lines: [string, string][] }[],
40 * hint?: string
41 * }} Card
42 */
43
44const MARKER = { new: '+', removed: '−', moved: '↕', edited: '✎' }
45
46const HELP = [
47 {
48 lines: [
49 ['/after <task>', 'queue a task · it runs when Claude finishes'],
50 ['/before <task>', 'queue a task at the front · it runs next'],
51 ['/after #2 <task>', 'insert right after #2 (/before #2: right before)'],
52 ['/after', 'edit the queue: ↑↓ pick · e edit · d delete · k j move'],
53 ['/after list', 'print the queue'],
54 ['/after rm 2 · rm 2-4', 'remove tasks (also: rm last, rm all)'],
55 ['/after edit 2 <text>', 'rewrite a task'],
56 ['/after mv 3 1', 'move #3 to #1 (also: mv 3 last, top 3)'],
57 ['/after pause · resume', 'hold the queue / let it run again'],
58 ['/after undo · clear', 'undo the last change / empty the queue']
59 ]
60 },
61 {
62 heading: 'Plain language works too (Haiku understands it)',
63 lines: [
64 ['/after tests, write a README', 'goes right after the tests task'],
65 ['/before deploy bump the version', 'goes right before the deploy task'],
66 ['/after remove the tests one', 'removes it'],
67 ['/after -- remove the old API', '"--" queues the text as-is']
68 ]
69 }
70]
71
72const text = (value) => shorten(value, TEXT_MAX)
73
74/**
75 * The card for one /after or /before command.
76 * @param {any} outcome what applyIntent / applyOps returned, with the saved state
77 * @param {{ idle?: boolean }} [options] idle: Claude is not working, so the queue starts now
78 * @returns {Card}
79 */
80export function buildCard (outcome, { idle = false } = {}) {
81 const { state, message } = outcome
82 const badge = outcome.via === 'haiku' ? 'haiku' : outcome.via === 'heuristics' ? 'local' : undefined
83
84 if (outcome.view === 'help') {
85 return { tone: 'info', icon: '?', title: 'Queue prompts for Claude Code without steering it', rows: [], sections: HELP }
86 }
87 if (!message) return queueCard(state)
88 if (message.kind === 'error') return { tone: 'error', icon: '✗', title: message.text, rows: [], badge }
89 if (message.kind === 'info') return { tone: 'info', icon: '·', title: message.text, rows: [], badge }
90
91 const starts = idle && outcome.kick && !state.paused && !state.running && state.items.length > 0
92 if (outcome.action === 'pause') {
93 return { tone: 'paused', icon: '⏸', title: 'Paused', detail: 'queued tasks wait until /after resume', rows: [] }
94 }
95 if (outcome.action === 'resume') {
96 return starts
97 ? { tone: 'ok', icon: '▶', title: 'Resumed', detail: 'Claude is idle, so the queue starts now', rows: [] }
98 : { tone: 'ok', icon: '▶', title: 'Resumed', rows: [] }
99 }
100 if (outcome.action === 'undo') {
101 const detail = state.items.length ? `${plural(state.items.length, 'task')} queued` : 'queue empty'
102 return { ...queueCard(state), tone: 'ok', icon: '↶', title: 'Undid the last change', detail, hint: undefined }
103 }
104
105 // An idle Claude takes the first task at once: draw the queue as it is about to be
106 const shown = starts ? takeNext(state).state : state
107 const startedId = starts ? shown.running.id : null
108 const { title, detail } = describeChanges(outcome, shown, startedId)
109 return { tone: 'ok', icon: '✓', title, detail, badge, rows: changeRows(outcome, shown, startedId), focus: true }
110}
111
112/** The whole queue, for /after with nothing else. */
113function queueCard (state) {
114 const count = state.items.length
115 const title = count ? `${plural(count, 'task')} queued` : 'Queue empty'
116 const detail = state.paused
117 ? 'paused · /after resume to run them'
118 : state.running || state.busy ? 'they run when Claude finishes' : count ? 'Claude is idle · /after resume starts them' : undefined
119 const rows = [
120 ...state.done.slice(0, MAX_DONE).reverse().map(task => task.status === 'stopped'
121 ? { key: `d${task.id}`, kind: 'stopped', label: '■', text: text(task.text), tag: 'interrupted' }
122 : { key: `d${task.id}`, kind: 'done', label: '✓', text: text(task.text) }),
123 ...(state.running ? [{ key: `r${state.running.id}`, kind: 'running', label: '▶', text: text(state.running.text) }] : []),
124 ...state.items.map((item, i) => ({ key: `i${item.id}`, kind: 'item', label: String(i + 1), text: text(item.text) }))
125 ]
126 return {
127 tone: state.paused ? 'paused' : 'info',
128 icon: state.paused ? '⏸' : state.running ? '▶' : '≡',
129 title,
130 detail,
131 rows: cap(rows),
132 hint: HINT
133 }
134}
135
136function describeChanges (outcome, shown, startedId) {
137 const position = (id) => shown.items.findIndex(item => item.id === id) + 1
138 const parts = []
139 const adds = outcome.changes.filter(change => change.type === 'add')
140 if (adds.length === 1) {
141 const [add] = adds
142 const at = position(add.id)
143 if (add.id === startedId) parts.push('Starting now')
144 else if (add.where === 'after' && at > 1) parts.push(`Queued #${at} · right after #${at - 1}`)
145 else if (add.where === 'before' && at < shown.items.length) parts.push(`Queued #${at} · right before #${at + 1}`)
146 else if (add.where === 'start' && shown.items.length > 1) parts.push(`Queued #${at} · next up`)
147 else parts.push(`Queued #${at}`)
148 } else if (adds.length > 1) {
149 parts.push(`Queued ${plural(adds.length, 'task')}`)
150 }
151 let gone = 0
152 for (const change of outcome.changes) {
153 if (change.type === 'remove') parts.push(`Removed ${plural(change.count, 'task')}`)
154 if (change.type === 'clear') parts.push(`Cleared ${plural(change.count, 'task')}`)
155 if (change.type === 'move') parts.push(`Moved #${change.from} → #${position(change.id)}`)
156 if (change.type === 'edit') parts.push(`Edited #${position(change.id)}`)
157 if (change.type === 'remove' || change.type === 'clear') gone += change.count
158 }
159 const detail = gone > 0
160 ? `/after undo restores ${gone === 1 ? 'it' : 'them'}`
161 : startedId !== null && !adds.some(add => add.id === startedId) ? 'Claude is idle, so the queue starts now'
162 : startedId !== null ? 'Claude is idle' : undefined
163 return { title: parts.join(' · ') || 'Done', detail }
164}
165
166/** The rows a change touched, with one row of context around each, the rest folded. */
167function changeRows (outcome, shown, startedId) {
168 const { marks, removed, before } = outcome
169 const was = new Map(before.map((item, i) => [item.id, i + 1]))
170
171 /** @type {(Row & { id?: number })[]} */
172 const rows = []
173 if (shown.running) {
174 const isStarting = shown.running.id === startedId
175 rows.push({
176 key: `r${shown.running.id}`,
177 kind: 'running',
178 label: '▶',
179 text: text(shown.running.text),
180 ...(isStarting && {
181 change: 'starting',
182 marker: marks[startedId] === 'new' ? MARKER.new : undefined,
183 tag: '← starting now'
184 })
185 })
186 }
187 shown.items.forEach((item, i) => {
188 const change = marks[item.id]
189 const row = { key: `i${item.id}`, id: item.id, kind: 'item', label: String(i + 1), text: text(item.text) }
190 if (change === 'new') Object.assign(row, { change, marker: MARKER.new, tag: '← new' })
191 if (change === 'edited') Object.assign(row, { change, marker: MARKER.edited, tag: '← edited' })
192 if (change === 'moved') Object.assign(row, { change, marker: MARKER.moved, tag: `← was #${was.get(item.id)}` })
193 rows.push(row)
194 })
195
196 // A removed task shows, struck out, where it used to be
197 const present = new Set(shown.items.map(item => item.id))
198 const order = [...removed].sort((a, b) => (was.get(a.id) ?? 0) - (was.get(b.id) ?? 0))
199 for (const task of order) {
200 const index = was.has(task.id) ? was.get(task.id) - 1 : before.length
201 const successor = before.slice(index + 1).find(item => present.has(item.id))
202 const at = successor ? rows.findIndex(row => row.id === successor.id) : rows.length
203 rows.splice(at === -1 ? rows.length : at, 0,
204 { key: `x${task.id}`, kind: 'removed', label: '✕', text: text(task.text), change: 'removed', marker: MARKER.removed })
205 }
206
207 const visible = new Set()
208 rows.forEach((row, i) => {
209 if (row.change) for (let j = i - 1; j <= i + 1; j++) if (j >= 0 && j < rows.length) visible.add(j)
210 })
211 // Folding a single row saves nothing: show it
212 rows.forEach((_, i) => {
213 const alone = (i === 0 || visible.has(i - 1)) && (i === rows.length - 1 || visible.has(i + 1))
214 if (!visible.has(i) && alone && visible.size > 0) visible.add(i)
215 })
216 const folded = []
217 let hidden = 0
218 rows.forEach((row, i) => {
219 if (!visible.has(i)) return void hidden++
220 if (hidden) folded.push(more(hidden, i))
221 hidden = 0
222 const { id: _, ...plain } = row
223 folded.push(plain)
224 })
225 if (hidden) folded.push(more(hidden, rows.length))
226 return cap(folded)
227}
228
229const more = (count, at) => ({ key: `m${at}`, kind: 'more', label: '…', text: `${count} more` })
230
231/** Keeps a card short: past MAX_ROWS, the rest folds into one "… n more" row. */
232function cap (rows) {
233 if (rows.length <= MAX_ROWS) return rows
234 const kept = rows.slice(0, MAX_ROWS - 1)
235 const rest = rows.slice(MAX_ROWS - 1)
236 const count = rest.reduce((sum, row) => sum + (row.kind === 'more' ? Number.parseInt(row.text, 10) || 0 : 1), 0)
237 return [...kept, more(count, 'end')]
238}
239
240/**
241 * The card as plain text, the way the terminal draws it: for tests, the README and any
242 * surface that cannot draw it.
243 * @param {Card} card
244 */
245export function cardText (card) {
246 const width = Math.max(1, ...card.rows.map(row => row.label.length))
247 const body = [`${card.icon} ${card.title}${card.detail ? ` · ${card.detail}` : ''}`]
248 if (card.rows.length) body.push('')
249 for (const row of card.rows) {
250 body.push(`${row.marker ?? ' '} ${row.label.padStart(width)} ${row.text}${row.tag ? ` ${row.tag}` : ''}`)
251 }
252 for (const section of card.sections ?? []) {
253 const column = Math.max(...section.lines.map(([command]) => command.length))
254 body.push('')
255 if (section.heading) body.push(section.heading)
256 for (const [command, description] of section.lines) body.push(` ${command.padEnd(column)} ${description}`)
257 }
258 if (card.hint) body.push('', card.hint)
259
260 const badge = card.badge === 'haiku' ? ' ✦ Haiku ' : card.badge === 'local' ? ' ✦ matched locally ' : ''
261 const inner = Math.max(...body.map(line => line.length), ' After '.length + badge.length + 4)
262 const top = `╭─ After ${'─'.repeat(inner - ' After '.length - badge.length)}${badge}─╮`
263 return [top, ...body.map(line => `│ ${line.padEnd(inner)} │`), `╰${'─'.repeat(inner + 2)}╯`].join('\n')
264}
265src/ui/format.js 9 lines1/** Collapses whitespace and truncates with an ellipsis. */
2export const shorten = (text, max = 60) => {
3 const value = String(text).replace(/\s+/g, ' ').trim()
4 return value.length > max ? `${value.slice(0, max - 1)}…` : value
5}
6
7export const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
8
9src/ui/render.js 27 lines1// Plain-text lines the pane shares: the last change and what the queue will do next.
2// What /after prints in the transcript is a card (card.js).
3
4const ICON = { ok: '✓', info: '·', error: '✗' }
5
6/**
7 * @param {{ kind: 'ok' | 'info' | 'error', text: string } | null} message
8 * @param {{ via?: string | null, note?: string }} [options]
9 */
10export function renderMessage (message, { via = null, note = '' } = {}) {
11 if (!message?.text) return ''
12 const [first, ...rest] = message.text.split('\n')
13 const lines = [`${ICON[message.kind]} ${first}`, ...rest.map(line => ` ${line.trimStart()}`)]
14 if (via === 'haiku' && message.kind !== 'info') lines.push(` ✦ Haiku${note ? `: ${note}` : ''}`)
15 if (via === 'heuristics' && message.kind !== 'info') lines.push(' ✦ matched locally')
16 return lines.join('\n')
17}
18
19/** One line on what the queue will do next. */
20export function describeStatus (state) {
21 if (state.paused) return { tone: 'yellow', text: '⏸ paused · queued tasks wait until you resume' }
22 if (state.running) return { tone: 'green', text: '▶ Claude is working on a queued task' }
23 if (state.busy) return { tone: 'green', text: '● Claude is working · queued tasks run when it finishes' }
24 if (state.items.length === 0) return { tone: 'dim', text: 'Claude is idle · nothing queued' }
25 return { tone: 'yellow', text: 'Claude is idle · /after resume starts the queue' }
26}
27src/smart/heuristics.js 165 lines1// Local, instant understanding of natural-language queue commands.
2// Used to decide when Haiku is worth calling and as the fallback when it is not available.
3import { shorten } from '../ui/format.js'
4import { bestMatch, keywords, normalize, wordSimilarity } from './text.js'
5
6const REMOVE_RE = /^\s*(?:remove|delete|drop|cancel|discard|forget(?:\s+about)?|skip|scrap|rm|del|don'?t\s+do|no\s+hagas|quita(?:r|me)?|elimina(?:r)?|borra(?:r)?|cancela(?:r)?|olv[ií]da(?:te)?(?:\s+de)?|descarta(?:r)?|saca(?:r|me)?)\s+(.+)$/iu
7const MOVE_RE = /^\s*(?:move|mueve|put|pon|prioriti[sz]e|prioriza|bump|send|manda)\s+(.+?)\s+(?:to\s+(?:the\s+)?|al\s+|a\s+la\s+|a\s+|hasta\s+(?:el\s+|la\s+|arriba|abajo)?)?(first|top|front|start|beginning|next|primer[oa]|principio|inicio|arriba|last|end|bottom|final|[uú]ltim[oa]|abajo|fondo)\s*$/iu
8const EDIT_RE = /^\s*(?:change|rename|rewrite|replace|edit|cambia|renombra|reescribe|reemplaza|edita)\s+(.+?)\s+(?:to|with|por|con|->|=>)\s+(.+)$/iu
9const EVERYTHING_RE = /^(?:all|everything|todo|todas|todos)(?:\s+(?:the|las|los))?(?:\s+(?:tasks|tareas))?$/i
10const TO_END = new Set(['last', 'end', 'bottom', 'final', 'ultimo', 'ultima', 'abajo', 'fondo'])
11const ORDINALS = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, primero: 1, primera: 1, primer: 1, segundo: 2, segunda: 2, tercero: 3, tercera: 3, tercer: 3, cuarto: 4, cuarta: 4, quinto: 5, quinta: 5 }
12const ORDINAL_RE = /^(?:the\s+|el\s+|la\s+)?(\w+)(?:\s+(?:one|task|item|tarea))?$/
13
14/** "the second one", "la última" → the task at that position. */
15function byOrdinal (target, items) {
16 const word = ORDINAL_RE.exec(normalize(target))?.[1]
17 if (!word) return null
18 if (TO_END.has(word)) return items.at(-1) ?? null
19 return ORDINALS[word] ? items[ORDINALS[word] - 1] ?? null : null
20}
21
22const CONNECTORS = ['and then', 'y luego', 'y despues', 'then', 'luego', 'despues', 'entonces', '->', '=>', '-', '—', ',', ':', ';']
23 .map(connector => connector.split(' '))
24const ACTION_VERBS = new Set([
25 'do', 'make', 'write', 'add', 'create', 'update', 'run', 'fix', 'refactor', 'remove', 'delete',
26 'test', 'document', 'generate', 'implement', 'check', 'review', 'commit', 'push', 'deploy', 'build',
27 'rename', 'move', 'clean', 'bump', 'publish', 'release', 'prepare', 'set', 'install', 'upgrade',
28 'migrate', 'translate', 'explain', 'verify', 'lint', 'format', 'open', 'ask', 'summarize', 'improve',
29 'haz', 'hacer', 'crea', 'crear', 'anade', 'anadir', 'escribe', 'actualiza', 'ejecuta', 'corre',
30 'arregla', 'revisa', 'genera', 'implementa', 'elimina', 'borra', 'documenta', 'prueba', 'refactoriza',
31 'sube', 'despliega', 'publica', 'prepara', 'instala', 'migra', 'traduce', 'explica', 'comprueba',
32 'verifica', 'limpia', 'mueve', 'renombra', 'pasa', 'lanza', 'mejora', 'resume', 'abre', 'pon'
33])
34
35const MANAGEMENT_RE = /^\s*(?:remove|delete|drop|cancel|discard|forget|skip|scrap|rm|del|don'?t|no\s+hagas|quita\w*|elimina\w*|borra\w*|cancela\w*|olv[ií]da\w*|descarta\w*|saca\w*|move|mueve|put|pon|prioriti[sz]e|prioriza|bump|send|manda|change|rename|rewrite|replace|edit|cambia|renombra|reescribe|reemplaza|edita)(?=\s)/iu
36
37/** Adds the task where the command puts new tasks by default. */
38export const defaultOps = (command, text) => [{
39 type: 'add',
40 text: text.trim(),
41 where: { kind: command === 'before' ? 'start' : 'end' }
42}]
43
44/** Starts like a queue-management instruction ("remove …", "move …", "cambia …"). */
45export const looksLikeManagement = (text) => MANAGEMENT_RE.test(text)
46
47/** Shares at least one meaningful word with a queued task. */
48export function mentionsQueuedTask (text, items) {
49 const words = keywords(text)
50 return items.some(item => keywords(item.text).some(other => words.some(word => wordSimilarity(word, other) >= 0.8)))
51}
52
53/**
54 * A removal aimed at a task that already left the queue ("remove the tests" once the tests
55 * task started). Queuing that text as a new task could be destructive, so it is refused.
56 * `started` lists the running task and recently finished ones, newest first.
57 */
58export function removalOfStartedTask (text, started = []) {
59 const match = text.match(REMOVE_RE)
60 if (!match || started.length === 0) return null
61 return bestMatch(match[1], started, { min: 0.6, margin: 0 })?.item ?? null
62}
63
64export const describeStarted = (task) => task.status === 'running'
65 ? `“${shorten(task.text, 48)}” already started, so it is not in the queue anymore (press Esc in Claude Code to stop it)`
66 : `“${shorten(task.text, 48)}” already ran, so there is nothing to remove`
67
68/** Whether the command could be about tasks already in the queue (and so deserves Haiku). */
69export const needsSmart = (text, items, started = []) =>
70 (items.length > 0 && (looksLikeManagement(text) || mentionsQueuedTask(text, items))) ||
71 removalOfStartedTask(text, started) !== null
72
73function detectRemoval (text, items) {
74 const match = text.match(REMOVE_RE)
75 if (!match) return null
76 const target = match[1].trim()
77 if (EVERYTHING_RE.test(normalize(target))) return [{ type: 'clear' }]
78 const item = byOrdinal(target, items) ?? bestMatch(target, items, { min: 0.6 })?.item
79 return item ? [{ type: 'remove', ids: [item.id] }] : null
80}
81
82function detectMove (text, items) {
83 const match = text.match(MOVE_RE)
84 if (!match) return null
85 const found = bestMatch(match[1], items, { min: 0.6 })
86 if (!found) return null
87 const where = { kind: TO_END.has(normalize(match[2])) ? 'end' : 'start' }
88 return [{ type: 'move', id: found.item.id, where }]
89}
90
91function detectEdit (text, items) {
92 const match = text.match(EDIT_RE)
93 if (!match) return null
94 const found = bestMatch(match[1], items, { min: 0.75 })
95 return found ? [{ type: 'edit', id: found.item.id, text: match[2].trim() }] : null
96}
97
98/** Removes a leading connector left over from a reference: "then write docs" → "write docs". */
99export function trimConnector (text) {
100 const rest = stripConnector(text.trim().split(/\s+/))
101 return rest?.length ? rest.join(' ') : text.trim()
102}
103
104/** Drops a leading connector ("then", "y luego", "->", ",") and returns the remaining words, or null. */
105function stripConnector (words) {
106 const head = words.map(word => normalize(word) || word)
107 const connector = CONNECTORS.find(parts => parts.every((part, i) => head[i] === part))
108 return connector ? words.slice(connector.length) : null
109}
110
111/** "/after tests, write the README" → add "write the README" right after the tests task. */
112function detectRelative (command, text, items) {
113 const words = text.trim().split(/\s+/)
114 let best = null
115
116 for (let size = 1; size <= Math.min(6, words.length - 1); size++) {
117 let left = words.slice(0, size).join(' ')
118 let rest = words.slice(size)
119 let linked = false
120
121 if (/[,:;]$/.test(left)) {
122 left = left.replace(/[,:;]+$/, '')
123 linked = true
124 }
125 const afterConnector = stripConnector(rest)
126 if (afterConnector) {
127 rest = afterConnector
128 linked = true
129 }
130 if (rest.length === 0) continue
131 if (!linked && !ACTION_VERBS.has(normalize(rest[0]))) continue
132
133 const found = bestMatch(left, items, { min: 0.75 })
134 if (found && (!best || found.score > best.found.score)) best = { found, rest: rest.join(' ') }
135 }
136
137 if (!best) return null
138 return [{
139 type: 'add',
140 text: best.rest,
141 where: { kind: command === 'before' ? 'before' : 'after', id: best.found.item.id }
142 }]
143}
144
145/**
146 * The task in "<words pointing at anchor> <task>" ("decirme de que va el proyecto ejecuta los
147 * tests" with "de que va el proyecto?" as the anchor), or null when the text does not start
148 * with a reference to it.
149 */
150export const taskAfterReference = (text, anchor) => detectRelative('after', text, [anchor])?.[0].text ?? null
151
152/** Best-effort resolution without any model. Falls back to a plain add. */
153export function resolveHeuristically ({ command, text, items, started = [] }) {
154 if (items.length > 0) {
155 const ops = detectRemoval(text, items) ??
156 detectMove(text, items) ??
157 detectEdit(text, items) ??
158 detectRelative(command, text, items)
159 if (ops) return { ops, via: 'heuristics' }
160 }
161 const late = removalOfStartedTask(text, started)
162 if (late) return { ops: [], via: 'heuristics', note: describeStarted(late) }
163 return { ops: defaultOps(command, text), via: null }
164}
165src/smart/protocol.js 153 lines1// The contract with Haiku: what we ask, the JSON shape we accept and how we validate it.
2// Haiku only ever sees queue positions; we map them back to stable ids ourselves.
3
4export const SYSTEM_PROMPT = `You turn a user's queue command into JSON operations for "After", a FIFO task queue for a coding agent.
5
6Commands:
7- "/after <text>": by default, queue <text> as a new task at the END.
8- "/before <text>": by default, queue <text> as a new task at the START (it runs next).
9
10<text> may instead refer to tasks already in the queue (numbered in run order):
11- Relative placement: "/after tests, write a README" -> add "write a README" right after the queued task about tests. "/before deploy bump the version" -> add "bump the version" right before the deploy task.
12- Removal: remove / delete / drop / cancel / skip / quita / elimina / borra + a reference -> remove the matching task(s).
13- Reordering: "move <reference> first / last / after <other> / before <other>".
14- Editing: "change <reference> to <new text>".
15
16People refer to queued tasks loosely: by one distinctive word ("the beta one", "lo de los tests"), by position ("the second one", "the last one") or in another language. A removal, move or edit verb followed by such a reference is a queue operation.
17
18Examples, with the queue "1. reply with exactly ALPHA 2. reply with exactly BETA 3. add tests":
19- "/after remove the beta one" -> remove 2
20- "/after quita lo de los tests" -> remove 3
21- "/after drop the second one" -> remove 2
22- "/after alpha, then write docs" -> add "write docs" after 1
23- "/before tests bump the version" -> add "bump the version" before 3
24- "/before añadir los tests corre el linter" -> add "corre el linter" before 3 ("añadir los tests" only points at task 3)
25- "/after decirme lo de beta, revisa los logs" -> add "revisa los logs" after 2
26- "/before beta, add more tests" -> add "add more tests" before 2 (the leading words point at task 2; the new task resembling task 3 does not matter)
27- "/after move tests first" -> move 3 to start
28- "/after remove the unused dependencies" -> add "remove the unused dependencies" at the end (no queued task is about dependencies)
29
30Rules:
31- If the command refers to a task that already started or finished (listed separately, it can no longer be changed), return no operations and explain that in "note".
32- If nothing in the queue matches, the whole text is a new task, even when it starts with a verb like "remove" or "move".
33- Keep the user's wording and language for task text. Only strip the words that locate it in the queue, including connectors such as "then", "y luego" or a leading comma.
34- In a relative placement, the words that point at the anchor task are never part of the new task, even when they paraphrase it ("decirme de qué va el proyecto" for "de que va el proyecto?") and even with no comma: the task text starts where the new instruction starts, usually at its verb.
35- The anchor is the task the leading words point at, never a task the new task merely resembles. A relative "/before" goes right before that task, not at the start of the queue.
36- Never invent tasks and never drop details from the task text.
37- Use "note" for a very short, plain explanation of what you understood (same language as the user).`
38
39const POSITION = { type: 'integer', minimum: 1 }
40
41export const RESPONSE_SCHEMA = {
42 type: 'object',
43 properties: {
44 ops: {
45 type: 'array',
46 maxItems: 10,
47 items: {
48 type: 'object',
49 properties: {
50 op: { type: 'string', enum: ['add', 'remove', 'move', 'edit'] },
51 text: { type: 'string', description: 'Task text for add and edit' },
52 target: { ...POSITION, description: 'Queue number the remove, move or edit acts on' },
53 position: { type: 'string', enum: ['end', 'start', 'after', 'before'], description: 'Where add or move puts the task' },
54 anchor: { ...POSITION, description: 'Queue number used by position "after" or "before"' }
55 },
56 required: ['op'],
57 additionalProperties: false
58 }
59 },
60 note: { type: 'string' }
61 },
62 required: ['ops'],
63 additionalProperties: false
64}
65
66/** The system prompt sent with every request: the rules plus the exact reply format. */
67export const SYSTEM = `${SYSTEM_PROMPT}
68
69Reply with one JSON object and nothing else (no prose, no code fence). It must match this JSON schema:
70${JSON.stringify(RESPONSE_SCHEMA)}`
71
72/**
73 * Pulls the JSON object out of the model's reply text. Tolerates a code fence or a stray
74 * sentence around it; returns null when there is no object to read.
75 */
76export function parseReply (text) {
77 const value = String(text ?? '')
78 const start = value.indexOf('{')
79 const end = value.lastIndexOf('}')
80 if (start === -1 || end < start) return null
81 try {
82 const parsed = JSON.parse(value.slice(start, end + 1))
83 return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
84 } catch {
85 return null
86 }
87}
88
89const oneLine = (text) => text.replace(/\s+/g, ' ')
90
91export function buildUserMessage ({ command, text, items, started = [] }) {
92 const queue = items.length ? items.map((item, i) => `${i + 1}. ${oneLine(item.text)}`).join('\n') : '(empty)'
93 const history = started.length
94 ? `\n\nAlready started or finished (cannot be changed):\n${started.map(task => `- ${oneLine(task.text)} (${task.status === 'running' ? 'running' : 'finished'})`).join('\n')}`
95 : ''
96 return `Command: /${command} ${text}\n\nQueue:\n${queue}${history}`
97}
98
99/**
100 * Validates Haiku's answer against the queue snapshot it saw and converts it into
101 * internal operations. Returns null when anything is off, so callers can fall back.
102 */
103export function parseResponse (response, items) {
104 if (!response || !Array.isArray(response.ops)) return null
105 // No operations is a valid answer (for example: that task already ran), as long as it says why
106 if (response.ops.length === 0) return typeof response.note === 'string' && response.note.trim() ? [] : null
107 const idAt = (position) => Number.isInteger(position) ? items[position - 1]?.id ?? null : null
108
109 const placement = ({ position, anchor }, fallback) => {
110 if (!position) return fallback
111 if (position === 'end' || position === 'start') return { kind: position }
112 const id = idAt(anchor)
113 return id === null ? undefined : { kind: position, id }
114 }
115
116 const ops = []
117 for (const raw of response.ops) {
118 const text = typeof raw?.text === 'string' ? raw.text.trim() : ''
119 switch (raw?.op) {
120 case 'add': {
121 const where = placement(raw, { kind: 'end' })
122 if (!text || !where) return null
123 ops.push({ type: 'add', text, where })
124 break
125 }
126 case 'remove': {
127 const id = idAt(raw.target)
128 if (id === null) return null
129 const previous = ops.find(op => op.type === 'remove')
130 if (previous) previous.ids.push(id)
131 else ops.push({ type: 'remove', ids: [id] })
132 break
133 }
134 case 'move': {
135 const id = idAt(raw.target)
136 const where = placement(raw, undefined)
137 if (id === null || !where || where.id === id) return null
138 ops.push({ type: 'move', id, where })
139 break
140 }
141 case 'edit': {
142 const id = idAt(raw.target)
143 if (id === null || !text) return null
144 ops.push({ type: 'edit', id, text })
145 break
146 }
147 default:
148 return null
149 }
150 }
151 return ops
152}
153src/smart/text.js 98 lines1// Tiny, dependency-free fuzzy matching tuned for short task descriptions in English and Spanish.
2
3const STOPWORDS = new Set([
4 // English
5 'a', 'an', 'the', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'at', 'by', 'with', 'from', 'into',
6 'it', 'its', 'this', 'that', 'these', 'those', 'my', 'our', 'your', 'me', 'us', 'please', 'task',
7 'tasks', 'item', 'one', 'thing', 'stuff', 'about', 'some', 'all', 'is', 'be', 'are', 'was',
8 // Spanish
9 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'de', 'del', 'al', 'en', 'con',
10 'para', 'por', 'que', 'lo', 'le', 'les', 'se', 'su', 'sus', 'mi', 'mis', 'tu', 'tus', 'este',
11 'esta', 'esto', 'ese', 'esa', 'eso', 'tarea', 'tareas', 'cosa', 'porfa', 'favor'
12])
13
14const SUFFIXES = [
15 ['ciones', 'cion'], ['ations', 'ate'], ['ation', 'ate'], ['iendo', ''], ['ando', ''],
16 ['mente', ''], ['ings', ''], ['ing', ''], ['ies', 'y'], ['ers', ''], ['es', ''], ['ed', ''],
17 ['er', ''], ['ar', ''], ['ir', ''], ['s', '']
18]
19
20/** Lowercase, strip accents and punctuation, collapse whitespace. */
21export function normalize (text) {
22 return String(text ?? '')
23 .normalize('NFD')
24 .replace(/[̀-ͯ]/g, '')
25 .toLowerCase()
26 .replace(/[^\p{L}\p{N}]+/gu, ' ')
27 .trim()
28}
29
30export const tokenize = (text) => normalize(text).split(' ').filter(Boolean)
31
32export const keywords = (text) => tokenize(text).filter(word => word.length > 1 && !STOPWORDS.has(word))
33
34/** Very small suffix stripper; good enough to match "adding"/"add" or "tests"/"test". */
35export function stem (word) {
36 for (const [suffix, replacement] of SUFFIXES) {
37 if (word.length - suffix.length >= 3 && word.endsWith(suffix)) {
38 return word.slice(0, -suffix.length) + replacement
39 }
40 }
41 return word
42}
43
44const commonPrefix = (a, b) => {
45 let i = 0
46 while (i < a.length && i < b.length && a[i] === b[i]) i++
47 return i
48}
49
50/** 0..1 similarity between two normalized words. */
51export function wordSimilarity (a, b) {
52 if (a === b) return 1
53 const sa = stem(a)
54 const sb = stem(b)
55 if (sa === sb) return 0.9
56 // One word (or its stem) is a prefix of the other: "read"/"readme", "añadir"/"añade"
57 const isPrefix = (x, y) => {
58 const shorter = Math.min(x.length, y.length)
59 return shorter >= 3 && commonPrefix(x, y) === shorter
60 }
61 if (isPrefix(a, b) || isPrefix(sa, sb)) return 0.8
62 const prefix = commonPrefix(a, b)
63 if (prefix >= 5 && prefix >= Math.min(a.length, b.length) * 0.75) return 0.7
64 return 0
65}
66
67const coverage = (from, to) =>
68 from.reduce((sum, word) => sum + Math.max(0, ...to.map(other => wordSimilarity(word, other))), 0) / from.length
69
70/**
71 * How well `query` describes `target` (0..1). Weighted towards the query being fully
72 * explained by the target, because users refer to tasks with a few of their words.
73 */
74export function similarity (query, target) {
75 const q = keywords(query)
76 const t = keywords(target)
77 if (q.length === 0 || t.length === 0) return 0
78 return 0.7 * coverage(q, t) + 0.3 * coverage(t, q)
79}
80
81/** Items sorted by similarity to `query`, best first. */
82export function rank (query, items) {
83 return items
84 .map(item => ({ item, score: similarity(query, item.text) }))
85 .sort((a, b) => b.score - a.score)
86}
87
88/**
89 * The single item `query` clearly refers to, or null when there is no good match
90 * or when two items are too close to call.
91 */
92export function bestMatch (query, items, { min = 0.6, margin = 0.12 } = {}) {
93 const [first, second] = rank(query, items)
94 if (!first || first.score < min) return null
95 if (second && first.score - second.score < margin) return null
96 return first
97}
98