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…

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.

<sub>The demo is a recreation of the band, with the layout, colors and flow the plugin draws.</sub>
It is a Claude Mod: a plugin whose hooks are TypeScript functions running inside Claude Code.
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.ui.render on AbovePrompt). Collapse it with ctrl+x ctrl+a.○ 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.$.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.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| Command | What it does |
|---|---|
/loose-ends | The whole list |
/loose-ends add <text> | Add an item by hand |
/loose-ends done 2 5 | Mark items done |
/loose-ends undo 2 | Reopen an item |
/loose-ends rm 3 | Drop an item |
/loose-ends clear | Empty the list |
/loose-ends scan | Read the whole conversation again |
/loose-ends on / off | Show or hide the band (off also pauses the model calls) |
/loose-ends rows 12 | Rows the band may take |
/loose-ends exit on / off | Ask before /exit while items are open |
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-endshooks/register.tsx 165 lines1/* @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}
165hooks/loose-ends/command.ts 113 lines1import { 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}
113hooks/loose-ends/extract.ts 87 lines1import 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}
87hooks/loose-ends/list.ts 106 lines1export 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