SLOPSHOPPER

loose-ends

A live checklist above the prompt of the conversation's loose ends: questions left unanswered, points made in passing, caveats and deferred work it moved past…

newbandcommandmodeltimer
v0.2.0MITupdated 2026-09-22fernandomoraes/loose-ends
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loose-ends
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /loose-ends ⎿ loose-ends: loose-ends: 0 open · 0 done ⎿ loose-ends: nothing yet: items show up as the conversation raises them ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

loose-ends

A live checklist above the Claude Code prompt of the conversation's loose ends: what was raised and then left behind when it moved on. An unanswered question, a point made in passing, a caveat nobody replied to, something deferred to "later" or skipped. Not the work in progress: that one you won't forget.

loose-ends demo

<sub>The demo is a recreation of the band, with the layout, colors and flow the plugin draws.</sub>

How it works

It is a Claude Mod: a plugin whose hooks are TypeScript functions running inside Claude Code.

  • After every main-loop turn (turn.complete), it reads the messages the checklist has not seen yet ($.session.messages()) and asks Haiku ($.model.complete) which of them were left behind and which open items the conversation settled. The latest exchange is held back one turn: something raised there is not a loose end yet, so it is judged once the next exchange shows whether it was picked up. At most 3 items are added per turn. The call runs outside the main context: the conversation's model never sees it.
  • The list is drawn in the band above the prompt (ui.render on AbovePrompt). Collapse it with ctrl+x ctrl+a.
  • Each item's ○ is a button that checks it off (and ✓ reopens it): click it in the fullscreen layout, or press ctrl+x tab to focus the band, walk with Tab or the arrows and press Enter.
  • Each session keeps its own list in $.store, so --resume brings it back. The last 30 sessions are kept.
  • /exit with open items asks first, from a command.run hook that runs the exit only when the dialog says so. It covers /exit alone: ctrl+c and ctrl+d raise no event a plugin can see, and session.end only observes. Turn it off with /loose-ends exit off.
  • Items you change by hand are pinned: the model never closes an item you reopened, and never adds back one you dropped.

Cost: one Haiku call per turn with the new messages (capped at about 40k characters) and the current list. It only runs in interactive sessions, never under claude -p.

/loose-ends

CommandWhat it does
/loose-endsThe whole list
/loose-ends add <text>Add an item by hand
/loose-ends done 2 5Mark items done
/loose-ends undo 2Reopen an item
/loose-ends rm 3Drop an item
/loose-ends clearEmpty the list
/loose-ends scanRead the whole conversation again
/loose-ends on / offShow or hide the band (off also pauses the model calls)
/loose-ends rows 12Rows the band may take
/loose-ends exit on / offAsk before /exit while items are open

Develop

Needs Claude Code 2.1.278 or later (the build its types were generated from) with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the settings env.

claude --plugin-dir .      # loads from disk, hot-reloads on save
bun test
claude plugin validate .claude-plugin/plugin.json

For type checking, run /plugin-types in a session, then bunx -p typescript tsc -p ..

To install it for every session:

