SLOPSHOPPER

next-up

Keeps the follow-ups Claude suggests in one backlog shared by every session, and a checklist Claude keeps for multi-step work in each session, each with a…

newpanebandspinnerguardcommand
★ 1v1.0.1MITupdated 2026-10-09aosmcleod/next-up-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · next-up
│ ┃ Next Up ✕ › fix the failing auth test and add an audit log call │ ┃ [ ★ 0 ] [ All 0 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing here yet. Claude adds tasks as it ⎿ Read 6 lines │ ┃ suggests them. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /next │ ⎿ next-up: Next Up pane toggled. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ Tasks ↗ ] [ Next Up ↗ ]

Draws

Pane · Next Up
[ ★ 0 ] [ All 0 ] Nothing here yet. Claude adds tasks as it suggests them.
Pane · next-up-tasks
[ To Do 0 ] [ Done 0 ] Nothing to do. Claude lists the steps here when work takes more than one.
README

Next Up

Keep the work Claude finds. See the work Claude is doing.

A Claude Code mod with two parts. Next Up collects the follow-ups Claude suggests in passing into one backlog shared by every session and project. Tasks has Claude keep a checklist for multi-step work in the session you are in, which you can watch and edit.

Licence: MIT Claude Code 2.1.287+ macOS · Windows

Next UpTasks
The Next Up pane beside a Claude Code session, listing backlog tasks for a project with Suggested, Added and Plan labelsThe Tasks pane beside the same session, with two steps still to do

Why

Claude is good at noticing work outside the task in front of it: a bug spotted in passing, a missing test, a docs gap, the refactor that should happen later. It offers these as suggestion chips or as a closing paragraph, and then the session ends and they are gone. Next Up keeps them, each with a prompt another session can act on without the conversation it came from.

Inside a session, the opposite problem: a long piece of work has steps, and it is hard to see which are done, which are next, and whether Claude has quietly dropped one. Tasks puts them in a list beside the conversation, kept by Claude as it works.


Install

Claude Code 2.1.287 or later (mods are on by default from that version).

/plugin marketplace add aosmcleod/next-up-mod
/plugin install next-up@next-up-mod

Or from a shell:

claude plugin marketplace add aosmcleod/next-up-mod
claude plugin install next-up@next-up-mod

Then start a new session, or run /reload-plugins in an open one. Two buttons appear in the footer below the prompt, each with its count: Tasks ↗ and Next Up ↗.

The Claude Code prompt box with '2 Tasks' and '5 Next Up' buttons in the footer below it

Turning a part off

Both parts are on by default. Switch either off in /config (Next Up backlog, Session tasks), with /plugin configure next-up@next-up-mod, or from a shell:

echo '{"tasks": "false"}' | claude plugin configure next-up@next-up-mod --values-stdin

Use "backlog" for Next Up, and "true" to switch a part back on. Restart Claude Code to apply a change. With a part off, its button, pane, tools and guidance are all gone; with both off, the mod does nothing.


Next Up: the backlog

Tasks come in by themselves

SourceLabelHow
Claude's suggestion chipsSuggested (purple)Whenever Claude offers a background task, it goes to the backlog instead; no chip shows in the chat
Claude, on its own or when you askAdded (blue)Claude is told about the backlog and given an add_tasks tool
/next plan <goal>Plan 2/5 (amber)Claude breaks the goal into ordered steps and files each one, without starting the work

A task that repeats an open one in the same project, word for word or nearly, is merged into it rather than listed twice.

Starting one

Hover a card for its buttons:

  • ↙ Now puts the task's prompt in this session's prompt box, unsent, so you can edit it.
  • New ↗ opens a new desktop session in the task's project with the prompt ready in its box (desktop app only; it takes a few seconds to appear).
  • ★ marks a favourite, which sorts first everywhere. The trash can drops the task.

When you send a prompt that still carries an open task's opening, in a session in that task's project, the task is marked done.

Where to see them

  • The footer button shows how many tasks are open for the session's project, and opens or closes the pane.
  • The pane has three tabs: ★ favourites, this project, and All (grouped by project).
  • The band (/next band) lists the project's top three above the prompt until you send your next message.

Tasks: the session list

For work that takes two or more steps, Claude plans the steps before it starts, marks each one done as it finishes, and adds, rewords or removes steps as the work changes. Before it ends a turn it checks the list: if steps remain and nothing blocks them, it carries on; otherwise it says which remain and why. Questions and one-step changes get no list.

  • The footer button shows how many steps are left, and opens or closes the Tasks pane.
  • The pane has two tabs, To Do in Claude's planned order and Done with the newest first. To Do cards are labelled To do with when they were planned; done ones are struck through and labelled Done with when they were finished.
  • Your edits: hover a To Do card to mark it ✓ Done or remove it. Claude is told what you changed with your next message, and treats it as decided.

The list belongs to the session and lasts as long as it does; it is not saved to disk.


Commands and tools

CommandDoes
/nextOpens or closes the Next Up pane (the Tasks pane, with Next Up off)
/next tasksOpens or closes the Tasks pane
/next list · /next allLists open backlog tasks for this project, or for every project
/next bandShows the project's top three backlog tasks above the prompt
/next plan <goal>Asks Claude to plan the goal into the backlog as ordered steps

Claude's tools: add_tasks, list_tasks and update_task for the backlog (so you can just ask: "add that to Next Up", "what's in Next Up for this repo?"), and update_session_tasks for Tasks (add, reword, mark done, remove).


What it touches

  • Data: the backlog lives in ~/.claude/next-up/log/<session>.jsonl, one append-only log per session. Each session writes only its own file and reads them all, so open sessions never overwrite each other. Plain JSON lines, safe to read or delete. Tasks are kept in memory only.
  • What it reads: the suggestion chips Claude offers, calls to its own tools, and the prompts you send (only to match them against open tasks, on your machine).
  • What it tells Claude: a short guide to the backlog and the session list. In the terminal it is part of the system prompt; in the desktop app, where a mod starts after the system prompt is built, it goes along with your first message instead. While session steps remain, the open ones go along with each message you send, and so do your edits from the Tasks pane. None of this appears in the conversation.
  • What it changes: with Tasks on, Claude Code's own to-do reminders are left out, so Claude keeps a single list. The add_tasks and update_session_tasks tools are listed for Claude up front rather than behind tool search.
  • What it runs: git rev-parse to find the session's project, uname once per session outside Windows, and open (macOS) or rundll32 (Windows) for New.
  • Network: none. Nothing leaves your machine.

Known limits

  • New needs the Claude desktop app, which runs on macOS and Windows. On Linux, use Now. On Windows a new session can take 5 to 10 seconds to appear, because the link is handed to a second app instance.
  • Suggestion chips are a desktop feature. In the terminal, backlog tasks come in through Claude's add_tasks tool and /next plan.
  • The band is off until you ask for it. On desktop, no mod runs until a session's first message, so a list shown at session start would vanish as soon as it appeared.
  • Colours are tuned for dark themes. The card hover and tray use fixed dark colours.
  • Footer buttons look like the app's, not like Next Up's. The desktop app draws every mod button in the footer in its own one-line style.
  • Backlog logs are never compacted. A log only grows while its session adds or changes tasks, and only logs that changed are read again, so this stays small in practice.

Development

git clone https://github.com/aosmcleod/next-up-mod
claude plugin marketplace add ./next-up-mod
claude plugin install next-up@next-up-mod

Installing copies the plugin into ~/.claude/plugins/cache/next-up-mod/next-up/<version>/, and sessions load that copy, even though claude plugin list reports the checkout as where it is read from. After an edit, reinstall (claude plugin uninstall next-up@next-up-mod, then install again) and start a new session or run /reload-plugins.

Checks, from the repo root:

claude plugin validate . --strict
claude plugin test .

tsc -p . --noEmit also works once Claude Code has loaded the plugin, which writes the engine's type declarations to .claude-plugin/types/ (gitignored).

PathHolds
hooks/register.tsxThe hooks: capture, the footer, panes and band, tools, guidance and /next
hooks/store.tsThe backlog's logic: folding logs, duplicates, labels, ages, sent prompts
hooks/store-io.tsReading and appending the per-session logs
hooks/capture.tsTurning chips and add_tasks input into backlog tasks
hooks/view.tsWhat the backlog's footer button, pane and band show
hooks/session.tsThe session task list: changes, what Claude is told, the Tasks pane
types/index.d.tsThe task models and the state the drawings read
tests/claude plugin test suites for the plain modules

Author

Built by Alec McLeod (@aosmcleod), a product manager who kept losing Claude's best ideas at the end of a session.

Issues and pull requests are welcome.


Licence

MIT.

