SLOPSHOPPER

after

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…

newpanebandrowscommandtoast
v0.2.0MITupdated 2026-10-05midudev/after-for-claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · after
│ ┃ After ✕ › fix the failing auth test and add an audit log call │ ┃ Claude is idle · nothing queued │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing queued · type a task below ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ +: a task, or rm 2 · mv 3 1 · remove the tes ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ a: add p: pause u: undo ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /after │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · After
Claude is idle · nothing queued Nothing queued · type a task below +: a task, or rm 2 · mv 3 1 · remove the tests one ⏎ queue a: add p: pause u: undo
README

After

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
❯ _
  • No steering. Queued tasks never reach Claude until it finishes what it is doing.
  • You see the queue. Every command answers with a card showing exactly what changed, a band above the prompt shows what runs now and what comes next, and bare /after opens the queue to edit it with the keyboard.
  • Plain language. /after tests, write a README explaining them goes right after the tests task; /after remove the tests one removes it. Haiku figures it out.
  • Nothing to install around it. No Node.js, no API key, no background processes: the mod runs inside Claude Code.

Why

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:

  • Steering (just type) changes what Claude is doing now.
  • After (/after …) is work for later.

Install

/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.md and before.md in ~/.claude/commands. Delete them: the mod registers /after and /before itself, and After tells you at startup if those files are still there. The after CLI for a second terminal is gone too; the pane replaces it.

Usage

CommandWhat 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.
/afterOpen the queue to pick, edit, reorder and remove tasks with the keyboard.
/after listPrint the queue in the transcript.
/after rm 2 · rm 2-4 · rm last · rm allRemove tasks.
/after edit 2 <text>Rewrite a task.
/after mv 3 1 · mv 3 last · top 3Reorder.
/after pause · /after resumeHold the queue / let it run again.
/after undoUndo the last change.
/after clearEmpty the queue.
/after helpAll 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.

Plain language

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".

Editing the queue

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.

How it works

After is one hooks module (hooks/register.tsx) running inside Claude Code. Nothing patches Claude Code and nothing runs outside it.

  1. /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.
  2. The queue moves when a turn ends. On 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.
  3. Haiku through $.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.
  4. One queue per session, held in $.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.

Configuration

/config lists After's options (or set them under pluginConfigs.after.options in settings.json):

OptionDefault
Plain-language commands (smart)onOff uses local matching only, never the model.
Model (model)haikuModel for plain-language commands.
Where the queue shows (display)bandband above the prompt, status as one line under it, or none.

Good to know

  • Esc interrupts the running task as usual; the queue waits until the next turn ends normally. Use /after pause to hold it.
  • If Claude is idle, /after <task> starts the task right away. To build a queue first, /after pause, add your tasks, then /after resume.
  • Queued tasks arrive as your own message, marked in the transcript as coming from the After plugin.

Development

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.

License

MIT © Miguel Ángel Durán (midudev)

Source 13 files
hooks/register.tsx 639 lines
1// 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}
639
src/commands/apply.js 195 lines
1// 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}
195
src/commands/execute.js 39 lines
1// 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}
39
src/commands/parse.js 129 lines
1// 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}
129
src/core/queue.js 172 lines
1// 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}
172
src/smart/resolve.js 80 lines
1import { 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}
80
src/ui/card.js 265 lines
1// 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}
265
src/ui/format.js 9 lines
1/** 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
9
src/ui/render.js 27 lines
1// 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}
27
src/smart/heuristics.js 165 lines
1// 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}
165
src/smart/protocol.js 153 lines
1// 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}
153
src/smart/text.js 98 lines
1// 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