claude plugin marketplace add fernandomoraes/loose-ends
claude plugin install loose-ends@loose-ends
Source 4 files
hooks/register.tsx 165 lines
1/* @jsx h */
2import type { CommandRunInput, CommandRunResult, EngineInterface, Register } from 'claude-code'
3import { DEFAULTS, applyCommand, openText, readConfig, type Config } from './loose-ends/command.ts'
4import { SYSTEM, buildPrompt, parseOps, pending } from './loose-ends/extract.ts'
5import { EMPTY, applyOps, bandItems, counts, mark, readState, toggle, type LooseEndsState } from './loose-ends/list.ts'
6
7const MODEL = 'haiku'
8const MAX_SESSIONS = 30
9const COLOR = '#5fb3b3'
10
11let config: Config = DEFAULTS
12let state: LooseEndsState = EMPTY
13let sessionId: string | undefined
14let isInteractive = false
15let isUpdating = false
16let isQueued = false
17
18const log = ($: EngineInterface, what: string, to: 'transcript' | 'debug' = 'transcript') => (err: unknown) =>
19  $.ui.log(`loose-ends: ${what}: ${err}`, { to })
20
21const keyOf = (id: string) => `session:${id}`
22
23async function syncSession($: EngineInterface): Promise<void> {
24  const id = await $.session.id()
25  if (id === sessionId) return
26  sessionId = id
27  state = readState(await $.store.get(keyOf(id)).catch(log($, 'store read failed')))
28  $.ui.invalidate('ui.render')
29}
30
31async function save($: EngineInterface): Promise<void> {
32  if (sessionId === undefined) return
33  await $.store.set(keyOf(sessionId), state)
34  const sessions = (await $.store.keys()).filter(key => key.startsWith('session:'))
35  for (const old of sessions.slice(0, Math.max(0, sessions.length - MAX_SESSIONS))) await $.store.delete(old)
36}
37
38async function toggleItem($: EngineInterface, id: number): Promise<void> {
39  state = toggle(state, id)
40  $.ui.invalidate('ui.render')
41  await save($).catch(log($, 'store write failed'))
42}
43
44async function update($: EngineInterface): Promise<void> {
45  if (isUpdating) {
46    isQueued = true
47    return
48  }
49  isUpdating = true
50  $.ui.invalidate('ui.render')
51  try {
52    await syncSession($)
53    const { batch, latest, cursor } = pending(await $.session.messages(), state.cursor)
54    if (batch.length > 0) {
55      const reply = await $.model.complete({ model: MODEL, system: SYSTEM, prompt: buildPrompt(state.items, batch, latest) })
56      const ops = parseOps(reply)
57      if (!ops) throw new Error(`unreadable reply: ${reply.slice(0, 200)}`)
58      state = applyOps(state, ops)
59    }
60    state = { ...state, cursor }
61    await save($)
62  } catch (err) {
63    log($, 'update failed', 'debug')(err)
64  } finally {
65    isUpdating = false
66    $.ui.invalidate('ui.render')
67    if (isQueued) {
68      isQueued = false
69      $.clock.after(0, () => void update($))
70    }
71  }
72}
73
74type Next = (e: CommandRunInput) => Promise<CommandRunResult>
75
76/** `/exit` while items are open: the dialog decides, and Esc or a dismissal keeps the session. */
77async function confirmExit($: EngineInterface, e: CommandRunInput, next: Next): Promise<CommandRunResult> {
78  await syncSession($).catch(log($, 'session not read', 'debug'))
79  const { open } = counts(state.items)
80  if (!config.enabled || !config.confirmExit || open === 0) return next(e)
81  const answer = await $.ui
82    .ask(`${open} open loose end${open === 1 ? '' : 's'}. Leave the session?`, { options: ['Leave', 'Stay'], header: 'Loose ends' })
83    .catch(() => 'Stay')
84  return answer === 'Leave' ? next(e) : { text: openText(state.items) }
85}
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    const r = await next(e)
90    isInteractive = e.isInteractive
91    config = readConfig(await $.store.get('config').catch(log($, 'store read failed')))
92    await syncSession($).catch(log($, 'session not read'))
93    await $.command.register({
94      name: 'loose-ends',
95      description: 'What this conversation left behind: add, done, undo, rm, clear, scan, on, off (loose-ends)',
96      argumentHint: '[add <text> | done <n> | undo <n> | rm <n> | clear | scan | on | off | rows <n> | exit on|off | help]',
97      immediate: true,
98    }).catch(log($, '/loose-ends not registered'))
99    return r
100  })
101
102  on('turn.start', async ($, e, next) => {
103    await syncSession($).catch(log($, 'session not read', 'debug'))
104    return next(e)
105  })
106
107  on('turn.complete', async ($, e, next) => {
108    const r = await next(e)
109    const isMainAnswer = e.agentId === undefined && (e.reason === 'answer' || e.reason === 'aborted')
110    if (isInteractive && config.enabled && isMainAnswer) $.clock.after(0, () => void update($))
111    return r
112  })
113
114  on('command.run', { command: 'loose-ends' }, async ($, e) => {
115    await syncSession($).catch(log($, 'session not read', 'debug'))
116    const applied = applyCommand(state, config, e.args)
117    if (applied.config !== config) {
118      config = applied.config
119      await $.store.set('config', config).catch(log($, 'store write failed'))
120    }
121    if (applied.state !== state) {
122      state = applied.state
123      await save($).catch(log($, 'store write failed'))
124    }
125    if (applied.scan) $.clock.after(0, () => void update($))
126    $.ui.invalidate('ui.render')
127    return { text: applied.text }
128  })
129
130  on('command.run', { command: 'exit' }, confirmExit)
131  on('command.run', { command: 'quit' }, confirmExit)
132
133  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134    if (!config.enabled || e.props.hasSurvey) return next(e)
135    const { open, done } = counts(state.items)
136    if (open + done === 0 && !isUpdating) return next(e)
137    const { Box, Text, Button } = $.ui.resolve(e)
138    const room = Math.max(1, Math.min(config.rows, e.props.maxRows) - 1)
139    const { shown, hidden } = bandItems(state.items, room)
140    const header = `Loose ends · ${open} open${done > 0 ? ` · ${done} done` : ''}${isUpdating ? ' · updating…' : ''}`
141    const hint = e.viewport?.isFullscreen ? 'click ○ to check off' : 'ctrl+x tab, then enter to check off'
142    return (
143      <Box flexDirection="column">
144        <Box flexDirection="column" width={e.props.bodyColumns}>
145          <Box flexDirection="row" gap={2}>
146            <Text color={COLOR} bold>{header}</Text>
147            <Text dimColor wrap="truncate-end">{hint}</Text>
148          </Box>
149          {shown.map(item => (
150            <Box flexDirection="row" paddingLeft={2}>
151              <Button key={`toggle:${item.id}`} label={`${mark(item)} ${item.id}.`} plain
152                dimColor={item.status === 'done'} onPress={() => void toggleItem($, item.id)} />
153              <Text wrap="truncate-end" dimColor={item.status === 'done'} strikethrough={item.status === 'done'}>
154                {` ${item.text}`}
155              </Text>
156            </Box>
157          ))}
158          {hidden > 0 ? <Text dimColor>{`  +${hidden} more · /loose-ends`}</Text> : null}
159        </Box>
160        {await next(e)}
161      </Box>
162    )
163  })
164}
165
hooks/loose-ends/command.ts 113 lines
1import { EMPTY, addItem, counts, mark, setStatus, type Item, type Status, type LooseEndsState } from './list.ts'
2
3export type Config = {
4  enabled: boolean
5  /** Rows the band may take above the prompt, its header included. */
6  rows: number
7  /** Whether `/exit` asks first while items are open. */
8  confirmExit: boolean
9}
10
11export const DEFAULTS: Config = { enabled: true, rows: 8, confirmExit: true }
12
13const MIN_ROWS = 2
14const MAX_ROWS = 30
15
16export function readConfig(saved: unknown): Config {
17  const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
18  return {
19    enabled: typeof s.enabled === 'boolean' ? s.enabled : DEFAULTS.enabled,
20    rows: typeof s.rows === 'number' && Number.isInteger(s.rows) && s.rows >= MIN_ROWS && s.rows <= MAX_ROWS ? s.rows : DEFAULTS.rows,
21    confirmExit: typeof s.confirmExit === 'boolean' ? s.confirmExit : DEFAULTS.confirmExit,
22  }
23}
24
25export const HELP = [
26  '/loose-ends                   the whole list',
27  '/loose-ends add <text>        add an item by hand',
28  '/loose-ends done <n...>       mark items done',
29  '/loose-ends undo <n...>       reopen items',
30  '/loose-ends rm <n...>         drop items (the model will not add them again)',
31  '/loose-ends clear             empty the list',
32  '/loose-ends scan              read the whole conversation again',
33  '/loose-ends on | off          show or hide the band above the prompt',
34  `/loose-ends rows <n>          rows the band may take (${MIN_ROWS} to ${MAX_ROWS})`,
35  '/loose-ends exit on | off     ask before /exit while items are open',
36].join('\n')
37
38const OPEN_SHOWN = 5
39
40/** What `/exit` prints when the person stays. */
41export function openText(items: readonly Item[]): string {
42  const open = items.filter(item => item.status === 'open')
43  const shown = open.slice(0, OPEN_SHOWN)
44  const rest = open.length - shown.length
45  return [
46    `loose-ends: staying here, ${open.length} open`,
47    ...shown.map(line),
48    ...(rest > 0 ? [`… +${rest} more · /loose-ends`] : []),
49  ].join('\n')
50}
51
52const line = (item: Item) => `${mark(item)} ${item.id}. ${item.text}`
53
54export function listText(items: readonly Item[], config: Config): string {
55  const visible = items.filter(item => item.status !== 'dropped')
56  const { open, done } = counts(items)
57  const head = `loose-ends: ${open} open · ${done} done${config.enabled ? '' : ' (band off: /loose-ends on)'}`
58  if (visible.length === 0) return `${head}\nnothing yet: items show up as the conversation raises them`
59  const ordered = [...visible.filter(item => item.status === 'open'), ...visible.filter(item => item.status === 'done')]
60  return [head, ...ordered.map(line)].join('\n')
61}
62
63function parseIds(words: readonly string[]): number[] {
64  return words.join(' ').split(/[\s,]+/).map(word => Number(word.replace(/^#/, ''))).filter(n => Number.isInteger(n) && n > 0)
65}
66
67const STATUS_OF: Record<string, Status> = { done: 'done', undo: 'open', rm: 'dropped' }
68
69export type Applied = { state: LooseEndsState; config: Config; text: string; scan?: true }
70
71/** Applies one `/loose-ends` invocation; returns the new state and config and the transcript line. */
72export function applyCommand(state: LooseEndsState, config: Config, args: string): Applied {
73  const trimmed = args.trim()
74  const [head = '', ...rest] = trimmed.split(/\s+/)
75  const verb = head.toLowerCase()
76  const same = (text: string): Applied => ({ state, config, text })
77
78  if (!verb || verb === 'list') return same(listText(state.items, config))
79  if (verb === 'help') return same(HELP)
80  if (verb === 'on' || verb === 'off') {
81    return { state, config: { ...config, enabled: verb === 'on' }, text: `loose-ends: band ${verb}` }
82  }
83  if (verb === 'add') {
84    const added = addItem(state, trimmed.slice(head.length))
85    if (!added.item) return same('loose-ends: add takes the item\'s text, e.g. /loose-ends add review the schema')
86    return { state: added.state, config, text: `loose-ends: added ${line(added.item)}` }
87  }
88  const status = STATUS_OF[verb]
89  if (status) {
90    const ids = parseIds(rest)
91    if (ids.length === 0) return same(`loose-ends: ${verb} takes item numbers, e.g. /loose-ends ${verb} 2 5`)
92    const result = setStatus(state, ids, status)
93    const report = [
94      ...result.changed.map(line),
95      ...(result.missing.length > 0 ? [`no item ${result.missing.join(', ')}`] : []),
96    ]
97    return { state: result.state, config, text: `loose-ends: ${report.join('\n')}` }
98  }
99  if (verb === 'clear') return { state: { ...EMPTY, cursor: state.cursor }, config, text: 'loose-ends: cleared' }
100  if (verb === 'scan') return { state: { ...state, cursor: 0 }, config, text: 'loose-ends: reading the conversation again…', scan: true }
101  if (verb === 'exit') {
102    const arg = (rest[0] ?? '').toLowerCase()
103    if (arg !== 'on' && arg !== 'off') return same('loose-ends: exit on or off')
104    return { state, config: { ...config, confirmExit: arg === 'on' }, text: `loose-ends: /exit asks first: ${arg}` }
105  }
106  if (verb === 'rows') {
107    const rows = Number(rest[0])
108    if (!Number.isInteger(rows) || rows < MIN_ROWS || rows > MAX_ROWS) return same(`loose-ends: rows takes a whole number from ${MIN_ROWS} to ${MAX_ROWS}`)
109    return { state, config: { ...config, rows }, text: `loose-ends: the band takes up to ${rows} rows` }
110  }
111  return same(`loose-ends: no command "${head}"\n${HELP}`)
112}
113
hooks/loose-ends/extract.ts 87 lines
1import type { Item, Ops } from './list.ts'
2
3export type Message = { role: 'user' | 'assistant'; text: string }
4
5export const SYSTEM = `You watch a conversation between a person and an AI coding assistant and keep a short list of its loose ends: things that were raised, then left behind when the conversation moved on, and that the person would regret forgetting. You receive the current list, the earlier messages to judge, and the latest exchange, which shows where the conversation went next. Answer with JSON only, no prose:
6{"add": ["<new item>", ...], "done": [<id>, ...]}
7
8add: loose ends raised in <earlier_messages> that <latest_exchange> did not pick up, and that the list does not already cover (open, done or dropped). A loose end is, for example:
9- a question the person asked that went unanswered, or that the assistant answered only in part
10- a point the person raised in passing, next to the main request, that nobody came back to
11- something deferred with "later", "for now", "after this", or a follow-up both agreed on without doing it
12- a risk, caveat, bug or side effect the assistant noticed and the person did not respond to
13- something the assistant skipped, stubbed, left as a TODO, could not verify, or assumed without confirming
14- a choice the assistant offered or asked about that the person never answered
15Not a loose end: the work being done, the person's main request and its steps, anything <latest_exchange> works on or answers, plans the assistant is carrying out, options that were weighed and settled, explanations, and the assistant's own intermediate steps. When unsure, leave it out: most turns add nothing, and a missed item costs less than noise. Write each item as a short phrase of at most 80 characters, in the language the person writes in, specific enough to act on without rereading the conversation. Merge related points into one item.
16done: ids of open items the messages clearly settled: answered, decided, implemented or explicitly abandoned.
17When nothing changes, answer {"add": [], "done": []}.`
18
19const MAX_MESSAGE_CHARS = 4000
20const MAX_BATCH_CHARS = 40000
21const MAX_LATEST_CHARS = 12000
22export const MAX_ADDS = 3
23export const RESCAN_MESSAGES = 40
24
25export type Pending = {
26  /** Messages to judge: they come before the person's latest message, so what happened next is known. */
27  batch: Message[]
28  /** The person's latest message and the answers to it: evidence of what was picked up, judged on the next turn. */
29  latest: Message[]
30  cursor: number
31}
32
33/**
34 * The messages past `cursor`, minus the latest exchange: something raised there is not left behind yet,
35 * so the cursor stops at the person's latest message and that exchange is judged once the next one exists.
36 * A transcript shorter than the cursor was compacted or cleared, so the tail is read again.
37 */
38export function pending(all: readonly Message[], cursor: number): Pending {
39  const from = cursor > all.length ? Math.max(0, all.length - RESCAN_MESSAGES) : cursor
40  const lastUser = all.findLastIndex(message => message.role === 'user' && message.text.trim() !== '')
41  const split = lastUser < from ? from : lastUser
42  const keep = (messages: readonly Message[]) => messages.filter(message => message.text.trim() !== '')
43  return { batch: keep(all.slice(from, split)), latest: keep(all.slice(Math.max(from, lastUser))), cursor: split }
44}
45
46const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)} […]` : text)
47
48const line = (message: Message) => `${message.role === 'user' ? 'PERSON' : 'ASSISTANT'}: ${clip(message.text.trim(), MAX_MESSAGE_CHARS)}`
49
50/** The newest messages that fit in `budget` characters; `fromStart` keeps the oldest instead. */
51function fit(messages: readonly Message[], budget: number, fromStart = false): string {
52  const lines: string[] = []
53  const ordered = fromStart ? messages : [...messages].reverse()
54  for (const message of ordered) {
55    const text = line(message)
56    if (text.length > budget) break
57    budget -= text.length
58    if (fromStart) lines.push(text)
59    else lines.unshift(text)
60  }
61  return lines.join('\n\n')
62}
63
64export function buildPrompt(items: readonly Item[], batch: readonly Message[], latest: readonly Message[] = []): string {
65  const list = items.length === 0 ? '(empty)' : items.map(item => `#${item.id} [${item.status}] ${item.text}`).join('\n')
66  return `<list>\n${list}\n</list>\n\n<earlier_messages>\n${fit(batch, MAX_BATCH_CHARS)}\n</earlier_messages>\n\n<latest_exchange>\n${fit(latest, MAX_LATEST_CHARS, true)}\n</latest_exchange>`
67}
68
69export function parseOps(reply: string): Ops | undefined {
70  const start = reply.indexOf('{')
71  const end = reply.lastIndexOf('}')
72  if (start < 0 || end < start) return undefined
73  let data: unknown
74  try {
75    data = JSON.parse(reply.slice(start, end + 1))
76  } catch {
77    return undefined
78  }
79  if (typeof data !== 'object' || data === null) return undefined
80  const d = data as Record<string, unknown>
81  const add = Array.isArray(d.add) ? d.add.filter((x): x is string => typeof x === 'string').slice(0, MAX_ADDS) : []
82  const done = Array.isArray(d.done)
83    ? d.done.map(x => Number(String(x).replace(/^#/, ''))).filter(Number.isInteger)
84    : []
85  return { add, done }
86}
87
hooks/loose-ends/list.ts 106 lines
1export type Status = 'open' | 'done' | 'dropped'
2
3export type Item = {
4  id: number
5  text: string
6  status: Status
7  /** Set once the person changed the status by hand: the model no longer closes it. */
8  pinned: boolean
9}
10
11export type LooseEndsState = { items: Item[]; nextId: number; cursor: number }
12
13export type Ops = { add: string[]; done: number[] }
14
15export const EMPTY: LooseEndsState = { items: [], nextId: 1, cursor: 0 }
16
17const MAX_TEXT = 120
18
19export function cleanText(text: string): string {
20  const flat = text.replace(/\s+/g, ' ').trim()
21  return flat.length > MAX_TEXT ? `${flat.slice(0, MAX_TEXT - 1)}…` : flat
22}
23
24const normalize = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
25
26const isStatus = (value: unknown): value is Status => value === 'open' || value === 'done' || value === 'dropped'
27
28function readItem(raw: unknown): Item[] {
29  if (typeof raw !== 'object' || raw === null) return []
30  const r = raw as Record<string, unknown>
31  if (typeof r.id !== 'number' || !Number.isInteger(r.id) || typeof r.text !== 'string' || !isStatus(r.status)) return []
32  return [{ id: r.id, text: r.text, status: r.status, pinned: r.pinned === true }]
33}
34
35export function readState(saved: unknown): LooseEndsState {
36  const s = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>
37  const items = Array.isArray(s.items) ? s.items.flatMap(readItem) : []
38  const maxId = items.reduce((max, item) => Math.max(max, item.id), 0)
39  const nextId = typeof s.nextId === 'number' && Number.isInteger(s.nextId) && s.nextId > maxId ? s.nextId : maxId + 1
40  const cursor = typeof s.cursor === 'number' && Number.isInteger(s.cursor) && s.cursor >= 0 ? s.cursor : 0
41  return { items, nextId, cursor }
42}
43
44export function applyOps(state: LooseEndsState, ops: Ops): LooseEndsState {
45  const items = state.items.map(item =>
46    item.status === 'open' && !item.pinned && ops.done.includes(item.id) ? { ...item, status: 'done' as const } : item)
47  const seen = new Set(items.map(item => normalize(item.text)))
48  let nextId = state.nextId
49  for (const raw of ops.add) {
50    const text = cleanText(raw)
51    const key = normalize(text)
52    if (!key || seen.has(key)) continue
53    seen.add(key)
54    items.push({ id: nextId++, text, status: 'open', pinned: false })
55  }
56  return { ...state, items, nextId }
57}
58
59export function addItem(state: LooseEndsState, raw: string): { state: LooseEndsState; item: Item | undefined } {
60  const text = cleanText(raw)
61  if (!text) return { state, item: undefined }
62  const item: Item = { id: state.nextId, text, status: 'open', pinned: false }
63  return { state: { ...state, items: [...state.items, item], nextId: state.nextId + 1 }, item }
64}
65
66export function setStatus(state: LooseEndsState, ids: readonly number[], status: Status): { state: LooseEndsState; changed: Item[]; missing: number[] } {
67  const known = new Set(state.items.map(item => item.id))
68  const missing = ids.filter(id => !known.has(id))
69  const changed: Item[] = []
70  const items = state.items.map(item => {
71    if (!ids.includes(item.id)) return item
72    const next = { ...item, status, pinned: true }
73    changed.push(next)
74    return next
75  })
76  return { state: changed.length > 0 ? { ...state, items } : state, changed, missing }
77}
78
79export function toggle(state: LooseEndsState, id: number): LooseEndsState {
80  const item = state.items.find(candidate => candidate.id === id)
81  if (!item || item.status === 'dropped') return state
82  return setStatus(state, [id], item.status === 'open' ? 'done' : 'open').state
83}
84
85export function counts(items: readonly Item[]): { open: number; done: number } {
86  let open = 0
87  let done = 0
88  for (const item of items) {
89    if (item.status === 'open') open++
90    else if (item.status === 'done') done++
91  }
92  return { open, done }
93}
94
95/** What fits in `room` rows: open items first, then the latest done ones; one row goes to "+N more". */
96export function bandItems(items: readonly Item[], room: number): { shown: Item[]; hidden: number } {
97  const open = items.filter(item => item.status === 'open')
98  const done = items.filter(item => item.status === 'done').reverse()
99  const all = [...open, ...done]
100  if (all.length <= room) return { shown: all, hidden: 0 }
101  const fit = Math.max(0, room - 1)
102  return { shown: all.slice(0, fit), hidden: all.length - fit }
103}
104
105export const mark = (item: Item) => (item.status === 'done' ? '✓' : item.status === 'dropped' ? '✗' : '○')
106