Source 7 files
hooks/register.tsx 852 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Project, SessionTask, Tab, Task, TasksTab } from '../types'
5import { listText, taskFromChip, tasksFromAddInput } from './capture'
6import { applyChanges, editsNote, openReminder, sessionText, tasksLabel, tasksView } from './session'
7import type { SessionChanges, UserEdits } from './session'
8import type { AddInput, Stamp } from './capture'
9import { ageOf, eventForIncoming, fold, labelOf, submittedTask } from './store'
10import type { TaskEvent } from './store'
11import { appendEvents, logsSignature, readEvents } from './store-io'
12import type { Files, LogCache } from './store-io'
13import { bandTasks, cap, detailCap, footerLabel, labelColour, paneView, AMBER } from './view'
14
15// Next Up's hooks, for its two features (each can be switched off in /config):
16//
17// - The backlog: follow-ups come in from Claude's suggestion chips and its
18//   add_tasks tool, are kept in per-session logs shared by every session, and
19//   are shown by a footer button, a pane and (on request) a band above the
20//   prompt. Sending a task's prompt marks it done.
21// - Tasks: a list Claude keeps for multi-step work in this session through
22//   update_session_tasks, shown by a footer button and a pane. The user's edits
23//   there reach Claude with their next prompt.
24//
25// Desktop limits this works within: cards are plain trees, not Client regions
26// (each Client boots its own frame, so a pane of them is slow to appear); only
27// native Buttons take clicks and they cannot be coloured; every state write
28// redraws every site, so state is written only when it changes.
29
30const PLUGIN = 'next-up'
31const PANE = 'next-up'
32const TASKS_PANE = 'next-up-tasks'
33const TOOL = (name: string) => `mcp__${PLUGIN}__${name}`
34const POLL_MS = 30_000
35const NEW_PROMPT_MAX = 4000
36
37const HOVER = '#2a2a2a'
38// Fully transparent, so a card's round border can sit there at rest unseen.
39const CLEAR = '#00000000'
40const TRAY = '#1f1f1f'
41const RULE = '#4a4a4a'
42const MUTED_RED = '#c0605a'
43const GREEN = '#6fbf73'
44// The trash can as a text glyph (U+FE0E asks for text, not emoji, presentation),
45// so it takes the button's hover colour.
46const TRASH = '\u{1F5D1}\uFE0E'
47// The band's Now slot: wide enough for the "Now ↓" button.
48const BAND_NOW_COLUMNS = 8
49// On hover a card's text fades to this, behind its buttons.
50const FADE = { color: '#5e5e5e', dimColor: false }
51
52// What Claude is told, per feature: file worthwhile follow-ups in the backlog
53// instead of trailing them at the end of a reply; keep a list for multi-step
54// work in the session.
55const NUDGE_BACKLOG = [
56  '# Next Up',
57  'The user keeps a shared backlog of follow-up work across their Claude sessions, called Next Up.',
58  `When you notice worthwhile work outside the current task (a bug spotted in passing, a missing test, a docs gap, a refactor worth doing later), add it with the ${TOOL('add_tasks')} tool rather than only mentioning it.`,
59  'Give each task a short imperative title and a self-contained prompt another session could act on without this conversation.',
60  'Do not add the current task, trivial items, or anything the user has just declined. Do not announce that you added them.',
61  `When the user asks you to add something to Next Up, or to plan work into it, use ${TOOL('add_tasks')}; for a plan, pass a plan title and list the steps in order.`,
62].join('\n')
63
64const NUDGE_TASKS = [
65  '# Session tasks',
66  `For work in this session that takes two or more steps, keep a task list with ${TOOL('update_session_tasks')}. The user watches it in a Tasks pane.`,
67  '- Before starting, add the steps you plan from the request.',
68  '- Work through it in order. When a step is finished, mark it done right away, then take the next one.',
69  '- Keep it true: add steps as you find them, reword ones that changed, remove ones that no longer apply.',
70  '- Before ending your turn, check the list. If steps remain and nothing blocks them, carry on; otherwise say which remain and why.',
71  '- Skip it for questions and single-step changes. Use it instead of any other todo or task-list tool, so there is one list.',
72  '- While steps remain, the open ones come with each user message. The user can also mark steps done or remove them; you are told with their next message. Treat those changes as decided.',
73].join('\n')
74
75// The two features, each switched in the plugin's settings (/config). Read
76// when the module loads, so a change applies from the next session or reload.
77const features = { backlog: true, tasks: true }
78
79// The guidance for the features switched on, narrowed to the tools a request
80// offers when that is known (a subagent may be given fewer).
81function nudgeText(tools?: readonly string[]): string {
82  const offers = (name: string) => !tools || tools.includes(TOOL(name))
83  const backlog = features.backlog && offers('add_tasks') ? NUDGE_BACKLOG : ''
84  const tasks = features.tasks && offers('update_session_tasks') ? NUDGE_TASKS : ''
85
86  return [backlog, tasks].filter(Boolean).join('\n\n')
87}
88
89const tasksAtom = atom({ plugin: 'next-up', key: 'tasks' } as const, [] as Task[])
90const projectAtom = atom({ plugin: 'next-up', key: 'project' } as const, null as Project)
91const tabAtom = atom({ plugin: 'next-up', key: 'tab' } as const, 'project' as Tab)
92const bandAtom = atom({ plugin: 'next-up', key: 'band' } as const, false)
93const sessionAtom = atom({ plugin: 'next-up', key: 'session' } as const, [] as SessionTask[])
94const tasksTabAtom = atom({ plugin: 'next-up', key: 'tasksTab' } as const, 'todo' as TasksTab)
95
96// What the user changed in the Tasks pane since Claude last saw the list; sent
97// with the next prompt, then cleared.
98const edits: UserEdits = { done: [], removed: [] }
99
100// Whether Claude has been told about Next Up this session: through the system
101// prompt where the engine composes it after Next Up starts (the terminal), or
102// else beside the first prompt sent (desktop, where no mod runs until the
103// first message and the system prompt is already built).
104const nudge = { composed: false, sent: false }
105
106// Repeat presses of the same button on the same task within this window are one.
107const REPEAT_MS = 1000
108const lastPress = new Map<string, number>()
109
110type Platform = 'windows' | 'macos' | 'linux'
111
112// Session-wide context, set at session start (a reload sets it again).
113const context = { session: '', dir: '', cwd: '', project: null as Project, platform: 'macos' as Platform }
114// The folded list, the log signature it was read at, and each log's events.
115const cache = { signature: '\u0000', tasks: [] as Task[], logs: new Map() as LogCache }
116
117function stamp(): Stamp {
118  return { session: context.session, now: new Date().toISOString(), newId: () => crypto.randomUUID() }
119}
120
121// Where the logs live: ~/.claude/next-up.
122async function dataDir($: EngineInterface): Promise<string> {
123  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
124
125  return `${home.replace(/\\/g, '/')}/.claude/next-up`
126}
127
128// The file operations the store uses. ($ may not cross an import, so the
129// store-io functions get these instead of $.fs itself.)
130function filesOf($: EngineInterface): Files {
131  return {
132    exists: path => $.fs.exists(path),
133    list: path => $.fs.list(path),
134    read: ((path: string) => $.fs.read(path)) as Files['read'],
135    write: (path, text) => $.fs.write(path, text),
136  }
137}
138
139// The project a folder belongs to: its git root, or null outside one (or
140// where git is not installed).
141async function projectOf($: EngineInterface, folder: string): Promise<Project> {
142  const ran = await $.process.run(['git', '-C', folder, 'rev-parse', '--show-toplevel']).catch(() => null)
143  const root = ran?.exitCode === 0 ? ran.stdout.trim().replace(/\\/g, '/') : ''
144
145  return root ? { root, name: root.split('/').pop() ?? root } : null
146}
147
148// Windows sets OS for every process; elsewhere uname tells macOS from Linux.
149async function platformOf($: EngineInterface): Promise<Platform> {
150  if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
151  const ran = await $.process.run(['uname', '-s']).catch(() => null)
152
153  return ran?.stdout.trim() === 'Darwin' ? 'macos' : 'linux'
154}
155
156async function setProject($: EngineInterface, project: Project) {
157  context.project = project
158  if ((await read($, projectAtom))?.root !== project?.root) await update($, projectAtom, () => project)
159}
160
161// Re-reads the logs when any changed, and only then writes the list to state
162// (a state write redraws every site, which drops clicks in flight).
163async function refresh($: EngineInterface): Promise<Task[]> {
164  const signature = await logsSignature(filesOf($), context.dir)
165  if (signature !== cache.signature) {
166    cache.tasks = fold(await readEvents(filesOf($), context.dir, cache.logs))
167    cache.signature = signature
168    await update($, tasksAtom, () => cache.tasks)
169  }
170
171  return cache.tasks
172}
173
174// This session's writes, one at a time. Appending rewrites the session's log,
175// so two hooks writing at once (two chips in one turn, quick clicks on two
176// cards) would otherwise both start from the same copy and one would be lost.
177let writes: Promise<unknown> = Promise.resolve()
178
179function serially<T>(work: () => Promise<T>): Promise<T> {
180  const run = writes.then(work, work)
181  writes = run.catch(() => undefined)
182
183  return run
184}
185
186async function record($: EngineInterface, events: TaskEvent[]) {
187  await appendEvents(filesOf($), context.dir, context.session, events)
188  await refresh($)
189}
190
191// Adds incoming tasks, merging each into an open duplicate where there is one
192// (within the batch too). Returns how many were new and how many merged.
193// The read and the write run together in the queue, so a duplicate arriving at
194// the same moment sees this batch.
195function addIncoming($: EngineInterface, incoming: Task[]): Promise<{ added: Task[]; merged: number }> {
196  return serially(() => addNow($, incoming))
197}
198
199async function addNow($: EngineInterface, incoming: Task[]): Promise<{ added: Task[]; merged: number }> {
200  const known = [...(await refresh($))]
201  const events: TaskEvent[] = []
202  const added: Task[] = []
203  for (const task of incoming) {
204    const event = eventForIncoming(task, known, context.session)
205    events.push(event)
206    if (event.op === 'add') {
207      known.push(task)
208      added.push(task)
209    }
210  }
211  await record($, events)
212
213  return { added, merged: incoming.length - added.length }
214}
215
216function setFields($: EngineInterface, id: string, fields: Extract<TaskEvent, { op: 'set' }>['fields']): Promise<void> {
217  return serially(() => record($, [{ v: 1, at: new Date().toISOString(), by: context.session, op: 'set', id, fields }]))
218}
219
220async function togglePane($: EngineInterface, id = PANE, title = 'Next Up') {
221  const isOpen = (await $.ui.panes()).some(p => p.id === id)
222  if (isOpen) await $.ui.close({ id })
223  else await $.ui.open({ id, title })
224}
225
226// The session list's changes, from Claude's tool or the pane, applied in order.
227// The change is applied to the list as it stands when the write lands, so a
228// press and a tool call at the same moment never overwrite each other.
229async function changeSession($: EngineInterface, changes: SessionChanges): Promise<{ tasks: SessionTask[]; unknown: string[] }> {
230  let result: { tasks: SessionTask[]; unknown: string[] } = { tasks: [], unknown: [] }
231  const now = new Date().toISOString()
232  await update($, sessionAtom, prev => {
233    result = applyChanges(prev ?? [], changes, now)
234    return result.tasks
235  })
236
237  return result
238}
239
240// A press in the Tasks pane: applied, and remembered for Claude's next prompt.
241async function editSession($: EngineInterface, op: 'done' | 'remove', id: string) {
242  // A slow desktop can deliver several queued clicks at once; act on the first.
243  const now = Date.now()
244  const pressKey = `session-${op}:${id}`
245  if (now - (lastPress.get(pressKey) ?? 0) < REPEAT_MS) return
246  lastPress.set(pressKey, now)
247  const task = ((await read($, sessionAtom)) ?? []).find(t => t.id === id)
248  if (!task || task.status !== 'todo') return
249  await changeSession($, op === 'done' ? { done: [id] } : { remove: [id] })
250  if (op === 'done') edits.done.push(task)
251  else edits.removed.push(task)
252}
253
254// For a button: runs the press, and says so if it fails rather than failing silently.
255function press($: EngineInterface, work: Promise<unknown>) {
256  void work.catch(() => $.ui.toast('Next Up: that did not work, try again'))
257}
258
259// Opens the desktop app's new-session screen in the task's project with its
260// prompt in the box, unsent: claude://code/new?q=<prompt>&folder=<path>. The
261// desktop app runs on macOS and Windows only.
262async function newSession($: EngineInterface, task: Task) {
263  if (context.platform === 'linux') {
264    $.ui.toast('Next Up: New needs the Claude desktop app (macOS or Windows). Use Now instead.')
265    return
266  }
267  const root = task.repo?.root ?? context.cwd
268  const folder = context.platform === 'windows' ? root.replace(/\//g, '\\') : root
269  // Links this long are cut by some URL handlers; the opening that marks the
270  // task done on send survives the cut.
271  const prompt = task.prompt.slice(0, NEW_PROMPT_MAX)
272  const url = `claude://code/new?q=${encodeURIComponent(prompt)}&folder=${encodeURIComponent(folder)}`
273  const argv = context.platform === 'windows' ? ['rundll32', 'url.dll,FileProtocolHandler', url] : ['open', url]
274  const ran = await $.process.run(argv).catch(() => null)
275  if (ran?.exitCode !== 0) $.ui.toast("Next Up: couldn't open a new session")
276}
277
278async function act($: EngineInterface, action: 'now' | 'new' | 'favourite' | 'delete', id: string) {
279  // A slow desktop can deliver several queued clicks at once; act on the first.
280  const now = Date.now()
281  const pressKey = `${action}:${id}`
282  if (now - (lastPress.get(pressKey) ?? 0) < REPEAT_MS) return
283  lastPress.set(pressKey, now)
284  const task = cache.tasks.find(t => t.id === id)
285  if (!task) return
286  try {
287    if (action === 'now') await $.prompt.fill({ text: task.prompt, mode: 'replace' })
288    if (action === 'new') await newSession($, task)
289    if (action === 'favourite') await setFields($, id, { favourite: !task.favourite })
290    if (action === 'delete') await setFields($, id, { status: 'dropped' })
291  } catch {
292    $.ui.toast(`Next Up: couldn't ${action === 'favourite' ? 'update' : action} "${task.title}"`)
293  }
294}
295
296type CardModel = {
297  // Unique per site, so the band and the panes never share a key.
298  key: string
299  title: string
300  detail: string
301  favourite?: boolean
302  // A finished task: the title struck through.
303  struck?: boolean
304  // Top right, in order: the kind or Done in colour, or an age (dim).
305  topRight: Array<{ text: string; colour?: string }>
306  // Bottom right in a pane: the age, where the top right holds a label.
307  bottomRight?: string
308}
309
310type CardOptions = {
311  site: 'band' | 'pane'
312  // The width the card draws into, for the three-line description cap.
313  columns: number
314  // The band's one button, shown on hover in a fixed slot at the bottom right.
315  slot?: RenderChildren
316  // The pane's buttons, shown on hover on a tray centred over the card.
317  tray?: RenderChildren
318  // No buttons and no hover: a card only to read.
319  still?: boolean
320}
321
322// One card, shared by the band and both panes:
323//
324//   ★ Title, cut so the label always fits ................... Suggested
325//     Description, about three lines ........................... 2d
326//
327// On hover the text fades and the buttons sit centred on a solid tray over it,
328// so nothing reflows.
329function card(els: Elements['desktop'], m: CardModel, o: CardOptions) {
330  const { Box, Text } = els
331  // A still card (a finished task) has nothing to press, so no hover either.
332  const fade = o.still ? undefined : FADE
333
334  return (
335    // Always bordered (round), transparent at rest and the hover colour on
336    // hover, so the hover reads as a rounded card and nothing reflows. Half a
337    // cell short of the edges, so the corners are not clipped (the right one
338    // also clears the pane's scrollbar).
339    <Box
340      key={m.key}
341      flexDirection="column"
342      marginLeft={-0.5}
343      marginRight={-0.5}
344      paddingLeft={1}
345      paddingRight={1}
346      position="relative"
347      borderStyle="round"
348      borderColor={CLEAR}
349      hover={o.still ? undefined : { backgroundColor: HOVER, borderColor: HOVER }}
350    >
351      <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
352        <Box flexDirection="row" flexShrink={1} minWidth={0}>
353          {m.favourite ? <Text color={AMBER} bold hover={fade}>★ </Text> : null}
354          <Text wrap="truncate-end" strikethrough={m.struck} dimColor={m.struck} hover={fade}>{m.title}</Text>
355        </Box>
356        <Box flexDirection="row" flexShrink={0} gap={2}>
357          {m.topRight.map(part =>
358            part.colour ? (
359              <Text key={part.text} color={part.colour} hover={fade && { color: FADE.color }}>{part.text}</Text>
360            ) : (
361              <Text key={part.text} dimColor hover={fade}>{part.text}</Text>
362            ),
363          )}
364        </Box>
365      </Box>
366      <Box flexDirection="row" justifyContent="space-between" alignItems={o.site === 'band' ? 'flex-end' : 'flex-start'} gap={2}>
367        <Box flexShrink={1} minWidth={0}>
368          <Text dimColor wrap="wrap" hover={fade}>{cap(m.detail, detailCap(o.columns))}</Text>
369        </Box>
370        {o.site === 'band' ? (
371          // The band draws any floating box as a popover with its own frame, so
372          // its button lives in a fixed-size slot at the bottom right instead:
373          // empty at rest, the button on hover, and the slot's size never changes.
374          <Box width={BAND_NOW_COLUMNS} height={1} flexShrink={0} justifyContent="flex-end">
375            <Box display="none" hover={{ display: 'flex' }}>{o.slot}</Box>
376          </Box>
377        ) : m.bottomRight ? (
378          <Box flexShrink={0}>
379            <Text dimColor hover={fade}>{m.bottomRight}</Text>
380          </Box>
381        ) : null}
382      </Box>
383      {o.site === 'band' || o.still ? null : (
384        <Box position="absolute" top={0} bottom={0} left={0} right={0} display="none" hover={{ display: 'flex' }} flexDirection="row" justifyContent="center" alignItems="center">
385          {/* A solid tray, so the translucent native buttons read as solid. */}
386          <Box flexDirection="row" gap={1} paddingX={1} borderStyle="round" borderColor={TRAY} backgroundColor={TRAY}>
387            {o.tray}
388          </Box>
389        </Box>
390      )}
391    </Box>
392  )
393}
394
395// A Next Up task's card: Now (band); Now, New, favourite and delete (pane).
396function backlogCard($: EngineInterface, els: Elements['desktop'], task: Task, site: 'band' | 'pane', showProject: boolean, columns: number) {
397  const { Button } = els
398  const key = `${site}-${task.id}`
399  const age = ageOf(task.createdAt, Date.now())
400  const label = { text: labelOf(task), colour: labelColour(task) }
401  const model: CardModel = {
402    key,
403    title: task.title,
404    detail: showProject && task.repo ? `${task.repo.name} · ${task.detail}` : task.detail,
405    favourite: task.favourite,
406    // The band puts the age before the label; the pane puts it below.
407    topRight: site === 'band' ? [{ text: age }, label] : [label],
408    bottomRight: site === 'band' ? undefined : age,
409  }
410  if (site === 'band') {
411    return card(els, model, { site, columns, slot: <Button key={`${key}-now`} label="Now ↓" variant="primary" onPress={() => void act($, 'now', task.id)} /> })
412  }
413
414  return card(els, model, {
415    site,
416    columns,
417    tray: [
418      <Button key={`${key}-now`} label="↙ Now" variant="primary" onPress={() => void act($, 'now', task.id)} />,
419      <Button key={`${key}-new`} label="New ↗" onPress={() => void act($, 'new', task.id)} />,
420      <Button key={`${key}-fav`} label={task.favourite ? '★' : '☆'} hover={{ color: AMBER }} onPress={() => void act($, 'favourite', task.id)} />,
421      <Button key={`${key}-delete`} label={TRASH} hover={{ color: MUTED_RED }} onPress={() => void act($, 'delete', task.id)} />,
422    ],
423  })
424}
425
426// A session task's card: "To do" in amber or "Done" in green with the title
427// struck, and below it when it was planned or finished. Buttons: Done (to do
428// only) and delete; a finished task's card has none.
429function sessionCard($: EngineInterface, els: Elements['desktop'], task: SessionTask, columns: number) {
430  const { Button } = els
431  const key = `tasks-${task.id}`
432  const isDone = task.status === 'done'
433  const model: CardModel = {
434    key,
435    title: task.title,
436    detail: task.detail,
437    struck: isDone,
438    topRight: [isDone ? { text: 'Done', colour: GREEN } : { text: 'To do', colour: AMBER }],
439    // When it was finished, or when it was planned.
440    bottomRight: ageOf(isDone ? (task.doneAt ?? task.createdAt) : task.createdAt, Date.now()),
441  }
442
443  if (isDone) return card(els, model, { site: 'pane', columns, still: true })
444
445  return card(els, model, {
446    site: 'pane',
447    columns,
448    tray: [
449      <Button key={`${key}-done`} label="✓ Done" variant="primary" onPress={() => press($, editSession($, 'done', task.id))} />,
450      <Button key={`${key}-delete`} label={TRASH} hover={{ color: MUTED_RED }} onPress={() => press($, editSession($, 'remove', task.id))} />,
451    ],
452  })
453}
454
455async function registerBacklogTools($: EngineInterface) {
456  await $.tool.register({
457    name: 'add_tasks',
458    description:
459      "Add tasks to the user's Next Up backlog: follow-up work shared across all their Claude sessions. " +
460      'Use it when the user asks to add something to Next Up or to plan work into it, and for worthwhile follow-ups outside the current task. ' +
461      'Each task needs a short imperative title; give a self-contained prompt another session could act on, or details to build one from. ' +
462      'Pass plan.title to group the tasks as the ordered steps of one plan.',
463    inputSchema: {
464      type: 'object',
465      properties: {
466        tasks: {
467          type: 'array',
468          items: {
469            type: 'object',
470            properties: {
471              title: { type: 'string', description: 'Short and imperative, under 80 characters.' },
472              details: { type: 'string', description: 'A one- to three-line summary shown on the card.' },
473              prompt: { type: 'string', description: 'The full prompt to send when the task is started. Defaults to title plus details.' },
474            },
475            required: ['title'],
476          },
477        },
478        plan: { type: 'object', properties: { title: { type: 'string' } }, description: 'Group the tasks as the ordered steps of one plan.' },
479      },
480      required: ['tasks'],
481    },
482  })
483  await $.tool.register({
484    name: 'list_tasks',
485    description: "List the open tasks in the user's Next Up backlog, for the current project or all projects, with their ids.",
486    inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['project', 'all'] } } },
487  })
488  await $.tool.register({
489    name: 'update_task',
490    description: 'Update one Next Up task by id: mark it done or dropped, reopen it, or set it as a favourite. Use when you have finished a task that came from Next Up.',
491    inputSchema: {
492      type: 'object',
493      properties: {
494        id: { type: 'string' },
495        status: { type: 'string', enum: ['open', 'done', 'dropped'] },
496        favourite: { type: 'boolean' },
497      },
498      required: ['id'],
499    },
500  })
501}
502
503async function registerTasksTools($: EngineInterface) {
504  await $.tool.register({
505    name: 'update_session_tasks',
506    description:
507      'Keep the task list for the work in this session, shown to the user in a Tasks pane. ' +
508      'add appends steps in order; update rewords steps; done and remove take ids (s1, s2, ...). Call with no changes to read the list. ' +
509      'Returns the current list.' +
510      (features.backlog ? ' This is separate from the Next Up backlog, which is for work outside this session.' : ''),
511    inputSchema: {
512      type: 'object',
513      properties: {
514        add: {
515          type: 'array',
516          items: {
517            type: 'object',
518            properties: {
519              title: { type: 'string', description: 'Short and imperative, under 80 characters.' },
520              details: { type: 'string', description: 'Optional: one line on what the step involves.' },
521            },
522            required: ['title'],
523          },
524        },
525        update: {
526          type: 'array',
527          items: {
528            type: 'object',
529            properties: { id: { type: 'string' }, title: { type: 'string' }, details: { type: 'string' } },
530            required: ['id'],
531          },
532          description: 'Reword steps by id: a new title, details, or both.',
533        },
534        done: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks now finished.' },
535        remove: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks no longer needed.' },
536      },
537    },
538  })
539}
540
541function plural(n: number, word: string) {
542  return `${n} ${word}${n === 1 ? '' : 's'}`
543}
544
545// What /next does, for the commands list: only the features switched on.
546function commandHelp(): string {
547  if (!features.backlog) return 'Next Up: open the Tasks pane'
548  return features.tasks
549    ? 'Next Up: open the pane; `/next tasks`, `/next list`, `/next all`, `/next band`, or `/next plan <goal>`'
550    : 'Next Up: open the pane; `/next list`, `/next all`, `/next band`, or `/next plan <goal>`'
551}
552
553// /next plan <goal>: asks Claude, as a turn of its own, to plan into Next Up.
554function planPrompt(goal: string) {
555  return `Plan this into my Next Up backlog: ${goal}\n\nBreak it into a few ordered steps and add them with ${TOOL('add_tasks')}, passing a plan title. Give each step a self-contained prompt. Do not start the work.`
556}
557
558// Next Up sits in the path of session start, Claude's suggestion chips and
559// every sent prompt. If one of its hooks fails, the catch below lets the event
560// carry on as if Next Up were not there.
561export const register: Register = (on, options) => {
562  features.backlog = options.backlog !== false
563  features.tasks = options.tasks !== false
564  if (!features.backlog && !features.tasks) return
565
566  on('session.start', async ($, e, next) => {
567    context.session = await $.session.id()
568    context.dir = await dataDir($)
569    context.cwd = e.cwd
570    nudge.composed = false
571    nudge.sent = false
572    // The tools and command first: the nudge tells Claude about them, so they
573    // must exist even if what follows fails.
574    // Each step on its own, so one failing does not take the others with it.
575    await $.command.register({ name: 'next', description: commandHelp() }).catch(() => undefined)
576    if (features.tasks) await registerTasksTools($).catch(() => undefined)
577    if (!features.backlog) return next(e)
578    await registerBacklogTools($).catch(() => undefined)
579    context.platform = await platformOf($)
580    await setProject($, await projectOf($, e.cwd))
581    await refresh($)
582    // Other sessions write their own logs; pick their changes up. A failed
583    // poll is retried on the next tick.
584    $.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
585    // The session-start list is off by default: on desktop no mod runs until the
586    // first message is sent, so it could only flash and vanish as that prompt
587    // arrives. /next band shows it on demand.
588    await update($, bandAtom, () => false)
589
590    return next(e)
591  }).catch(($, e, next) => next(e))
592
593  // A /cd or the host moving the session: follow it to its project.
594  if (features.backlog) on('classic.CwdChanged', async ($, e, next) => {
595    context.cwd = e.new_cwd
596    await setProject($, await projectOf($, e.new_cwd))
597
598    return next(e)
599  }).catch(($, e, next) => next(e))
600
601  // The footer: two native Buttons, "<n> Tasks ↗" and "<n> Next Up ↗".
602  // Desktop forces mod buttons there to its own look and one line
603  // (anthropics/claude-code#100116).
604  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
605    const { Box, Button } = $.ui.resolve(e)
606    const tasks = features.tasks ? tasksLabel((await read($, sessionAtom)) ?? []) : null
607    const backlog = features.backlog ? footerLabel((await read($, tasksAtom)) ?? [], (await read($, projectAtom)) ?? null) : null
608
609    return (
610      <Box key="next-up-footer" flexDirection="row" alignItems="center" gap={1}>
611        {tasks ? <Button key="next-up-tasks-open" label={tasks} hover={{ color: AMBER }} onPress={() => press($, togglePane($, TASKS_PANE, 'Tasks'))} /> : null}
612        {backlog ? <Button key="next-up-open" label={backlog} hover={{ color: AMBER }} onPress={() => press($, togglePane($))} /> : null}
613      </Box>
614    )
615  })
616
617  // The Tasks pane: To Do and Done, over the same cards.
618  if (features.tasks) on('ui.render', { component: 'Pane', requestId: TASKS_PANE }, async ($, e) => {
619    if (e.surface !== 'desktop' && e.surface !== 'terminal') {
620      const { Text } = $.ui.resolve(e)
621      return <Text>Tasks needs the terminal or the desktop app.</Text>
622    }
623    const els = $.ui.resolve(e) as Elements['desktop']
624    const { Box, Button, Text } = els
625    const tab = (await read($, tasksTabAtom)) ?? 'todo'
626    const view = tasksView((await read($, sessionAtom)) ?? [], tab)
627    const empty = tab === 'todo' ? 'Nothing to do. Claude lists the steps here when work takes more than one.' : 'Nothing done yet.'
628
629    return (
630      <Box flexDirection="column" gap={1}>
631        <Box flexDirection="row" gap={1}>
632          {view.tabs.map(t => (
633            <Button key={`tasks-tab-${t.id}`} label={t.label} variant={t.id === tab ? 'primary' : undefined} dimColor={t.id !== tab} onPress={() => press($, update($, tasksTabAtom, () => t.id))} />
634          ))}
635        </Box>
636        {view.items.length === 0 ? <Text dimColor>{empty}</Text> : null}
637        <Box flexDirection="column">{view.items.map(t => sessionCard($, els, t, e.props.bodyColumns))}</Box>
638      </Box>
639    )
640  })
641
642  // The session-start list: new sessions only, gone after the first prompt.
643  if (features.backlog) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
644    if (e.props.hasSurvey || !(await read($, bandAtom))) return next(e)
645    if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
646    const els = $.ui.resolve(e) as Elements['desktop']
647    const { Box, Button, Text } = els
648    const project = (await read($, projectAtom)) ?? null
649    const top = bandTasks((await read($, tasksAtom)) ?? [], project)
650    if (top.length === 0) return next(e)
651
652    return (
653      <Box flexDirection="column" gap={1}>
654        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
655          <Text dimColor>Next Up</Text>
656          <Box flexDirection="row" gap={1} alignItems="center">
657            <Button key="band-see-all" label="See all ↗" dimColor onPress={() => press($, togglePane($))} />
658            <Button key="band-hide" label="Hide" role="dismiss" onPress={() => press($, update($, bandAtom, () => false))} />
659          </Box>
660        </Box>
661        <Box flexDirection="column">
662          {top.map(task => backlogCard($, els, task, 'band', project === null, e.props.bodyColumns))}
663        </Box>
664      </Box>
665    )
666  })
667
668  // The pane: ★, the project and All, over the same cards.
669  if (features.backlog) on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
670    if (e.surface !== 'desktop' && e.surface !== 'terminal') {
671      const { Text } = $.ui.resolve(e)
672      return <Text>Next Up needs the terminal or the desktop app.</Text>
673    }
674    const els = $.ui.resolve(e) as Elements['desktop']
675    const { Box, Button, Text } = els
676    const view = paneView((await read($, tasksAtom)) ?? [], (await read($, projectAtom)) ?? null, (await read($, tabAtom)) ?? 'project')
677
678    return (
679      <Box flexDirection="column" gap={1}>
680        <Box flexDirection="row" gap={1}>
681          {view.tabs.map(tab => (
682            <Button key={`tab-${tab.id}`} label={tab.label} variant={tab.id === view.current ? 'primary' : undefined} dimColor={tab.id !== view.current} onPress={() => press($, update($, tabAtom, () => tab.id))} />
683          ))}
684        </Box>
685        {view.items.length === 0 ? <Text dimColor>Nothing here yet. Claude adds tasks as it suggests them.</Text> : null}
686        <Box flexDirection="column">
687          {view.items.map(item =>
688            item.kind === 'header' ? (
689              // A group header outside the cards: the project, then a rule to the edge.
690              <Box key={`group-${item.key}`} flexDirection="row" alignItems="center" gap={1} marginTop={1}>
691                <Text dimColor>{item.name}</Text>
692                {/* A bar a sixteenth of a line tall (~1px): an Svg line drew nothing, text rules wrapped. */}
693                <Box flexGrow={1} minWidth={0} height={0.06} backgroundColor={RULE} />
694              </Box>
695            ) : (
696              backlogCard($, els, item.task, 'pane', false, e.props.bodyColumns)
697            ),
698          )}
699        </Box>
700      </Box>
701    )
702  })
703
704  // Claude's suggestion chips: answered here, so the task lands in the backlog
705  // and no chip shows. One Next Up cannot keep (no title or prompt), or a
706  // failed save, goes on to the chip so the suggestion is not lost.
707  if (features.backlog) on('tool.call', { tool: /spawn_task$/ }, async ($, e, next) => {
708    const task = taskFromChip(e as unknown as Record<string, unknown>, context.project, stamp())
709    if (!task) return next(e)
710    const { added } = await addIncoming($, [task])
711    const how = added.length > 0 ? 'Saved' : 'Already in'
712    const where = context.project?.name ?? 'no project'
713
714    return { result: `${how} the user's Next Up backlog (${where}) as "${task.title}". No suggestion chip was shown, so there is no task_id and nothing to dismiss.` }
715  }).catch(($, e, next) => next(e))
716
717  if (features.backlog) on('tool.call', { tool: /^mcp__next-up__add_tasks$/ }, async ($, e) => {
718    const incoming = tasksFromAddInput(e as unknown as AddInput, context.project, stamp())
719    if (incoming.length === 0) return { result: 'No tasks added: each task needs a title.' }
720    const { added, merged } = await addIncoming($, incoming)
721    const where = context.project?.name ?? 'no project'
722    const parts = [`Added ${plural(added.length, 'task')} to Next Up (${where})`]
723    if (merged > 0) parts.push(`${plural(merged, 'task')} matched existing ${merged === 1 ? 'one and was' : 'ones and were'} merged`)
724
725    return { result: `${parts.join('; ')}.` }
726  }).catch(() => ({ result: 'Next Up could not save the tasks. Nothing was added.' }))
727
728  if (features.backlog) on('tool.call', { tool: /^mcp__next-up__list_tasks$/ }, async ($, e) => {
729    const scope = (e as unknown as { scope?: string }).scope === 'all' ? 'all' : 'project'
730
731    return { result: listText(await refresh($), context.project, scope) }
732  }).catch(() => ({ result: 'Next Up could not read the backlog.' }))
733
734  if (features.backlog) on('tool.call', { tool: /^mcp__next-up__update_task$/ }, async ($, e) => {
735    const input = e as unknown as { id?: string; status?: Task['status']; favourite?: boolean }
736    const task = (await refresh($)).find(t => t.id === input.id)
737    if (!task) return { result: `No Next Up task with id ${String(input.id)}.` }
738    const fields: Extract<TaskEvent, { op: 'set' }>['fields'] = {}
739    if (input.status) fields.status = input.status
740    if (typeof input.favourite === 'boolean') fields.favourite = input.favourite
741    if (Object.keys(fields).length === 0) return { result: 'Nothing to update: pass a status or favourite.' }
742    await setFields($, task.id, fields)
743
744    return { result: `Updated "${task.title}".` }
745  }).catch(() => ({ result: 'Next Up could not update the task.' }))
746
747  // The two tools Claude reaches for unprompted sit in its tool list, not
748  // behind tool search: a deferred tool is a bare name until it is loaded,
749  // and on desktop Claude did not go looking for it.
750  on('tool.describe', { tool: /^mcp__next-up__(add_tasks|update_session_tasks)$/ }, async ($, e, next) => {
751    const described = await next(e)
752
753    return { ...described, isDeferred: false }
754  }).catch(($, e, next) => next(e))
755
756  // Claude's changes to the session list. It sees the whole list in the result,
757  // so the user's earlier edits are known to it from here on.
758  if (features.tasks) on('tool.call', { tool: /^mcp__next-up__update_session_tasks$/ }, async ($, e) => {
759    const { tasks, unknown } = await changeSession($, e as unknown as SessionChanges)
760    edits.done = []
761    edits.removed = []
762    const note = unknown.length ? `\n(No task with id ${unknown.join(', ')}: it may have been removed.)` : ''
763
764    return { result: `${sessionText(tasks)}${note}` }
765  }).catch(() => ({ result: 'Next Up could not update the session task list.' }))
766
767  // In the terminal Claude Code nudges Claude toward its own task tools; with
768  // Next Up's list in use, that would make two lists.
769  if (features.tasks) on('prompt.attachment', async ($, e, next) => {
770    if (e.type === 'todo_reminder' || e.type === 'task_reminder') return { text: null }
771
772    return next(e)
773  }).catch(($, e, next) => next(e))
774
775  // The nudge, appended after the engine's own sections.
776  on('prompt.compose', async ($, e, next) => {
777    const composed = await next(e)
778    // Already given beside the first prompt: not twice.
779    if (nudge.sent) return composed
780    const text = nudgeText(e.tools)
781    if (!text) return composed
782    nudge.composed = true
783
784    return { sections: [...composed.sections, { id: 'next-up', text, scope: 'session' as const }] }
785  }).catch(($, e, next) => next(e))
786
787  // The first prompt takes the session-start list down. Sending a task's
788  // prompt (from Now, or a New session's pre-filled box) marks it done;
789  // filling the box alone does not.
790  on('prompt.submit', async ($, e, next) => {
791    // Only a prompt the user sent can start a task (not a peer's message, a
792    // notification or a plugin's own prompt).
793    const fromUser = e.origin?.kind === 'composer' || e.origin?.kind === 'bridge' || e.origin?.kind === 'sdk'
794    if (features.backlog && fromUser) {
795      if (await read($, bandAtom)) await update($, bandAtom, () => false)
796      const sent = submittedTask(e.text, await refresh($), context.project)
797      if (sent) {
798        await setFields($, sent.id, { status: 'done' })
799        $.ui.toast(`Next Up: "${sent.title}" started`)
800      }
801    }
802    // Unseen by the user, beside this prompt: Next Up's guidance, if the system
803    // prompt never carried it, and their edits in the Tasks pane.
804    // Both are marked delivered only once the prompt has gone through.
805    const extra: string[] = []
806    const sendsNudge = !nudge.composed && !nudge.sent
807    if (sendsNudge) extra.push(`From the Next Up mod the user has installed:\n\n${nudgeText()}`)
808    const pending = { done: [...edits.done], removed: [...edits.removed] }
809    if (features.tasks) {
810      const session = (await read($, sessionAtom)) ?? []
811      const note = editsNote(session, pending)
812      const reminder = note ? null : openReminder(session)
813      if (note ?? reminder) extra.push((note ?? reminder) as string)
814    }
815    if (extra.length === 0) return next(e)
816    const entered = await next({ ...e, context: [...(e.context ?? []), ...extra] })
817    if (!entered.drop) {
818      if (sendsNudge) nudge.sent = true
819      edits.done = edits.done.filter(t => !pending.done.includes(t))
820      edits.removed = edits.removed.filter(t => !pending.removed.includes(t))
821    }
822
823    return entered
824  }).catch(($, e, next) => next(e))
825
826  on('command.run', { command: 'next' }, async ($, e) => {
827    const args = e.args.trim()
828    if (!features.backlog || (args === 'tasks' && features.tasks)) {
829      await togglePane($, TASKS_PANE, 'Tasks')
830      return { text: 'Tasks pane toggled.' }
831    }
832    if (args === 'tasks') return { text: 'Tasks is switched off in /config (Next Up: Session tasks).' }
833    if (/^plan(\s|$)/.test(args)) {
834      const goal = args.slice(4).trim()
835      if (!goal) return { text: 'Usage: /next plan <goal>' }
836      await $.prompt.submit({ text: planPrompt(goal), asUser: true })
837      return { text: `Planning "${goal}" into Next Up.` }
838    }
839    if (args === 'list' || args === 'all') return { text: listText(await refresh($), context.project, args === 'all' ? 'all' : 'project') }
840    // Shows the session-start list in this session, which normally appears only
841    // in a new one.
842    if (args === 'band') {
843      await update($, bandAtom, () => true)
844      return { text: 'Next Up list shown above the prompt.' }
845    }
846    if (args) return { text: `Usage: /next [${features.tasks ? 'tasks | ' : ''}list | all | band | plan <goal>]` }
847    await togglePane($)
848
849    return { text: 'Next Up pane toggled.' }
850  })
851}
852
hooks/capture.ts 90 lines
1// How tasks come in, as plain functions: from Claude's suggestion chips and
2// from the add_tasks tool. No `$`; the hooks module does the reading and writing.
3
4import { labelOf } from './store'
5import type { Project, Task } from './store'
6
7export type { Project } from './store'
8
9export type Stamp = { session: string; now: string; newId: () => string }
10
11const TITLE_MAX = 80
12
13function clean(text: unknown): string {
14  return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
15}
16
17function short(text: string, max: number): string {
18  return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
19}
20
21function base(stamp: Stamp, project: Project): Pick<Task, 'repo' | 'status' | 'favourite' | 'seen' | 'createdAt' | 'updatedAt'> {
22  return { repo: project, status: 'open', favourite: false, seen: 1, createdAt: stamp.now, updatedAt: stamp.now }
23}
24
25// A suggestion chip (the desktop's spawn_task: title, tldr, prompt) as a task.
26// Null when it has no title or prompt to keep.
27export function taskFromChip(args: { title?: unknown; tldr?: unknown; prompt?: unknown }, project: Project, stamp: Stamp): Task | null {
28  const title = clean(args.title)
29  const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : ''
30  if (!title || !prompt) return null
31
32  return {
33    ...base(stamp, project),
34    id: stamp.newId(),
35    title: short(title, TITLE_MAX),
36    detail: clean(args.tldr),
37    prompt,
38    kind: 'suggested',
39    sources: [{ kind: 'chip', session: stamp.session, at: stamp.now }],
40  }
41}
42
43export type AddInput = {
44  tasks?: Array<{ title?: unknown; details?: unknown; prompt?: unknown }>
45  plan?: { title?: unknown }
46}
47
48// add_tasks' input as tasks: "Added", or "Plan n/m" when a plan title is given
49// (steps numbered in the order listed). A task without a prompt gets its title
50// and details as one.
51export function tasksFromAddInput(input: AddInput, project: Project, stamp: Stamp): Task[] {
52  const items = (input.tasks ?? []).filter(t => clean(t.title))
53  const planTitle = clean(input.plan?.title)
54  const planId = planTitle ? stamp.newId() : ''
55
56  return items.map((item, i) => {
57    const title = short(clean(item.title), TITLE_MAX)
58    const details = typeof item.details === 'string' ? item.details.trim() : ''
59    const prompt = typeof item.prompt === 'string' && item.prompt.trim() ? item.prompt.trim() : [title, details].filter(Boolean).join('\n\n')
60
61    return {
62      ...base(stamp, project),
63      id: stamp.newId(),
64      title,
65      detail: clean(details) || (planTitle ? `${planTitle}, step ${i + 1} of ${items.length}` : ''),
66      prompt,
67      kind: planTitle ? ('plan' as const) : ('added' as const),
68      ...(planTitle ? { plan: { id: planId, title: planTitle, step: i + 1, of: items.length } } : {}),
69      sources: [{ kind: 'tool' as const, session: stamp.session, at: stamp.now }],
70    }
71  })
72}
73
74// Open tasks as text, for list_tasks and /next: favourites first, newest first.
75// Outside a project (or for "all"), each line names its project.
76export function listText(tasks: Task[], project: Project, scope: 'project' | 'all'): string {
77  const open = tasks.filter(t => t.status === 'open' && (scope === 'all' || project === null || t.repo?.root === project.root))
78  if (open.length === 0) return scope === 'all' || project === null ? 'Next Up is empty.' : `No open tasks for ${project.name}.`
79  const sorted = [...open].sort((a, b) => Number(b.favourite) - Number(a.favourite) || b.createdAt.localeCompare(a.createdAt))
80  const showProject = scope === 'all' || project === null
81
82  return sorted
83    .map(t => {
84      const where = showProject ? ` [${t.repo?.name ?? 'no project'}]` : ''
85      const star = t.favourite ? '★ ' : ''
86      return `- ${star}${t.title} (${labelOf(t)}, id ${t.id})${where}${t.detail ? `: ${t.detail}` : ''}`
87    })
88    .join('\n')
89}
90
hooks/session.ts 128 lines
1// The session's own task list, as plain functions: the steps of the work in
2// hand, kept by Claude through update_session_tasks and edited from the Tasks
3// pane. No `$`; the hooks module keeps the list in state.
4
5import type { SessionTask, TasksTab } from '../types'
6import { GAP } from './view'
7
8export type { SessionTask, TasksTab } from '../types'
9
10export type SessionChanges = {
11  add?: Array<{ title?: unknown; details?: unknown }>
12  update?: Array<{ id?: unknown; title?: unknown; details?: unknown }>
13  done?: unknown[]
14  remove?: unknown[]
15}
16
17const TITLE_MAX = 80
18
19function clean(text: unknown): string {
20  return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
21}
22
23function shorten(title: string): string {
24  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`
25}
26
27// The next free short id: one past the highest s<n> ever given (removed tasks
28// stay in the list, hidden, for this).
29function nextNumber(tasks: SessionTask[]): number {
30  return tasks.reduce((n, t) => Math.max(n, Number(t.id.slice(1)) || 0), 0) + 1
31}
32
33// Applies Claude's changes: rewordings, removals, completions, then additions
34// (which go to the end, in the order given). Unknown ids are reported, not
35// fatal.
36export function applyChanges(tasks: SessionTask[], changes: SessionChanges, now: string): { tasks: SessionTask[]; unknown: string[] } {
37  const ids = new Set(tasks.filter(t => t.status !== 'removed').map(t => t.id))
38  const named = (list: unknown[] | undefined) => (list ?? []).map(id => String(id).trim()).filter(Boolean)
39  const remove = new Set(named(changes.remove))
40  const done = new Set(named(changes.done))
41  const updates = new Map<string, { title: string; details: string | null }>()
42  for (const u of changes.update ?? []) {
43    const id = String(u.id ?? '').trim()
44    if (id) updates.set(id, { title: clean(u.title), details: typeof u.details === 'string' ? clean(u.details) : null })
45  }
46  const unknown = [...remove, ...done, ...updates.keys()].filter(id => !ids.has(id))
47
48  let next = tasks.map(t => {
49    if (t.status === 'removed') return t
50    const u = updates.get(t.id)
51    if (u) t = { ...t, ...(u.title ? { title: shorten(u.title) } : {}), ...(u.details !== null ? { detail: u.details } : {}) }
52    if (remove.has(t.id)) return { ...t, status: 'removed' as const }
53    if (done.has(t.id) && t.status === 'todo') return { ...t, status: 'done' as const, doneAt: now }
54    return t
55  })
56  let n = nextNumber(tasks)
57  for (const item of changes.add ?? []) {
58    const title = clean(item.title)
59    if (!title) continue
60    next = [...next, { id: `s${n}`, title: shorten(title), detail: clean(item.details), status: 'todo', createdAt: now }]
61    n += 1
62  }
63
64  return { tasks: next, unknown: [...new Set(unknown)] }
65}
66
67// The list as Claude reads it, in a tool result or beside a prompt.
68export function sessionText(tasks: SessionTask[]): string {
69  if (tasks.length === 0) return 'The session task list is empty.'
70  const line = (t: SessionTask) => `- [${t.id}] ${t.title}${t.detail ? `: ${t.detail}` : ''}`
71  const todo = tasks.filter(t => t.status === 'todo')
72  const done = tasks.filter(t => t.status === 'done')
73
74  return [
75    `To do (${todo.length}):`,
76    ...(todo.length ? todo.map(line) : ['- nothing left']),
77    `Done (${done.length}):`,
78    ...(done.length ? done.map(line) : ['- nothing yet']),
79  ].join('\n')
80}
81
82// What the user changed from the pane, for Claude to hear before its next turn.
83export type UserEdits = { done: SessionTask[]; removed: SessionTask[] }
84
85export function editsNote(tasks: SessionTask[], edits: UserEdits): string | null {
86  if (edits.done.length === 0 && edits.removed.length === 0) return null
87  const names = (list: SessionTask[]) => list.map(t => `[${t.id}] ${t.title}`).join('; ')
88  const lines = ['The user edited the session task list (in the Tasks pane) since you last saw it. Treat their changes as decided: do not redo or re-add these.']
89  if (edits.done.length) lines.push(`Marked done by the user: ${names(edits.done)}.`)
90  if (edits.removed.length) lines.push(`Removed by the user: ${names(edits.removed)}.`)
91  lines.push('', 'The list now:', sessionText(tasks))
92
93  return lines.join('\n')
94}
95
96// Beside each prompt while steps remain, so Claude sees the list without
97// asking for it.
98export function openReminder(tasks: SessionTask[]): string | null {
99  const todo = tasks.filter(t => t.status === 'todo')
100  if (todo.length === 0) return null
101
102  return [
103    `Session tasks still to do (${todo.length}), in order. Keep them current with update_session_tasks: mark each done as it is finished, add or reword steps as the work changes.`,
104    ...todo.map(t => `- [${t.id}] ${t.title}`),
105  ].join('\n')
106}
107
108// The footer button: "<to do>  Tasks ↗", or just the name with none.
109export function tasksLabel(tasks: SessionTask[]): string {
110  const count = tasks.filter(t => t.status === 'todo').length
111
112  return count > 0 ? `${count}${GAP}Tasks ↗` : 'Tasks ↗'
113}
114
115// The Tasks pane: To Do in planned order, Done newest first.
116export function tasksView(tasks: SessionTask[], tab: TasksTab): { tabs: Array<{ id: TasksTab; label: string }>; items: SessionTask[] } {
117  const todo = tasks.filter(t => t.status === 'todo')
118  const done = tasks.filter(t => t.status === 'done').sort((a, b) => (b.doneAt ?? '').localeCompare(a.doneAt ?? ''))
119
120  return {
121    tabs: [
122      { id: 'todo', label: `To Do ${todo.length}` },
123      { id: 'done', label: `Done ${done.length}` },
124    ],
125    items: tab === 'done' ? done : todo,
126  }
127}
128
hooks/store.ts 137 lines
1// Next Up's store: plain functions, no `$`. Every session appends events to its
2// own log (~/.claude/next-up/log/<session>.jsonl); every session folds all logs
3// into one task list. Last write wins per field.
4
5// The task model lives in the state contract (types/), which the drawings read.
6import type { Source, Task } from '../types'
7
8export type { Kind, Project, Source, Task } from '../types'
9
10type Stamp = { v: 1; at: string; by: string }
11
12export type TaskEvent =
13  | (Stamp & { op: 'add'; task: Task })
14  | (Stamp & { op: 'merge'; id: string; source: Source })
15  | (Stamp & { op: 'set'; id: string; fields: Partial<Pick<Task, 'title' | 'detail' | 'prompt' | 'status' | 'favourite'>> })
16
17// Folds events from every log into the current list, oldest event first.
18export function fold(events: TaskEvent[]): Task[] {
19  const byId = new Map<string, Task>()
20  const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at) || a.by.localeCompare(b.by))
21  for (const e of sorted) {
22    if (e.op === 'add') {
23      if (!byId.has(e.task.id)) byId.set(e.task.id, { ...e.task })
24      continue
25    }
26    const task = byId.get(e.id)
27    if (!task) continue
28    if (e.op === 'merge') {
29      byId.set(e.id, { ...task, seen: task.seen + 1, sources: [...task.sources, e.source], updatedAt: e.at })
30    } else {
31      byId.set(e.id, { ...task, ...e.fields, updatedAt: e.at })
32    }
33  }
34
35  return [...byId.values()]
36}
37
38const STOPWORDS = new Set(['a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'and', 'or', 'with', 'is', 'be', 'this', 'that', 'it', 'at', 'by'])
39
40// Title words for comparison: lowercase, punctuation dropped, stopwords out,
41// a trailing plural "s" trimmed. Letters and digits of any script count.
42export function words(text: string): string[] {
43  return text
44    .toLowerCase()
45    .replace(/[^\p{L}\p{N}]+/gu, ' ')
46    .split(' ')
47    .filter(w => w && !STOPWORDS.has(w))
48    .map(w => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w))
49}
50
51// Word overlap of two titles, 0 to 1 (Jaccard).
52export function similarity(a: string, b: string): number {
53  const x = new Set(words(a))
54  const y = new Set(words(b))
55  if (x.size === 0 || y.size === 0) return 0
56  let shared = 0
57  for (const w of x) if (y.has(w)) shared += 1
58
59  return shared / (x.size + y.size - shared)
60}
61
62// Word overlap at or above this is a near duplicate. At 0.6, short titles that
63// differ by one meaningful word ("Add tests for X" / "Add docs for X") merged.
64const NEAR = 0.75
65
66// An open task in the same project that the candidate repeats: the same words
67// (exact) or most of them (near). A plan's steps are asked for deliberately,
68// so they are never merged away. A title with no words matches nothing.
69export function findDuplicate(candidate: Pick<Task, 'title' | 'repo' | 'plan'>, tasks: Task[]): { task: Task; match: 'exact' | 'near' } | null {
70  if (candidate.plan) return null
71  const key = words(candidate.title).join(' ')
72  if (!key) return null
73  let best: { task: Task; score: number } | null = null
74  for (const task of tasks) {
75    if (task.status !== 'open' || (task.repo?.root ?? null) !== (candidate.repo?.root ?? null)) continue
76    if (words(task.title).join(' ') === key) return { task, match: 'exact' }
77    const score = similarity(task.title, candidate.title)
78    if (score >= NEAR && (!best || score > best.score)) best = { task, score }
79  }
80
81  return best ? { task: best.task, match: 'near' } : null
82}
83
84// The event to append for an incoming task: a merge into a duplicate, or an add.
85export function eventForIncoming(task: Task, tasks: Task[], by: string): TaskEvent {
86  const duplicate = findDuplicate(task, tasks)
87  const source = task.sources[0] ?? { kind: 'tool', session: by, at: task.createdAt }
88  if (duplicate) return { v: 1, at: task.createdAt, by, op: 'merge', id: duplicate.task.id, source }
89
90  return { v: 1, at: task.createdAt, by, op: 'add', task }
91}
92
93// The card's label: Suggested (a Claude chip), Added (you, or Claude when
94// asked), Plan n/m.
95export function labelOf(task: Task): string {
96  if (task.kind === 'plan' && task.plan) return `Plan ${task.plan.step}/${task.plan.of}`
97
98  return task.kind === 'added' ? 'Added' : 'Suggested'
99}
100
101const OPENING = 80
102// Below this an opening is too generic to find inside a longer prompt
103// ("Fix lint"), so a short prompt has to be sent exactly.
104const MIN_OPENING = 30
105
106const squash = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase()
107
108// A sent prompt "is" a task when it still carries the task's opening: the
109// first 80 characters, whitespace folded. Edits after that still count.
110export function isSubmissionOf(submitted: string, task: Task): boolean {
111  const prompt = squash(task.prompt)
112  const opening = prompt.slice(0, OPENING)
113  if (opening.length < MIN_OPENING) return opening.length > 0 && squash(submitted) === prompt
114
115  return squash(submitted).includes(opening)
116}
117
118// The open task in this project (or outside any, outside one) that a sent
119// prompt starts; the longest opening wins when several fit.
120export function submittedTask(submitted: string, tasks: Task[], project: Task['repo']): Task | null {
121  const here = tasks.filter(t => t.status === 'open' && (t.repo?.root ?? null) === (project?.root ?? null))
122  const matches = here.filter(t => isSubmissionOf(submitted, t))
123
124  return matches.sort((a, b) => squash(b.prompt).slice(0, OPENING).length - squash(a.prompt).slice(0, OPENING).length)[0] ?? null
125}
126
127// "now", "5m", "2h", "3d", "2w".
128export function ageOf(iso: string, now: number): string {
129  const seconds = Math.max(0, (now - Date.parse(iso)) / 1000)
130  if (seconds < 60) return 'now'
131  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
132  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
133  if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`
134
135  return `${Math.floor(seconds / 604800)}w`
136}
137
hooks/store-io.ts 91 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { TaskEvent } from './store'
4
5// The store's files. Each session writes only its own log, so two sessions
6// never write the same file; every session reads all of them.
7//
8//   ~/.claude/next-up/log/<session>.jsonl   one event per line
9//
10// The functions take only the file operations they use (the hooks module
11// wraps $.fs), so tests can hand them an in-memory stand-in.
12
13export type Files = Pick<EngineInterface['fs'], 'exists' | 'list' | 'read' | 'write'>
14
15// The logs' names, sizes and times, joined: a change in any log changes it, so
16// a session can poll this cheaply and fold again only when it moves.
17export async function logsSignature(fs: Files, dir: string): Promise<string> {
18  const logDir = `${dir}/log`
19  if (!(await fs.exists(logDir))) return ''
20  const entries = await fs.list(logDir)
21
22  return entries
23    .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
24    .map(entry => `${entry.name}:${entry.size}:${entry.mtimeMs}`)
25    .sort()
26    .join('|')
27}
28
29// Each log's parsed events, by file name, with the size and time they were read
30// at. Logs only grow, and only one session writes each, so most stay unchanged
31// between reads.
32export type LogCache = Map<string, { stamp: string; events: TaskEvent[] }>
33
34function parse(text: string): TaskEvent[] {
35  const events: TaskEvent[] = []
36  for (const line of text.split('\n')) {
37    if (!line.trim()) continue
38    try {
39      events.push(JSON.parse(line) as TaskEvent)
40    } catch {
41      // A torn or hand-edited line: skip it.
42    }
43  }
44
45  return events
46}
47
48// Every event in every log. A line that does not parse is skipped, so one bad
49// write never hides the rest. Given a cache, only logs whose size or time
50// moved since the last read are read again.
51export async function readEvents(fs: Files, dir: string, cache: LogCache = new Map()): Promise<TaskEvent[]> {
52  const logDir = `${dir}/log`
53  if (!(await fs.exists(logDir))) {
54    cache.clear()
55    return []
56  }
57  const seen = new Set<string>()
58  const events: TaskEvent[] = []
59  for (const entry of await fs.list(logDir)) {
60    if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
61    seen.add(entry.name)
62    const stamp = `${entry.size}:${entry.mtimeMs}`
63    let cached = cache.get(entry.name)
64    if (cached?.stamp !== stamp) {
65      // A log that cannot be read (removed since the listing, too large, no
66      // permission) keeps what was last read of it, or is skipped, so it never
67      // hides the others.
68      const text = await fs.read(`${logDir}/${entry.name}`).catch(() => null)
69      if (text === null) {
70        if (cached) events.push(...cached.events)
71        continue
72      }
73      cached = { stamp, events: parse(text) }
74      cache.set(entry.name, cached)
75    }
76    events.push(...cached.events)
77  }
78  for (const name of cache.keys()) if (!seen.has(name)) cache.delete(name)
79
80  return events
81}
82
83// Appends to this session's own log. $.fs has no append, so this rewrites the
84// file; safe because no other session writes it.
85export async function appendEvents(fs: Files, dir: string, session: string, events: TaskEvent[]): Promise<void> {
86  if (events.length === 0) return
87  const file = `${dir}/log/${session}.jsonl`
88  const before = (await fs.exists(file)) ? await fs.read(file) : ''
89  await fs.write(file, before + events.map(event => `${JSON.stringify(event)}\n`).join(''))
90}
91
hooks/view.ts 93 lines
1// What the footer, pane and session-start list show, as plain functions.
2
3import type { Project, Tab, Task } from '../types'
4
5// Between the count and the name: a space and a no-break space, since the
6// desktop draws labels as web text and folds two plain spaces into one.
7export const GAP = ' \u00a0'
8
9export const AMBER = '#d48a1a'
10export const BLUE = '#6ea8e0'
11export const PURPLE = '#b392f0'
12
13// Open tasks for a project, or every open task outside one.
14export function openFor(tasks: Task[], project: Project): Task[] {
15  return tasks.filter(t => t.status === 'open' && (project === null || t.repo?.root === project.root))
16}
17
18// Favourites first, then newest first.
19export function ordered(tasks: Task[]): Task[] {
20  return [...tasks].sort((a, b) => Number(b.favourite) - Number(a.favourite) || b.createdAt.localeCompare(a.createdAt))
21}
22
23// The footer button: "<open here>  Next Up ↗", or just the name with none.
24export function footerLabel(tasks: Task[], project: Project): string {
25  const count = openFor(tasks, project).length
26
27  return count > 0 ? `${count}${GAP}Next Up ↗` : 'Next Up ↗'
28}
29
30export type PaneView = {
31  tabs: Array<{ id: Tab; label: string }>
32  current: Tab
33  // Cards in order; a header starts each project group in ★ and All.
34  // A header's key is the project's root (two clones can share a name).
35  items: Array<{ kind: 'header'; key: string; name: string } | { kind: 'card'; task: Task }>
36}
37
38const NO_PROJECT = 'No project'
39
40// Projects are told apart by their root; tasks outside any share one group.
41function groupOf(task: Task): { key: string; name: string } {
42  return task.repo ? { key: task.repo.root, name: task.repo.name } : { key: '', name: NO_PROJECT }
43}
44
45// The pane's tabs and list. The project tab exists only inside a project;
46// ★ and All group by project, this project first, then by name.
47export function paneView(tasks: Task[], project: Project, tab: Tab): PaneView {
48  const open = tasks.filter(t => t.status === 'open')
49  const here = project ? open.filter(t => t.repo?.root === project.root) : []
50  const favourites = open.filter(t => t.favourite)
51  const tabs: PaneView['tabs'] = [
52    { id: 'favourites', label: `★ ${favourites.length}` },
53    ...(project ? [{ id: 'project' as const, label: `${project.name} ${here.length}` }] : []),
54    { id: 'all', label: `All ${open.length}` },
55  ]
56  const current: Tab = tab === 'project' && !project ? 'all' : tab
57  if (current === 'project') return { tabs, current, items: ordered(here).map(task => ({ kind: 'card', task })) }
58
59  const shown = current === 'favourites' ? favourites : open
60  const groups = [...new Map(shown.map(t => [groupOf(t).key, groupOf(t)])).values()].sort((a, b) =>
61    a.key === project?.root ? -1 : b.key === project?.root ? 1 : a.key === '' ? 1 : b.key === '' ? -1 : a.name.localeCompare(b.name),
62  )
63  const items: PaneView['items'] = groups.flatMap(group => [
64    { kind: 'header' as const, ...group },
65    ...ordered(shown.filter(t => groupOf(t).key === group.key)).map(task => ({ kind: 'card' as const, task })),
66  ])
67
68  return { tabs, current, items }
69}
70
71// The session-start list: up to three open tasks for the project (every
72// project outside one), favourites first.
73export function bandTasks(tasks: Task[], project: Project): Task[] {
74  return ordered(openFor(tasks, project)).slice(0, 3)
75}
76
77// The label's colour, text only: plans amber, added blue, suggestions purple.
78export function labelColour(task: Task): string {
79  if (task.kind === 'plan') return AMBER
80
81  return task.kind === 'added' ? BLUE : PURPLE
82}
83
84// About three lines of description: the site's width in cells times three,
85// plus a third for the desktop's proportional font fitting more per cell.
86export function detailCap(columns: number): number {
87  return Math.max(60, Math.floor((columns - 6) * 3 * 1.3))
88}
89
90export function cap(text: string, max: number): string {
91  return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
92}
93
types/index.d.ts 62 lines
1// Next Up's state contract: the task model, and the values its drawings read.
2
3export type Kind = 'suggested' | 'added' | 'plan'
4
5export type Source = { kind: 'chip' | 'tool' | 'manual'; session: string; at: string }
6
7export type Task = {
8  id: string
9  title: string
10  // The card's summary, about three lines.
11  detail: string
12  // The full prompt Now and New send.
13  prompt: string
14  repo: { root: string; name: string } | null
15  kind: Kind
16  plan?: { id: string; title: string; step: number; of: number }
17  // done: its prompt was sent.
18  status: 'open' | 'done' | 'dropped'
19  favourite: boolean
20  seen: number
21  sources: Source[]
22  createdAt: string
23  updatedAt: string
24}
25
26export type Project = Task['repo']
27
28export type Tab = 'favourites' | 'project' | 'all'
29
30// A step of the work in this session, kept by Claude through
31// update_session_tasks. In memory only: it ends with the session.
32export type SessionTask = {
33  // Short, for Claude to refer to: s1, s2, ...
34  id: string
35  title: string
36  detail: string
37  // removed: dropped by Claude or the user. Kept, hidden, so its id is never
38  // given to another task.
39  status: 'todo' | 'done' | 'removed'
40  createdAt: string
41  doneAt?: string
42}
43
44export type TasksTab = 'todo' | 'done'
45
46declare module 'claude-code' {
47  interface PluginState {
48    'next-up': {
49      // Every task, folded from all sessions' logs.
50      tasks: Task[]
51      // The session's project (its git root), or null outside one.
52      project: Project
53      tab: Tab
54      // The session-start list: shown in a new session until the first prompt.
55      band: boolean
56      // This session's own task list, in the order Claude planned it.
57      session: SessionTask[]
58      tasksTab: TasksTab
59    }
60  }
61}
62