Track a project's tasks in its TASKS.md, shown in a pane

A task list panel for Claude Code. You and Claude both work from the same list.
- [ ] A task
A note
- [x] A done task
## A list
- [ ] Another task
git clone https://github.com/amaezey/task-list ~/.claude/skills/task-list
Start a new Claude Code session, then type /task.
git -C ~/.claude/skills/task-list pull
hooks/register.tsx 462 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { tall } from './list'
5import type { Row } from './list'
6import { LIST, TASK, bare, shield, addList, addNote, drop, dropList, file, findList, headOf, isDone, isOpen, lay, match, nameOf, placeList, remove, rename, rooms, setNote, tick, notesOf } from './tasks'
7
8const PANE = 'task-list'
9const FILE = 'TASKS.md'
10// The pane, and the width it asks for when docked beside the terminal's transcript: enough for a
11// name, a count and the row's marks, so the list fills its column instead of floating in it. (A
12// width the person drags the dock to wins; the desktop app and the inline block ignore it.)
13const OPEN = { id: PANE, title: 'Tasks', columns: 46 }
14// The file says what it is to whoever opens it, person or agent: its first line, written once.
15const NOTE = '<!-- Tasks, one per line: "- [ ] to do" or "- [x] done". "## Name" starts a sublist. Indent a task\'s notes under it. The Tasks panel shows this file. -->'
16// The one tool agents get. Its description is the whole briefing: what the list is, where it lives
17// and that the file may be edited directly, so nothing else has to tell an agent the list exists.
18const TOOL = 'mcp__task-list__tasks'
19const BRIEF = `The user's task list for this project, which they see in a side panel. It is ${FILE} in the project folder, plain markdown: "- [ ] name" is a task, "- [x] name" a done one, "## Name" starts a sublist, and lines indented under a task are its notes. Use this tool to show the list, to add, finish, reopen or remove a task, or to add a note to one, and when the user asks you to remember something they need to do. For anything else (rename, move, reorder, delete a sublist) edit the file. Every call returns the list as it now stands.`
20const INPUT = {
21 type: 'object',
22 properties: {
23 action: { enum: ['show', 'add', 'done', 'reopen', 'remove', 'note'], description: 'What to do. "remove" takes a task with its notes.' },
24 task: {
25 type: 'string',
26 description: 'For add: the new task. For done, reopen, remove and note: the name of the task, or enough of it to mean only one.',
27 },
28 note: { type: 'string', description: 'For note: text to add under the task, after any notes it has; empty takes its notes away. For add: a note to put under the new task.' },
29 list: { type: 'string', description: 'For add: the sublist to put it in, whatever its case, created if it is not there. Leave out for no sublist.' },
30 },
31 required: ['action'],
32} as const
33// TASKS.md in the project folder is the source of truth; this mirrors it so the pane redraws
34const text = atom({ plugin: 'task-list', key: 'text' } as const, '')
35// the line of the task or sublist whose name is being edited in place
36const editing = atom({ plugin: 'task-list', key: 'editing' } as const, null)
37// the line of the task whose every line shows: the rest of a long name, and its notes
38const expanded = atom({ plugin: 'task-list', key: 'expanded' } as const, null)
39// the fixed heading over the tasks in no sublist, shown once there is a sublist to tell them from
40const TOP = 'General'
41// the pane's width in columns, as the list reports it: names are laid out in lines to fit it
42const wide = atom({ plugin: 'task-list', key: 'wide' } as const, 0)
43// how many rows the list has asked for under the open field, to write a long name out whole
44const liveRows = atom({ plugin: 'task-list', key: 'liveRows' } as const, 0)
45// The clipboard as last read for the list. The list does its own typing and may not read the
46// clipboard, so on cmd+v it asks here, where a command can be run; `n` tells one read from the next.
47const clip = atom({ plugin: 'task-list', key: 'clip' } as const, { n: 0, text: '' })
48// where a new name is being typed: a sublist's heading line, -1 for the tasks in no sublist, or
49// 'list' for a new sublist
50const adding = atom({ plugin: 'task-list', key: 'adding' } as const, null)
51// Whether done tasks are left out of the pane. It is how the person likes to look at the list, not
52// part of the list, so it is kept in the mod's own store and TASKS.md never hears of it.
53const hideDone = atom({ plugin: 'task-list', key: 'hideDone' } as const, false)
54// what is in the terminal's own field as it is typed (the terminal only: the list does not hear keys there)
55const draft = atom({ plugin: 'task-list', key: 'draft' } as const, '')
56// which field that is: the terminal keeps a field's text under its key, so each new one takes a new key
57const field = atom({ plugin: 'task-list', key: 'field' } as const, 0)
58
59const load = async $ => {
60 // Only a file that is not there is an empty list. A read that fails for any other reason must
61 // stop here: carrying on as if the list were empty would have the next save wipe it.
62 // (a file saved with Windows line ends is read as any other; it is saved back without them)
63 const file = (await $.fs.exists(FILE)) ? shield((await $.fs.read(FILE)).replace(/\r\n/g, '\n')) : ''
64 // an unchanged file must not redraw the pane: a redraw between press and release loses the click
65 if ((await read($, text)) !== file) await update($, text, () => file)
66
67 return file
68}
69const save = async ($, lines: string[]) => {
70 const isNoted = lines.some(line => line.startsWith('<!-- Tasks'))
71 const file = (isNoted ? lines : [NOTE, '', ...lines]).join('\n')
72 await $.fs.write(FILE, bare(file))
73 await update($, text, () => file)
74 // the first line, written in, moves every line under it down two: what is open moves with its line
75 if (!isNoted) {
76 const down = (now: unknown) => (typeof now === 'number' && now >= 0 ? now + 2 : now) as never
77 await update($, expanded, down)
78 await update($, editing, down)
79 await update($, adding, down)
80 }
81}
82// Re-reads the file, then applies one change to the task or sublist on `line`. `label` is its name
83// as the pane drew it: a file that changed under the pane redraws instead of changing the wrong one.
84const change = async ($, line: number, label: string, apply: (lines: string[]) => string[]) => {
85 const fresh = (await load($)).split('\n')
86 await update($, editing, () => null)
87 await update($, expanded, () => null)
88 if (label !== undefined && nameOf(fresh[line]) === label) await save($, apply(fresh))
89}
90const add = async ($, apply: (lines: string[]) => string[]) => save($, apply((await load($)).split('\n')))
91// keeps the name typed in the terminal's field: as the new name of what is being renamed, or as a
92// new task or sublist where one was being added. An empty name keeps nothing.
93const commit = async ($, typed: string) => {
94 const name = typed.replace(/\s+/g, ' ').trim()
95 const renaming = await read($, editing)
96 const open = await read($, adding)
97 const mirror = (await read($, text)).split('\n')
98 await update($, draft, () => '')
99 await update($, editing, () => null)
100 await update($, adding, () => null)
101 if (!name) return
102 if (renaming !== null) await change($, renaming, nameOf(mirror[renaming]), lines => rename(lines, renaming, name))
103 else if (open === 'list') await add($, fresh => addList(fresh, name))
104 else if (typeof open === 'number') {
105 const put = (fresh: string[]) => file(fresh, open, `- [ ] ${name}`)
106 await (open < 0 ? add($, put) : change($, open, nameOf(mirror[open]), put))
107 }
108}
109
110// The tool's answer: what happened in a sentence, then the list. A name that fits no task, or more
111// than one, changes nothing and says so beside the list, which is all an agent needs to try again.
112const answer = async ($, input: { action?: string; task?: string; list?: string; note?: string }) => {
113 const fresh = (await load($)).split('\n')
114 // a name is one line, whatever was sent: a line break in one would write lines of its own into the file
115 const task = (input.task ?? '').replace(/\s+/g, ' ').trim()
116 const list = (input.list ?? '').replace(/\s+/g, ' ').trim()
117 const say = (what: string, lines: string[]) => ({ result: `${what}\n\n${FILE}:\n${bare(lines.join('\n')).trim() || '(empty)'}` })
118 const done = async (what: string, lines: string[]) => {
119 await save($, lines)
120
121 return say(what, (await read($, text)).split('\n'))
122 }
123 if (input.action === 'show' || !input.action) return say('The task list.', fresh)
124 if (!task) return say(`Nothing changed: "${input.action}" needs a task.`, fresh)
125 if (input.action === 'add') {
126 const isTop = !list || list.toLowerCase() === TOP.toLowerCase()
127 const lines = isTop || findList(fresh, list) >= 0 ? fresh : addList(fresh, list)
128
129 // the new task, with its note under it if it was given one
130 const head = isTop ? -1 : findList(lines, list)
131 const added = file(lines, head, `- [ ] ${task}`)
132 // (the line that was put in, which is the first that differs: another task may have the same name)
133 const at = added.findIndex((line, index) => line !== lines[index])
134
135 return done(`Added "${task}"${isTop ? '' : ` to ${list}`}.`, input.note?.trim() ? setNote(added, at, input.note) : added)
136 }
137 const found = match(fresh, task)
138 if (found.length !== 1)
139 return say(found.length ? `Nothing changed: "${task}" fits ${found.length} tasks. Use more of its name.` : `Nothing changed: no task is called "${task}".`, fresh)
140 const [at] = found
141 const row = fresh[at]
142 const name = nameOf(row)
143 if (input.action === 'remove') return done(`Removed "${name}".`, remove(fresh, at))
144 if (input.action === 'note') return input.note?.trim() ? done(`Noted under "${name}".`, addNote(fresh, at, input.note)) : done(`Took the notes off "${name}".`, setNote(fresh, at, ''))
145 if (input.action === 'done') return isDone(row) ? say(`"${name}" was already done.`, fresh) : done(`Done: "${name}".`, tick(fresh, at))
146 if (input.action === 'reopen') return isOpen(row) ? say(`"${name}" was already open.`, fresh) : done(`Reopened "${name}".`, tick(fresh, at))
147
148 return say(`Nothing changed: "${input.action}" is not show, add, done, reopen, remove or note.`, fresh)
149}
150
151
152// The arrow that opens a task out: one drawing, turned. Two characters never match as a pair,
153// since each is drawn by whichever font on the machine happens to hold it.
154// The marks at a row's right edge are drawings, not characters: a character is drawn by whichever
155// font on the machine happens to hold it, at the pane's one text size, so characters neither match
156// one another nor can be made larger. Each drawing is one stroke weight and one grey.
157// Under the pointer a drawing goes to the text colour, as a circle or a grip does. The mod is not
158// told the theme, so the drawing asks for itself: dark ink, or light ink where the scheme is dark.
159// (The blue on the drawing itself is what shows should a surface drop the style.)
160const INK = '<style>*{stroke:#1f1f1f}@media (prefers-color-scheme:dark){*{stroke:#f2f2f2}}</style>'
161const drawn = (size: number, path: string) => (isLit: boolean) =>
162 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${isLit ? '#4a90d9' : '#888'}" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${isLit ? INK : ''}${path}</svg>`
163const BIN = drawn(15, '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>')
164const PENCIL = drawn(15, '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>')
165const PLUS = drawn(15, '<path d="M5 12h14"/><path d="M12 5v14"/>')
166const CROSS = drawn(15, '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>')
167// the arrow that opens a task out: one drawing, turned to point right or down
168const ARROW = (turn: number) => drawn(15, `<path transform="rotate(${turn} 12 12)" d="m9 18 6-6-6-6"/>`)
169// which icon the pointer is on, as the list reports it: "row:part"
170const hotIcon = atom({ plugin: 'task-list', key: 'hotIcon' } as const, '')
171
172export const register: Register = on => {
173 // What the person did in the list. The list is one Client: it draws every row, the typing field
174 // included, and posts each act here, where the file is.
175 on('ui.message', async ($, e, next) => {
176 const did = e.data as { act: string; line?: number; before?: number | null; to?: number; name?: string; was?: string; isCancel?: boolean }
177 if (typeof did?.act !== 'string') return next(e)
178 const line = did.line ?? -1
179 // The name the person saw on this line: what the list says it drew there, else what the mirror
180 // holds. A change goes ahead only if the file still has that name on that line.
181 const label = did.was ?? nameOf((await read($, text)).split('\n')[line])
182 const name = (did.name ?? '').trim()
183 const put = (fresh: string[]) => file(fresh, line, `- [ ] ${name}`)
184 // On a terminal the list never hears the keys: a name is typed in the terminal's own field, laid
185 // over the row, and the pane asks for the keyboard so the field has it. (The terminal grants that
186 // only while the prompt is empty.)
187 if (e.surface === 'terminal' && (did.act === 'add' || did.act === 'addList' || did.act === 'edit')) {
188 await update($, draft, () => '')
189 await update($, field, now => now + 1)
190 void $.ui.open({ ...OPEN, focus: true }).catch(() => {})
191 }
192 if (did.act === 'tick') await change($, line, label, lines => tick(lines, line))
193 else if (did.act === 'drop') await change($, line, label, lines => drop(lines, line, did.before ?? null))
194 else if (did.act === 'place') await change($, line, label, lines => placeList(lines, line, did.to ?? 0))
195 else if (did.act === 'remove') await change($, line, label, lines => (LIST.test(lines[line]) ? dropList(lines, line) : remove(lines, line)))
196 else if (did.act === 'rename' && name) await change($, line, label, lines => rename(lines, line, name))
197 // the fixed heading is no line of the file, so there is nothing of it to have changed
198 else if (did.act === 'create' && name) await (line < 0 ? add($, put) : change($, line, label, put))
199 else if (did.act === 'createList' && name) {
200 await add($, fresh => addList(fresh, name))
201 await update($, adding, () => null)
202 } else if (did.act === 'paste') {
203 // macOS: `pbpaste` prints the clipboard. Where it is missing, nothing is pasted.
204 const read = await $.process.run(['pbpaste']).catch(() => null)
205 if (read?.exitCode === 0) await update($, clip, now => ({ n: now.n + 1, text: read.stdout }))
206 } else if (did.act === 'copy' && name) await $.ui.copy({ text: name, surface: e.surface })
207 else if (did.act === 'shut') {
208 await update($, expanded, () => null)
209 }
210 else if (did.act === 'hide') {
211 const now = !(await read($, hideDone))
212 // a done task that was open, or being typed over, is about to leave the pane
213 await update($, editing, () => null)
214 await update($, expanded, () => null)
215 await update($, hideDone, () => now)
216 await $.store.set('hideDone', now).catch(() => {})
217 }
218 else if (did.act === 'hot') await update($, hotIcon, () => did.name ?? '')
219 else if (did.act === 'wide') await update($, wide, () => Math.max(0, (did as { n?: number }).n ?? 0))
220 else if (did.act === 'rows') await update($, liveRows, () => Math.max(0, Math.min(20, (did as { n?: number }).n ?? 0)))
221 else if (did.act === 'more') await update($, expanded, now => (now === line ? null : line))
222 else if (did.act === 'edit' && line >= 0) {
223 await update($, adding, () => null)
224 // typing somewhere else shuts a task that was opened out; typing over its own name does not
225 await update($, expanded, now => (now === line ? now : null))
226 // The rows the name will be written over are there from the first drawing of the field, as many
227 // as the list would ask for: had it to ask, the name's further lines would go and come back.
228 const name = nameOf((await read($, text)).split('\n')[line]) ?? ''
229 const spare = e.surface === 'terminal' ? 0 : lay(name, ...rooms((await read($, wide)) || 40)).length - 1
230 await update($, liveRows, () => Math.max(0, Math.min(20, spare)))
231 await update($, editing, () => line)
232 } else if (did.act === 'add' || did.act === 'addList') {
233 await update($, expanded, () => null)
234 await update($, editing, () => null)
235 await update($, liveRows, () => 0)
236 await update($, adding, now => (did.act === 'addList' ? 'list' : now === line ? null : line))
237 } else if (did.act === 'close' && e.surface === 'terminal' && !did.isCancel && (await read($, draft)).trim()) {
238 // a click away from the terminal's field keeps what was typed in it, as it does on the desktop
239 await commit($, await read($, draft))
240 } else if (did.act === 'close') {
241 // closing a name unchanged folds its task back up, as keeping a changed one does
242 await update($, expanded, () => null)
243 await update($, editing, () => null)
244 await update($, adding, () => null)
245 await update($, draft, () => '')
246 }
247
248 return next(e)
249 })
250
251 // the terminal's own field: each edit is kept as it is typed, and Enter keeps the name
252 on('ui.input', async ($, e, next) => {
253 if (!e.element.startsWith('tl-name')) return next(e)
254 if (e.kind === 'submit') await commit($, e.value ?? '')
255 else await update($, draft, () => e.value ?? '')
256
257 return next(e)
258 })
259
260 on('session.start', async ($, e, next) => {
261 await $.command.register({
262 name: 'task',
263 description: 'Add a task to TASKS.md, or open the task list with no text',
264 })
265 await $.tool.register({ name: 'tasks', description: BRIEF, inputSchema: INPUT })
266 // a list that cannot be read, or a store that cannot, must not stop the session starting
267 await load($).catch(() => {})
268 const isHidden = (await $.store.get('hideDone').catch(() => false)) === true
269 await update($, hideDone, () => isHidden)
270 // The pane opens by itself only in a folder that has a task list. Anywhere else it waits for
271 // /task, so a session about something else is not handed an empty panel.
272 if (await $.fs.exists(FILE).catch(() => false)) void $.ui.open(OPEN).catch(() => {})
273
274 return next(e)
275 })
276
277 on('command.run', { command: 'task' }, async ($, e) => {
278 await $.ui.open(OPEN)
279 const task = e.args.trim()
280 if (!task) {
281 await load($)
282
283 return { text: 'Task list opened.' }
284 }
285 await add($, lines => file(lines, -1, `- [ ] ${task}`))
286
287 return { text: `Added: ${task}` }
288 })
289
290 // picks up edits made to TASKS.md by hand or by Claude
291 on('tool.call', async ($, e, next) => {
292 // a list that cannot be read is said so: an agent told nothing would take the list for empty
293 if (e.tool === TOOL) return answer($, e as never).catch((fault: Error) => ({ result: `Nothing changed: ${FILE} could not be read or saved (${fault.message}).` }))
294 const ran = await next(e)
295 // another tool's result is its own, whatever becomes of this re-read
296 await load($).catch(() => {})
297
298 return ran
299 })
300
301 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
302 const table = $.ui.resolve(e)
303 const { Box, Text } = table
304 const lines = (await read($, text)).split('\n')
305 const renaming = await read($, editing)
306 const open = await read($, adding)
307 const whole = await read($, expanded)
308 const isHiding = await read($, hideDone)
309 const isGrid = e.surface === 'terminal'
310 const spare = await read($, liveRows)
311 // the rows under an open field that the list writes a long name out on
312 const liveUnder = (line: number): Row[] => Array.from({ length: spare }, () => ({ kind: 'more', line, label: '', isDone: false, note: '', isLive: true }))
313 // a name's lines in a pane this wide: the same breaks the list uses when the name is typed
314 const columns = (await read($, wide)) || e.props.bodyColumns || 40
315 const [first, rest] = rooms(columns, isGrid)
316 const linesOf = (name: string) => lay(name, first, rest).map(([start, end]) => name.slice(start, end).trimEnd())
317 const heads = lines.flatMap((line, index) => (LIST.test(line) ? [index] : []))
318 const todo = (head: number) =>
319 lines.slice(head + 1, heads.find(other => other > head) ?? lines.length).filter(isOpen).length
320 // the last task of a sublist; the heading itself when it has none
321 const tail = (head: number) => lines.findLastIndex((line, index) => index === head || (headOf(lines, index) === head && TASK.test(line)))
322
323 // the pane top to bottom, a row each: the list draws exactly these
324 const rows: Row[] = []
325 const blank = (kind: Row['kind'], line = -1): Row => ({ kind, line, label: '', isDone: false, note: '' })
326 // the row a new task is typed in closes the list it will join
327 const newIn = (head: number) => open === head && rows.push(blank('new', head), ...liveUnder(head))
328 if (heads.length) {
329 const loose = lines.slice(0, heads[0]).filter(line => TASK.test(line)).length
330 rows.push({ kind: 'top', line: -1, label: TOP, isDone: !todo(-1), note: todo(-1) ? `${todo(-1)} to do` : loose ? 'all done' : '' })
331 }
332 let head = -1
333 lines.forEach((line, index) => {
334 const task = TASK.exec(line)
335 const list = LIST.exec(line)
336 const isTyping = renaming === index
337 if (list) {
338 newIn(head)
339 head = index
340 if (rows.length) rows.push(blank('gap', index))
341 const count = todo(index)
342 // an empty sublist has nothing done in it
343 const isEmpty = tail(index) === index
344 rows.push({ kind: 'list', line: index, label: list[1], name: list[1], isDone: !count && !isEmpty, note: count ? `${count} to do` : isEmpty ? '' : 'all done', isTyping })
345 if (isTyping) rows.push(...liveUnder(index))
346 } else if (task) {
347 const isDone = task[1] !== ' '
348 if (isDone && isHiding && !isTyping) return
349 const [top, ...more] = linesOf(task[2])
350 // What is under the task: its notes, each as many rows of text as it needs, standing in from the task's name.
351 const notes: Row[] = notesOf(lines, index).flatMap(text =>
352 lay(text, rest - 3, rest - 3).map(([start, end]) => ({ kind: 'more' as const, line: index, label: text.slice(start, end).trimEnd(), isDone, note: '', isNote: true })),
353 )
354 // one arrow for "there is more to this task", whether that is the rest of a long name or its notes
355 const note = more.length || notes.length ? 'more' : ''
356 // At rest a row holds the whole name and the list cuts it at the row's edge. Opened out, it
357 // holds the name's first line, and the lines after it follow on rows of their own.
358 const isOut = whole === index && !isTyping
359 rows.push({ kind: 'task', line: index, label: isOut ? top : task[2], name: task[2], isDone, note, isOpen: isOut, isTyping })
360 // while a name is typed its notes stay in view under it, if the task was opened out
361 if (isTyping) rows.push(...liveUnder(index), ...(whole === index ? notes : []))
362 if (isOut)
363 rows.push(
364 ...more.map(label => ({ kind: 'more' as const, line: index, label, isDone, note })),
365 ...notes,
366 )
367 }
368 })
369 newIn(head)
370 // a new sublist is typed where the foot stood, so a second click on the same spot lands in it
371 if (open === 'list') rows.push(...(rows.length ? [blank('gap')] : []), blank('newlist'), ...liveUnder(-1))
372 const isEmpty = !rows.length
373 // The foot: a row that starts a sublist, with the words that hide what is done at its right end
374 // (only where something is done), and under it a row that starts a task in General.
375 const finished = lines.filter(line => isDone(line)).length
376 rows.push(
377 ...(rows.length && open !== 'list' ? [blank('gap')] : []),
378 { ...blank('foot'), label: 'list', note: !finished ? '' : isHiding ? `Show ${finished} done` : 'Hide done' },
379 { ...blank('foot'), label: 'task' },
380 )
381
382 // An icon is a picture laid on row `at` (placed against that row's own box), in a slot the list leaves clear there: the list cannot
383 // draw one. `side` counts columns from the pane's right edge, or from its left when negative
384 // (-1 is column 0). `sign` stands in where a surface has no drawings. A picture cannot be pressed
385 // and takes any click that lands on it, so every icon lies under the list, which is drawn over
386 // them and is clear where it has left a slot: the click, and the light under the pointer, are
387 // the list's. (A button over the picture works too, but pressing one takes the keyboard from
388 // the list, which then cannot hear what is typed.)
389 const lit = await read($, hotIcon)
390 const icon = (at: number, side: number, drawing: (isLit: boolean) => string, sign: string, alt: string, part: string, across = 3) => (
391 <Box position="absolute" top={0} {...(side < 0 ? { left: -side - 1 } : { right: side })} width={across} height={1} flexDirection="row" justifyContent="center" alignItems="center">
392 {/* the terminal's table answers for `Svg` and draws nothing, so the surface is asked, not the table */}
393 {e.surface !== 'terminal' && 'Svg' in table ? <table.Svg source={drawing(lit === `${at}:${part}`)} alt={alt} /> : <Text dimColor={lit !== `${at}:${part}`}>{sign}</Text>}
394 </Box>
395 )
396 const icons = rows.map((row, at) => {
397 // a terminal draws over what is under: there the list writes each sign in its own slot
398 if (isGrid) return []
399 // a heading's plus starts a task under it
400 if (row.kind === 'top' || (row.kind === 'list' && !row.isTyping)) return [icon(at, 0, PLUS, '+', 'Add a task', 'plus')]
401 // the row a name is typed in: the bin (a name that exists), and the cross that closes it unkept
402 if (row.isTyping || row.kind === 'new' || row.kind === 'newlist')
403 return [...(row.isTyping ? [icon(at, 3, BIN, '-', 'Delete', 'bin')] : []), icon(at, 0, CROSS, '×', 'Close', 'close')]
404 // the foot row's starts, each behind a plus
405 if (row.kind === 'foot') return [icon(at, -2, PLUS, '+', row.label === 'task' ? 'New task' : 'New list', row.label)]
406 if (row.kind !== 'task') return []
407 // an opened-out task has its pencil where the bin stands once the name is open; a task with more to show has its arrow
408 const pencil = row.isOpen ? [icon(at, 3, PENCIL, '✎', 'Edit', 'pencil')] : []
409 const arrow = row.note !== '' ? [icon(at, 0, ARROW(row.isOpen ? 90 : 0), row.isOpen ? 'v' : '>', row.isOpen ? 'Close' : 'Open', 'arrow')] : []
410
411 return [...pencil, ...arrow]
412 })
413
414 // The list's rows stand at different heights, and the pictures are laid under it row for row:
415 // a box for each row, as tall as the list draws that row, with the row's pictures on its first line.
416 const sizes = tall(rows, isGrid)
417 const typingAt = rows.findIndex(row => row.isTyping || row.kind === 'new' || row.kind === 'newlist')
418 const total = sizes.reduce((sum, size) => sum + size.height, 0)
419 // the list is drawn down to the bottom of the pane, so that a click on the empty part of the pane is a click in the list
420 const down = Math.max(total, Math.max(e.viewport?.rows ?? 0, e.props.scroll?.bodyRows ?? 0) - 3)
421
422 return (
423 // (a terminal pane has its own top row, with its close mark, so the list starts right under it there)
424 <Box flexDirection="column" paddingTop={isGrid ? 0 : 1} paddingBottom={2}>
425 {isEmpty && <Text dimColor>Nothing to do yet.</Text>}
426 <Box flexDirection="column" height={down}>
427 {rows.map((_, at) => (
428 <Box flexDirection="column" height={sizes[at].height} paddingTop={sizes[at].top} flexShrink={0}>
429 <Box height={1}>{icons[at]}</Box>
430 </Box>
431 ))}
432 <Box position="absolute" top={0} left={0} right={0} height={down}>
433 {'Client' in table && (
434 <table.Client
435 key="list-0"
436 module="./list.tsx"
437 width="100%"
438 height={down}
439 props={{ rows, paste: await read($, clip), wide: await read($, wide), hasKeys: e.props.isFocused === true, isGrid }}
440 />
441 )}
442 </Box>
443 {/* On a terminal a name is typed in the terminal's own field, laid over its row where the
444 name stands (a task's past its circle, a sublist's past its grip) and short of the row's
445 two right-hand slots. */}
446 {isGrid && typingAt >= 0 && 'Input' in table && (
447 <Box position="absolute" top={sizes.slice(0, typingAt).reduce((sum, size) => sum + size.height, 0)} left={rows[typingAt].kind === 'list' || rows[typingAt].kind === 'newlist' ? 2 : 5} right={6} height={1}>
448 <table.Input
449 key={`tl-name-${await read($, field)}`}
450 autoFocus
451 value={rows[typingAt].isTyping ? (rows[typingAt].name ?? '') : ''}
452 placeholder={rows[typingAt].kind === 'newlist' ? 'New list' : 'New task'}
453 onSubmit={() => {}}
454 />
455 </Box>
456 )}
457 </Box>
458 </Box>
459 )
460 })
461}
462hooks/list.tsx 590 lines1import type { ClientModule } from 'claude-code'
2
3import { lay, rooms } from './tasks'
4
5
6export type Row = {
7 // 'more' is a further line of the task above it, shown while that task is opened out; 'top' is the
8 // fixed heading over the tasks in no sublist; 'new' and 'newlist' are the row a new name is typed
9 // in; 'foot' is one of the pane's last two rows: `label` 'list' starts a new sublist, and its `note`
10 // is the words at its right end that hide the done tasks or show them again (empty when nothing is
11 // done); `label` 'task' starts a new task in General, the tasks in no sublist
12 kind: 'task' | 'list' | 'gap' | 'more' | 'top' | 'new' | 'newlist' | 'foot'
13 // the line of TASKS.md the row shows; for 'new', the heading line of the list it adds to
14 line: number
15 // the name as drawn in the row: all of it at rest, its first line when opened out; `name` is always all of it
16 label: string
17 name?: string
18 isDone: boolean
19 // drawn at the row's right edge: a sublist's count, or the mark that opens a task out
20 note: string
21 // a task whose every line is showing: the rest of a long name, and its notes
22 isOpen?: boolean
23 // a 'more' row that is one of the task's notes, not part of its name
24 isNote?: boolean
25 // the task or sublist whose name is being typed over, in this row
26 isTyping?: boolean
27 // a 'more' row kept under an open field for the list to write the name in, whole, as it is typed
28 isLive?: boolean
29}
30// `paste` is the clipboard as the hooks module last read it for this list; `n` tells one read from the next
31// `wide` is the pane's width in columns as the hooks module last heard it from this list
32// `hasKeys` is whether the pane holds the keyboard
33// `isGrid` is a terminal: whole rows and columns only, a letter to a column, and no pictures
34type Props = { rows: Row[]; paste: { n: number; text: string }; wide: number; hasKeys: boolean; isGrid: boolean }
35type Held = { from: number; over: number } | null
36
37// How tall each row stands, in rows of the grid, and how far down it starts. Everything is in
38// thirds of a row. An item has a third of a row of air under it, which is what sets one item apart
39// from the next; the rows that run on from an item (the rest of its name, its notes)
40// follow with none, so that an item's own lines sit closer than separate items do, and a
41// heading sits as close to its first task. Between groups there is a further two thirds: a group
42// is three times as far from the next as an item is from the next. A row never moves when it is
43// opened out, since nothing here depends on whether it is.
44//
45// The pointer reports whole rows of the grid, so a click is matched to the row that the middle of
46// its grid row falls in. Thirds are what make that safe: a grid row's middle then never lands on
47// the join between two rows, as it would with halves, where a click on the lower half of one line
48// of a name would be read as the line under it.
49// A terminal draws whole rows only: there every row is one row tall, and so is the gap between groups.
50const THIRD = 1 / 3
51export const tall = (rows: Row[], isGrid = false) => {
52 const runsOn = (row?: Row) => row?.kind === 'more'
53 let thirds = 0
54
55 return rows.map((row, index) => {
56 const high = isGrid ? 3 : row.kind === 'gap' ? 2 : 3 + (runsOn(rows[index + 1]) ? 0 : 1)
57 const size = { off: thirds * THIRD, top: 0, height: high * THIRD, from: thirds, to: thirds + high }
58 thirds += high
59
60 return size
61 })
62}
63
64// The press in hand. It lives outside the drawing so that the pointer's next event reads it at once:
65// kept only in the drawn state, a move that arrives before the redraw sees no press and is dropped.
66// (`at` is where in a name being typed a press landed, when it landed in one: dragging from there selects)
67let grab: { from: number; over: number; isGrip: boolean; at?: number } | null = null
68// when and where the last press in a name being typed landed, to tell a double click
69let lastPress = { time: 0, at: -1 }
70// The name being typed, by the key of the row it is typed in. Typing is done here, key by key: the
71// list hears every key and draws the name itself, in the row, wrapped over as many rows as it
72// needs. (The surface's own field is one line of a fixed width and cannot be hidden and still
73// typed in.) `mark` is the other end of a selection, when there is one; `past` is what undo returns to.
74type Typing = { key: string; text: string; caret: number; mark: number | null; past: { text: string; caret: number }[] }
75let typed: Typing | null = null
76let pasted = 0
77// whether the pane has been seen holding the keyboard since the name being typed was opened
78let heldKeys = false
79// whether the pane held the keyboard when the list was last drawn
80let hadKeys = false
81// The part of a row under the pointer, as "row:part". The list lights it itself, from the same
82// reading of the pointer that decides what a click there does, so what lights is what will act.
83// (The surface's own hover styles do not draw inside a Client on the desktop.)
84let hot = ''
85// The cursor blinks as any cursor does: shown for half a second, gone for half, and shown without
86// a break while the person is typing or has just clicked.
87let isBlinkOn = true
88let lastTouch = 0
89let stopBlink: (() => void) | null = null
90type Part = 'grip' | 'tick' | 'name' | 'pencil' | 'arrow' | 'plus' | 'bin' | 'close' | 'task' | 'list' | 'hide' | ''
91// The columns each part of a row is given. Each part is drawn in a box of exactly that width, and
92// the pointer reports columns, so the part a click lands in is the part that is drawn there.
93const GRIP = 2
94const CIRCLE = 2
95const EDGE = 3
96// the air between a row's circle and its name: a column
97const AIR = 1
98// how far a sublist's name stands in from its grip: a quarter column, which sets it over the circles of its tasks
99const HEAD = 0.25
100// the letters a column holds, near enough to turn a click's column into a place in a name
101const LETTERS = 1.3
102// the columns each of the foot row's starts takes: a slot for its plus, then its words
103export const FOOT = 13
104
105// The pane's greys. A sublist's name is the text colour, and so is whatever is under the pointer.
106// A task's name, counts, notes and the foot's words are the theme's own dim; a done task's name
107// and the circle before any name are a mid grey; the grip, which is only wanted when reached
108// for, is fainter still. A mid grey reads on a light theme and a dark one alike.
109const MID = 'rgba(127, 127, 127, 0.95)'
110const FAINT = 'rgba(127, 127, 127, 0.5)'
111const WASH = 'rgba(127, 127, 127, 0.16)'
112// the parts of a row that are icons: pictures the hooks module draws
113const ICONS = ['plus', 'pencil', 'arrow', 'bin', 'close', 'task', 'list']
114// what stands in a picture's slot on a terminal, which has no pictures: the list writes these itself
115const SIGNS: Record<string, string> = { plus: '+', pencil: 'edit', bin: '-', close: '×', task: '+', list: '+' }
116
117// The whole pane: one row per task or sublist on the surface's own grid, which is what lets the
118// pointer be matched to a row. A press and release on one row is a click (the circle ticks, a name
119// opens the task out, a + starts a new one); a press on a
120// grip carried to another row drags the task, or the sublist and its tasks, there, and while it is
121// held the rows are drawn in the order a release would leave them. Every change is posted to the
122// hooks module, which owns the file.
123const List: ClientModule<Props, Held> = (props, surface) => {
124 const { Box, Text } = surface.elements
125 const { rows } = props
126 const held = surface.state ?? null
127 // the row a pointer is over; -1 when it is over the empty part of the pane, under the last row
128 // the row the middle of a grid row falls in, going by how tall each row stands
129 const base = tall(rows, props.isGrid)
130 const rowAt = (y: number) => base.findIndex(size => y * 3 + 1.5 >= size.from && y * 3 + 1.5 < size.to)
131 const isNamed = (row: Row | undefined) => row?.kind === 'task' || row?.kind === 'list'
132 // the one row with a field in it, if any: a name being typed over, or a new name
133 const typing = rows.findIndex(row => row.isTyping || row.kind === 'new' || row.kind === 'newlist')
134 // (the key must not change while the name is typed: the rows under it come and go as it wraps)
135 const fieldKey = typing < 0 ? '' : isNamed(rows[typing]) ? `name-${rows[typing].line}` : `${rows[typing].kind}-${rows[typing].line}`
136 if (typed && typed.key !== fieldKey) typed = null
137 if (typing >= 0 && !typed) {
138 const start = isNamed(rows[typing]) ? (rows[typing].name ?? rows[typing].label) : ''
139 typed = { key: fieldKey, text: start, caret: start.length, mark: null, past: [] }
140 // a read of the clipboard from before this name was opened is not a paste into it
141 pasted = props.paste.n
142 heldKeys = false
143 }
144 // One edit of the name: `from`..`to` is replaced by `put`, and the edit can be undone.
145 const edit = (from: number, to: number, put: string) => {
146 if (!typed) return
147 const text = typed.text.slice(0, from) + put + typed.text.slice(to)
148 typed = { ...typed, text, caret: from + put.length, mark: null, past: [...typed.past, { text: typed.text, caret: typed.caret }].slice(-100) }
149 }
150 const span = (): [number, number] => (typed.mark === null ? [typed.caret, typed.caret] : [Math.min(typed.mark, typed.caret), Math.max(typed.mark, typed.caret)])
151 // What the hooks module read off the clipboard goes in at the cursor, once, as one line. This
152 // comes before the name is laid out, so the rows drawn are the rows of the name as pasted into.
153 if (typed && props.paste.n !== pasted) edit(...span(), props.paste.text.replace(/\s+/g, ' '))
154 pasted = props.paste.n
155 // the name as lines of the pane's width, each a stretch of the text
156 // (on a terminal the name is typed in the terminal's own field, laid over the row: the list keeps one empty line for it)
157 const lines: [number, number][] = !typed ? [] : props.isGrid ? [[0, 0]] : lay(typed.text, ...rooms(surface.columns, props.isGrid))
158 // the hooks module lays out the rows, so it is told how wide the pane is and how many rows the name needs now
159 if (surface.columns > 0 && props.wide !== surface.columns) surface.post({ act: 'wide', n: surface.columns })
160 const kept = rows.filter(row => row.isLive).length
161 if (typed && surface.columns > 0 && lines.length - 1 !== kept) surface.post({ act: 'rows', n: lines.length - 1 })
162
163 // The rows in blocks: the tasks above the first sublist, then each sublist with the blank row
164 // over it, its name and its tasks. A sublist is carried, and lands, as one block.
165 const blocks: number[][] = [[]]
166 rows.forEach((row, index) => {
167 if (row.kind === 'foot') return
168 if (row.kind === 'gap' || (row.kind === 'list' && rows[index - 1]?.kind !== 'gap')) blocks.push([])
169 blocks.at(-1).push(index)
170 })
171 const blockOf = (index: number) => blocks.findIndex(block => block.includes(index))
172 const lastList = blocks.findLastIndex(block => block.some(index => rows[index].kind === 'list'))
173 const landing = (over: number) => Math.min(Math.max(1, blockOf(over) < 0 ? lastList : blockOf(over)), lastList)
174
175 // which part of a row a column falls in: the one reading that both hover and click go by
176 // Every icon is a picture the hooks module lays under the list, in a slot the list leaves clear;
177 // the click and the light under the pointer are the list's, the same for each of them.
178 const partOf = (row: Row | undefined, x: number): Part => {
179 // which slot from the right edge a column is in: 0 the last three columns, 1 the three before
180 const fromRight = surface.columns > 0 ? Math.floor((surface.columns - 1 - x) / EDGE) : 9
181 if (!row || row.kind === 'gap') return ''
182 // the row a name is typed in: the cross closes it, and a name that exists has its bin beside that
183 if (row.isTyping || row.kind === 'new' || row.kind === 'newlist') return fromRight === 0 ? 'close' : fromRight === 1 && row.isTyping ? 'bin' : ''
184 // the foot: what it starts stands at its left, and at its right, under the counts, the words that hide what is done
185 if (row.kind === 'foot') {
186 if (row.note && x >= surface.columns - 1 - row.note.length) return 'hide'
187
188 return x < FOOT ? (row.label === 'task' ? 'task' : 'list') : ''
189 }
190 if (row.kind === 'top') return fromRight === 0 ? 'plus' : ''
191 // a further line of a task's name is the task's name; a note is only to be read
192 if (row.kind === 'more') return row.isNote ? '' : 'name'
193 if (x < GRIP) return 'grip'
194 if (row.kind === 'list') return fromRight === 0 ? 'plus' : 'name'
195 // a task with more to show has its arrow, and once opened out, its pencil beside that
196 if (fromRight === 0 && (row.isOpen || row.note !== '')) return row.note !== '' ? 'arrow' : ''
197 // (a terminal's pencil is a word four columns wide)
198 if (row.isOpen && (props.isGrid ? surface.columns - 1 - x >= EDGE && surface.columns - 1 - x < EDGE + 4 : fromRight === 1)) return 'pencil'
199
200 return x < GRIP + CIRCLE ? 'tick' : 'name'
201 }
202 // Where in the name being typed a column of a row falls, as near as a column can say (letters are
203 // narrower than columns, and of different widths); null when the row is not one of the name's.
204 const placeAt = (row: number, x: number) => {
205 const span = lines[row - typing]
206 // (only the row the name is typed in and the rows kept for its further lines are the name's)
207 if (typing < 0 || !span || (row !== typing && !rows[row]?.isLive)) return null
208 // a sublist's name starts at its grip's edge, on its own row; a task's stands in past its circle
209 const isHead = row === typing && (rows[typing].kind === 'list' || rows[typing].kind === 'newlist')
210 const left = isHead ? GRIP : GRIP + CIRCLE + AIR
211
212 return Math.max(span[0], Math.min(span[1], span[0] + Math.round(Math.max(0, x - left) * (props.isGrid ? 1 : LETTERS))))
213 }
214 // A grey that is a step back from the text: on a terminal, which has no see-through colours, the theme's own dim.
215 // On a terminal, which has no see-through colours, it is the theme's own grey for what is not the text
216 // ('inactive', as the terminal's own quiet text is), so it is right on a light theme and a dark one.
217 const grey = (tone: string) => (props.isGrid ? { color: 'inactive' } : { color: tone })
218 const redraw = () => surface.setState(surface.state ? { ...surface.state } : null)
219 if (surface.state === undefined) {
220 stopBlink?.()
221 stopBlink = surface.every(530, () => {
222 if (!typed) return
223 isBlinkOn = Date.now() - lastTouch < 530 || !isBlinkOn
224 redraw()
225 })
226 }
227 const lit = (index: number, part: Part) => !grab && hot === `${index}:${part}`
228 // a task's name may run over several rows: the pointer on any one of them lights them all
229 const [hotRow, hotPart] = hot.split(':')
230 const isNameLit = (row: Row) => !grab && hotPart === 'name' && rows[Number(hotRow)]?.line === row.line && rows[Number(hotRow)]?.kind !== 'list'
231
232 // Enter, or a click anywhere else in the list: keep what was typed.
233 const keep = () => {
234 const row = rows[typing]
235 const name = (typed?.text ?? '').trim()
236 typed = null
237 if (isNamed(row)) surface.post(name && name !== (row.name ?? row.label) ? { act: 'rename', line: row.line, name, was: row.name ?? row.label } : { act: 'close' })
238 else if (!name) surface.post({ act: 'close' })
239 else surface.post(row.kind === 'newlist' ? { act: 'createList', name } : { act: 'create', line: row.line, name })
240 }
241 // The pane giving up the keyboard (a click into the chat, say) is a click away too. Only a change
242 // from holding it to not holding it counts, so a surface that never reports holding it closes nothing.
243 if (typed && props.hasKeys) heldKeys = true
244 // (not on a terminal: there the name is typed in the terminal's own field, and any click in the
245 // list takes the keyboard from that field first, so the click itself says what it is for)
246 if (typed && heldKeys && !props.hasKeys && !props.isGrid) keep()
247 // from here on `typed` may be null with a typing row still in `rows`: nothing below may assume it
248 // A task that is opened out shuts when the person clicks away from it: on an empty part of the
249 // list here, or out of the pane, which is the pane giving up the keyboard.
250 const isOut = rows.some(row => row.isOpen)
251 if (isOut && typing < 0 && hadKeys && !props.hasKeys) surface.post({ act: 'shut' })
252 hadKeys = props.hasKeys
253 const lineOf = (at: number) => Math.max(0, lines.findIndex(([start, end], index) => at >= start && (at < end || index === lines.length - 1)))
254
255 surface.onKey(e => {
256 if (!typed) return
257 lastTouch = Date.now()
258 const { text, caret, mark } = typed
259 const [from, to] = span()
260 const hasSpan = from !== to
261 // moves the cursor; with shift held the other end of the selection stays where it was
262 const go = (at: number) => {
263 typed = { ...typed, caret: Math.max(0, Math.min(text.length, at)), mark: e.shift ? (mark ?? caret) : null }
264 }
265 const line = lines[lineOf(caret)]
266 const letter = e.key === 'space' ? ' ' : e.key
267 if (e.key === 'return') return keep()
268 if (e.meta && e.key === 'v') return surface.post({ act: 'paste' })
269 if (e.meta && e.key === 'a') typed = { ...typed, mark: 0, caret: text.length }
270 else if (e.meta && (e.key === 'c' || e.key === 'x')) {
271 if (hasSpan) surface.post({ act: 'copy', name: text.slice(from, to) })
272 if (hasSpan && e.key === 'x') edit(from, to, '')
273 } else if (e.meta && e.key === 'z') {
274 const last = typed.past.at(-1)
275 if (last) typed = { ...typed, ...last, mark: null, past: typed.past.slice(0, -1) }
276 } else if (e.key === 'backspace') edit(hasSpan ? from : e.meta ? line[0] : Math.max(0, caret - 1), hasSpan ? to : caret, '')
277 else if (e.key === 'delete') edit(from, hasSpan ? to : Math.min(text.length, caret + 1), '')
278 else if (e.key === 'left') go(e.meta ? line[0] : hasSpan && !e.shift ? from : caret - 1)
279 else if (e.key === 'right') go(e.meta ? line[1] : hasSpan && !e.shift ? to : caret + 1)
280 else if (e.key === 'home') go(line[0])
281 else if (e.key === 'end') go(line[1])
282 else if (e.key === 'up' || e.key === 'down') {
283 // to the same place in the line above or below; off the first or last line, to the name's end
284 const next = lines[lineOf(caret) + (e.key === 'up' ? -1 : 1)]
285 go(next ? Math.min(next[0] + (caret - line[0]), next[1]) : e.key === 'up' ? 0 : text.length)
286 } else if (!e.meta && !e.ctrl && [...letter].length === 1) edit(from, to, letter)
287 else return
288 redraw()
289 })
290
291 surface.onPointer(e => {
292 const at = rowAt(e.fine?.y ?? e.y)
293 if (e.type === 'leave' || (e.type === 'move' && !grab)) {
294 // while a name is typed, only the icons of its own row light: a click anywhere else just keeps it
295 const now = e.type === 'leave' || (typing >= 0 && at !== typing) ? '' : `${at}:${partOf(rows[at], e.x)}`
296 if (now !== hot) {
297 // An icon is the hooks module's picture, so it is told when the pointer comes onto one or
298 // leaves it, and draws that picture in the text colour; the rest the list lights itself.
299 const icon = (key: string) => (ICONS.some(part => key.endsWith(`:${part}`)) ? key : '')
300 if (icon(now) !== icon(hot)) surface.post({ act: 'hot', name: icon(now) })
301 hot = now
302 redraw()
303 }
304 } else if (e.type === 'down' && e.button === 'left' && typed && placeAt(at, e.x) !== null && !(at === typing && partOf(rows[at], e.x) !== '')) {
305 // A press in the name being typed puts the cursor there, and a drag from it selects. A second
306 // press on the same spot straight after selects the word under it.
307 const place = placeAt(at, e.x)
308 lastTouch = Date.now()
309 const isDouble = Date.now() - lastPress.time < 400 && Math.abs(lastPress.at - place) <= 1
310 lastPress = { time: Date.now(), at: place }
311 const word = [typed.text.lastIndexOf(' ', place - 1) + 1, typed.text.indexOf(' ', place)]
312 typed = isDouble ? { ...typed, mark: word[0], caret: word[1] < 0 ? typed.text.length : word[1] } : { ...typed, caret: place, mark: null }
313 grab = { from: at, over: at, isGrip: false, at: isDouble ? undefined : place }
314 redraw()
315 } else if (e.type === 'move' && grab && grab.at !== undefined && typed) {
316 // the pointer carried on from a press in the name: everything between is selected
317 const to = placeAt(at < 0 ? typing + lines.length - 1 : Math.max(typing, Math.min(typing + lines.length - 1, at)), e.x)
318 if (to !== null && to !== typed.caret) {
319 typed = { ...typed, caret: to, mark: to === grab.at ? null : grab.at }
320 redraw()
321 }
322 } else if (e.type === 'down' && e.button === 'left') {
323 // with a name open, a press anywhere counts, the blank rows too: it is a click away from the name
324 if ((typing >= 0 || isOut) && !rows[at]) grab = { from: -1, over: -1, isGrip: false }
325 else if (rows[at] && (typing >= 0 || isOut || rows[at].kind !== 'gap')) grab = { from: at, over: at, isGrip: typing < 0 && isNamed(rows[at]) && partOf(rows[at], e.x) === 'grip' }
326 } else if (e.type === 'move' && grab) {
327 if (grab.isGrip && at >= 0 && at !== grab.over) {
328 grab.over = at
329 surface.setState({ from: grab.from, over: at })
330 }
331 } else if (e.type === 'up' && grab) {
332 const { from, over, isGrip } = grab
333 const row = rows[from]
334 grab = null
335 // a click under the last row, with a name open, is a click away from it
336 if (!row && typing >= 0) return keep()
337 // a click on an empty part of the list is a click away from a task that is opened out
338 if ((!row || row.kind === 'gap') && typing < 0) return isOut ? surface.post({ act: 'shut' }) : undefined
339 if (!row) return
340 if (typing >= 0) {
341 // The cross at the typing row's right edge closes it unkept. A press in the name was dealt
342 // with when it landed. A click anywhere else keeps what was typed.
343 const lineAt = from - typing
344 // A click on any row that is not the name's own is a click away. (Counting rows from the
345 // field would take the rows under it for the name's while its further rows are on their way.)
346 if (from !== typing && !row.isLive) keep()
347 else if (lineAt === 0 && partOf(row, e.x) === 'close') {
348 typed = null
349 surface.post({ act: 'close', isCancel: true })
350 } else if (lineAt === 0 && partOf(row, e.x) === 'bin') {
351 typed = null
352 surface.post({ act: 'remove', line: row.line, was: row.name ?? row.label })
353 }
354
355 return
356 }
357 if (isGrip && row.kind === 'list') {
358 // a sublist is dropped among the sublists: it takes the place of the one it was let go over
359 if (landing(over) !== blockOf(from)) surface.post({ act: 'place', line: row.line, to: landing(over) - 1, was: row.name })
360 } else if (isGrip && over !== from) {
361 // held over row r the task takes r's place: after it going down, before it going up
362 const slot = over > from ? over + 1 : over
363 const next = rows.slice(slot).find(other => isNamed(other) && other !== row)
364 surface.post({ act: 'drop', line: row.line, before: next ? next.line : null, was: row.name })
365 } else if (!isGrip) {
366 // a press on the grip that went nowhere is no click on anything
367 // a task's name opens the task out, and shuts it again; a sublist's name is typed over where it stands
368 const act = { tick: 'tick', plus: 'add', pencil: 'edit', arrow: 'more', name: row.kind === 'list' ? 'edit' : 'more', task: 'add', list: 'addList', hide: 'hide' }[partOf(row, e.x)]
369 // (`was` is the name as drawn: the hooks module changes nothing if the file no longer has it on that line)
370 if (act) surface.post({ act, line: row.line, was: row.name ?? row.label })
371 }
372 surface.setState(null)
373 }
374 })
375
376 const isDragging = held !== null && held.over !== held.from && rows[held.from] !== undefined
377 let order = rows.map((_, index) => index)
378 if (isDragging && rows[held.from].kind === 'list') {
379 const moved = [...blocks]
380 moved.splice(landing(held.over), 0, ...moved.splice(blockOf(held.from), 1))
381 order = [...moved.flat(), ...order.filter(index => rows[index].kind === 'foot')]
382 } else if (isDragging) order.splice(held.over, 0, ...order.splice(held.from, 1))
383
384 // a part's box: exactly as wide as the columns the pointer is read against
385 const part = (width: number, child: unknown) => (
386 <Box width={width} flexShrink={0}>
387 {child}
388 </Box>
389 )
390 // a slot the hooks module lays a picture under: clear, so the picture shows
391 // (a terminal has no pictures, so there the list writes the sign itself)
392 // (a terminal has no drawing that reads as a pencil, so there it is the word, in a slot a column wider)
393 const slot = (index: number, what: Part) =>
394 props.isGrid && what === 'pencil'
395 ? part(4, <Text dimColor={!lit(index, what)}>{SIGNS.pencil}</Text>)
396 : part(EDGE, <Text dimColor={!lit(index, what)}>{props.isGrid ? ` ${what === 'arrow' ? (rows[index].isOpen ? 'v' : '>') : SIGNS[what]}` : ' '}</Text>)
397 const grip = (index: number, isLive: boolean) =>
398 part(
399 GRIP,
400 isLive ? (
401 <Text {...(held?.from === index || lit(index, 'grip') ? {} : grey(FAINT))}>⠿</Text>
402 ) : (
403 <Text dimColor> </Text>
404 ),
405 )
406 // a name takes the room that is left and is cut at its own edge, whatever letters are in it
407 const name = (child: unknown) => (
408 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" paddingLeft={AIR}>
409 {child}
410 </Box>
411 )
412 // One line of the name being typed: its letters, the selection reversed out, and the cursor.
413 // (a sublist's name stands a quarter column in, a task's a whole one: typed, each stays where it stood)
414 const typedLine = (lineAt: number, isBold: boolean) => {
415 if (props.isGrid) return name(<Text> </Text>)
416 const [start, end] = lines[lineAt] ?? [0, 0]
417 const [from, to] = span()
418 const cut = (a: number, b: number) => typed.text.slice(Math.max(start, Math.min(end, a)), Math.max(start, Math.min(end, b)))
419 const isHere = typed.mark === null && lineOf(typed.caret) === lineAt
420 const hint = rows[typing].kind === 'newlist' ? 'New list' : 'New task'
421
422 return (
423 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" flexDirection="row" paddingLeft={isBold ? HEAD : AIR}>
424 <Text bold={isBold}>{cut(start, isHere ? typed.caret : from)}</Text>
425 {/* The cursor is a bar in the text colour, centred in a box a quarter column wide: a hair
426 of room either side of it, so it does not touch the letters it stands between. The box
427 stays when the bar blinks off, so the letters do not move as it blinks. */}
428 {isHere && !props.isGrid && (
429 <Box width={0.25} flexShrink={0} flexDirection="row" justifyContent="center">
430 <Text>{isBlinkOn || Date.now() - lastTouch < 530 ? '|' : ' '}</Text>
431 </Box>
432 )}
433 {/* a terminal has no room between two letters: its cursor is the letter it stands before, reversed out */}
434 {isHere && props.isGrid && <Text inverse>{cut(typed.caret, typed.caret + 1) || ' '}</Text>}
435 {!isHere && from !== to && <Text inverse>{cut(from, to)}</Text>}
436 <Text bold={isBold}>{cut(isHere ? typed.caret + (props.isGrid ? 1 : 0) : to, end)}</Text>
437 {!typed.text && isNamed(rows[typing]) === false && <Text dimColor>{hint}</Text>}
438 </Box>
439 )
440 }
441 // The row a name is typed in is the row it stands for, with the name live where it stood. The
442 // three columns before the cross are left empty: the hooks module draws delete there.
443 const field = (index: number) => {
444 const row = rows[index]
445 const isList = row.kind === 'list' || row.kind === 'newlist'
446
447 return (
448 <Box flexDirection="row" height={1}>
449 {grip(index, false)}
450 {!isList && part(CIRCLE, <Text {...grey(MID)}>{row.isDone ? '✓' : '○'}</Text>)}
451 {typedLine(0, isList)}
452 {isNamed(row) ? slot(index, 'bin') : part(EDGE, <Text> </Text>)}
453 {slot(index, 'close')}
454 </Box>
455 )
456 }
457
458 // each row in the height it stands at, its text at the top of it under any air it has above
459 const sizes = tall(order.map(index => rows[index]), props.isGrid)
460 // A terminal keeps whatever was last written in a cell until something is written over it, so
461 // there each row first writes spaces across the whole of itself: a row that moved leaves nothing behind.
462 const across = ' '.repeat(Math.max(0, surface.columns))
463 // the row a terminal shades: the one under the pointer, a task or a sublist, when nothing is held or typed
464 const isUnder = (index: number) => !grab && typing < 0 && hotRow === String(index) && isNamed(rows[index])
465 const frame = (nth: number, drawn: unknown) =>
466 props.isGrid ? (
467 <Box height={1}>
468 <Box position="absolute" top={0} left={0}>
469 {/* the row under the pointer gets the theme's own shade, as the terminal shades a line of the transcript */}
470 <Text {...(isUnder(order[nth]) ? { backgroundColor: 'userMessageBackground' } : {})}>{across}</Text>
471 </Box>
472 {rows[order[nth]].kind === 'gap' ? null : drawn}
473 </Box>
474 ) : rows[order[nth]].kind === 'gap' ? (
475 <Box height={sizes[nth].height} />
476 ) : (
477 <Box flexDirection="column" height={sizes[nth].height} paddingTop={sizes[nth].top}>
478 {drawn}
479 </Box>
480 )
481
482 return (
483 <Box flexDirection="column">
484 {order.map((index, nth) => frame(nth, (() => {
485 const row = rows[index]
486 // (a name just kept is no longer being typed, though its row is still here until the hooks module answers)
487 if (index === typing && typed) return field(index)
488 if (row.kind === 'gap' || row.kind === 'new' || row.kind === 'newlist') return <Text> </Text>
489 if (row.kind === 'foot')
490 return (
491 <Box flexDirection="row" height={1}>
492 {/* each start is a plus (a picture, under its slot) and its words; both go to the text colour together */}
493 {[[row.label, row.label === 'task' ? 'New task' : 'New list']].map(([what, words]) => (
494 <Box width={FOOT} flexShrink={0} flexDirection="row">
495 {/* the plus stands in the column of the circles, and its words where the names of tasks start */}
496 {part(GRIP - 1, <Text> </Text>)}
497 {slot(index, what as Part)}
498 {part(AIR, <Text> </Text>)}
499 <Text dimColor={!lit(index, what as Part)}>{words}</Text>
500 </Box>
501 ))}
502 {/* looking, not making: it stands apart at the row's other end, as quiet as the counts,
503 and ends where the pictures above it end (a picture sits half a column in from the pane's edge) */}
504 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" flexDirection="row" justifyContent="flex-end" paddingRight={0.5}>
505 <Text {...(lit(index, 'hide') ? {} : grey(MID))}>{row.note}</Text>
506 </Box>
507 </Box>
508 )
509 if (row.kind === 'list' || row.kind === 'top')
510 return (
511 <Box flexDirection="row" height={1}>
512 {grip(index, row.kind === 'list')}
513 {/* a heading's name starts over the circles of the tasks under it, which stand in from it */}
514 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" paddingLeft={HEAD}>
515 <Text bold wrap="truncate-end">
516 {row.label}
517 </Text>
518 </Box>
519 <Box flexShrink={0} paddingLeft={1}>
520 {/* a count is the least of what a heading says: a step greyer than a task's name, done or not */}
521 <Text {...grey(MID)}>{row.note}</Text>
522 </Box>
523 {slot(index, 'plus')}
524 </Box>
525 )
526 const isHeld = isDragging && held.from === index
527 const label = (
528 // A task's name rests a step back from the text colour and comes up to it under the pointer,
529 // as the icons do. A done one rests further back again, struck through.
530 <Text
531 bold={isHeld}
532 // (on a terminal a task's name is plain text: the grey step there is for counts, notes and what is done)
533 dimColor={!props.isGrid && !row.isDone && !isHeld && !isNameLit(row)}
534 {...(row.isDone && !isHeld && !isNameLit(row) ? grey(MID) : {})}
535 strikethrough={row.isDone}
536 wrap="truncate-end"
537 >
538 {row.label}
539 </Text>
540 )
541 if (row.isLive)
542 return (
543 <Box flexDirection="row" height={1}>
544 {part(GRIP + CIRCLE, <Text> </Text>)}
545 {typed && index - typing < lines.length ? typedLine(index - typing, false) : name(<Text> </Text>)}
546 </Box>
547 )
548 // A note stands in from its task's name, and is a step greyer.
549 if (row.isNote)
550 return (
551 <Box flexDirection="row" height={1}>
552 {part(GRIP + CIRCLE + AIR, <Text> </Text>)}
553 {part(CIRCLE, <Text> </Text>)}
554 <Box flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
555 <Text {...grey(MID)} wrap="truncate-end">
556 {row.label}
557 </Text>
558 </Box>
559 </Box>
560 )
561 if (row.kind === 'more')
562 return (
563 <Box flexDirection="row" height={1}>
564 {part(GRIP + CIRCLE, <Text> </Text>)}
565 {name(label)}
566 </Box>
567 )
568
569 return (
570 <Box flexDirection="row" height={1} {...(isHeld ? { backgroundColor: WASH } : {})}>
571 {grip(index, true)}
572 {part(
573 CIRCLE,
574 <Text {...(lit(index, 'tick') ? {} : grey(MID))}>{row.isDone ? '✓' : '○'}</Text>,
575 )}
576 {name(label)}
577 {/* the slots the hooks module draws in: the pencil of an opened-out task, then its arrow */}
578 {row.isOpen && slot(index, 'pencil')}
579 {(row.isOpen || row.note !== '') && (row.note !== '' ? slot(index, 'arrow') : part(EDGE, <Text> </Text>))}
580 </Box>
581 )
582 })()))}
583 {/* and the rows under the last one, which the list may have just given back */}
584 {props.isGrid && Array.from({ length: Math.max(0, surface.rows - order.length) }, () => <Text>{across}</Text>)}
585 </Box>
586 )
587}
588
589export default List
590hooks/tasks.ts 211 lines1// Pure edits on the lines of TASKS.md, which is read the way people and agents write task lists:
2// a task is a checkbox item under any bullet (`- [ ]`, `* [x]`, `1. [ ]`), a sublist is a heading
3// of level two or deeper, and the lines indented under a task are its notes: that task's
4// own and travel with it. Everything else in the file is left where it is. The lines above the
5// first sublist are the top list; `head` is a heading's line, -1 for the top list.
6export const TASK = /^(?:[-*+]|\d+[.)]) \[( |x)\] (.*)$/i
7export const LIST = /^#{2,6} (.+)$/
8
9// A code block (lines fenced by ``` or ~~~ at the start of a line) is text to be read as written:
10// nothing in it is a task or a sublist. `shield` marks its lines as the file is read, so that no
11// rule here takes one for either, and `bare` takes the marks off again before the file is written.
12// (A fence that is never closed runs to the end of the file, as it does wherever markdown is shown.)
13const MARK = '\uE000'
14export const shield = (text: string) => {
15 let isInside = false
16
17 return text
18 .split('\n')
19 .map(line => {
20 const isFence = /^(```|~~~)/.test(line)
21 const out = isInside || isFence ? MARK + line : line
22 if (isFence) isInside = !isInside
23
24 return out
25 })
26 .join('\n')
27}
28export const bare = (text: string) => text.replaceAll(MARK, '')
29
30export const isOpen = (line = '') => TASK.exec(line)?.[1] === ' '
31export const isDone = (line = '') => TASK.test(line) && !isOpen(line)
32export const nameOf = (line = '') => TASK.exec(line)?.[2] ?? LIST.exec(line)?.[1]
33
34export const toggle = (lines: string[], index: number) =>
35 lines.map((line, i) => (i === index ? line.replace(/\[( |x)\]/i, (_, mark) => (mark === ' ' ? '[x]' : '[ ]')) : line))
36
37// the line after a task and the indented lines that belong to it
38// (a blank line inside a note does not end it: the indented lines after the blank are still the task's)
39const end = (lines: string[], index: number) => {
40 let at = index + 1
41 for (let next = at; next < lines.length; next++) {
42 if (/^\s+\S/.test(lines[next])) at = next + 1
43 else if (lines[next].trim()) break
44 }
45
46 return at
47}
48// the file without a task, and the task with what belongs to it
49const lift = (lines: string[], index: number): [string[], string[]] => [
50 [...lines.slice(0, index), ...lines.slice(end(lines, index))],
51 lines.slice(index, end(lines, index)),
52]
53
54// a task's notes: the lines indented under it, as written but for the indent
55export const notesOf = (lines: string[], index: number) => lines.slice(index + 1, end(lines, index)).map(line => line.trim()).filter(Boolean)
56
57// Sets a task's note: the lines indented under it are replaced by `note`'s, and an empty note
58// takes them away.
59export const setNote = (lines: string[], index: number, note: string) => {
60 const text = note.split('\n').map(line => line.trim()).filter(Boolean).map(line => ` ${line}`)
61
62 return [...lines.slice(0, index + 1), ...text, ...lines.slice(end(lines, index))]
63}
64
65// adds lines of note under a task, after the notes it has
66export const addNote = (lines: string[], index: number, note: string) => setNote(lines, index, [...notesOf(lines, index), note].join('\n'))
67
68export const remove = (lines: string[], index: number) => lift(lines, index)[0]
69
70// adds lines at the bottom of a list, above the blank lines that close it
71export const insert = (lines: string[], head: number, add: string | string[]): [string[], number] => {
72 let at = head + 1
73 while (at < lines.length && !LIST.test(lines[at])) at++
74 while (at > head + 1 && !lines[at - 1].trim()) at--
75 // the comment at the top of the file stands a blank line apart from the first task under it
76 const gap = lines[at - 1]?.startsWith('<!--') ? [''] : []
77
78 return [[...lines.slice(0, at), ...gap, ...[add].flat(), ...lines.slice(at)], at + gap.length]
79}
80
81export const addList = (lines: string[], name: string) => {
82 const kept = lines.join('\n').trimEnd()
83
84 return `${kept}${kept ? '\n\n' : ''}## ${name}\n`.split('\n')
85}
86
87export const headOf = (lines: string[], index: number) => {
88 let head = index
89 while (head >= 0 && !LIST.test(lines[head])) head--
90
91 return head
92}
93
94// the file as its top list and one block of lines per sublist, trailing blank lines trimmed
95const blocks = (lines: string[]): [string[], string[][]] => {
96 const heads = lines.flatMap((line, index) => (LIST.test(line) ? [index] : []))
97 const trim = (block: string[]) => block.slice(0, block.findLastIndex(line => line.trim()) + 1)
98
99 return [trim(lines.slice(0, heads[0] ?? lines.length)), heads.map((head, n) => trim(lines.slice(head, heads[n + 1])))]
100}
101const join = (top: string[], lists: string[][]) => [top, ...lists].filter(block => block.length).flatMap(block => [...block, ''])
102const nth = (lines: string[], head: number) => lines.slice(0, head).filter(line => LIST.test(line)).length
103
104// moves a sublist, tasks and all, to be the `to`th sublist (0 the first)
105export const placeList = (lines: string[], head: number, to: number) => {
106 const [top, lists] = blocks(lines)
107 lists.splice(to, 0, ...lists.splice(nth(lines, head), 1))
108
109 return join(top, lists)
110}
111
112// removes the sublist and keeps its tasks, at the bottom of the top list
113export const dropList = (lines: string[], head: number) => {
114 const [top, lists] = blocks(lines)
115 const [gone] = lists.splice(nth(lines, head), 1)
116
117 return join([...top, ...gone.slice(1).filter(line => line.trim())], lists)
118}
119
120// renames a sublist or a task, whichever the line is; the line keeps its bullet, tick or level
121export const rename = (lines: string[], at: number, name: string) =>
122 lines.map((line, index) => (index === at ? `${/^(#+ |.*?\[[ x]\] )/i.exec(line)?.[1] ?? ''}${name}` : line))
123
124// puts an open task at the end of a list's open tasks, above its done ones
125export const file = (lines: string[], head: number, task: string | string[]) => {
126 const next = lines.findIndex((other, i) => i > head && LIST.test(other))
127 const done = lines.findIndex((other, i) => i > head && (next < 0 || i < next) && isDone(other))
128
129 return done < 0 ? insert(lines, head, task)[0] : [...lines.slice(0, done), ...[task].flat(), ...lines.slice(done)]
130}
131
132// Ticks or unticks a task and files it: a done task drops to the bottom of its list,
133// a reopened one returns to the end of the open tasks, above the done ones.
134export const tick = (lines: string[], index: number) => {
135 const [rest, task] = lift(toggle(lines, index), index)
136 const head = headOf(lines, index)
137
138 return isDone(task[0]) ? insert(rest, head, task)[0] : file(rest, head, task)
139}
140
141// Moves a task to sit just before the line `before`, or to the bottom of the last list when null.
142// Dropped just before a heading it ends the list above that heading: an open task ends that list's
143// open tasks, above its done ones, which is also where it belongs when the done ones are hidden.
144export const drop = (lines: string[], from: number, before: number | null) => {
145 const [rest, task] = lift(lines, from)
146 const close = (head: number) => (isOpen(task[0]) ? file(rest, head, task) : insert(rest, head, task)[0])
147 if (before === null) return close(rest.findLastIndex(other => LIST.test(other)))
148 const at = before > from ? before - task.length : before
149 if (LIST.test(rest[at])) return close(headOf(rest, at - 1))
150
151 return [...rest.slice(0, at), ...task, ...rest.slice(at)]
152}
153
154// The tasks a name points at: the one whose whole name it is, else every one it is part of.
155// An agent names a task the way a person would, so one match is an answer and more is a question.
156export const match = (lines: string[], name: string) => {
157 const want = name.trim().toLowerCase()
158 const called = (index: number) => nameOf(lines[index]).toLowerCase()
159 const tasks = lines.flatMap((line, index) => (TASK.test(line) ? [index] : []))
160 const whole = tasks.filter(index => called(index) === want)
161
162 return whole.length || !want ? whole : tasks.filter(index => called(index).includes(want))
163}
164
165// the heading line of the sublist called `name`, whatever its case; -1 when there is none
166export const findList = (lines: string[], name: string) =>
167 lines.findIndex(line => LIST.exec(line)?.[1].trim().toLowerCase() === name.trim().toLowerCase())
168
169// breaks a name into lines of at most `room` letters, at spaces where it can
170export const wrap = (name: string, room: number) => {
171 const out: string[] = []
172 for (const word of name.split(' ')) {
173 const last = out.at(-1)
174 if (last !== undefined && `${last} ${word}`.length <= room) out[out.length - 1] = `${last} ${word}`
175 else for (let at = 0; at < Math.max(1, word.length); at += room) out.push(word.slice(at, at + room))
176 }
177
178 return out
179}
180
181// How many letters fit on the lines of a name in a pane `columns` wide: the first line, which stops
182// short of the row's marks, then every line under it. The list has no way to measure text, so
183// lines are broken by counting letters. Letters are narrower than the columns they are drawn in
184// (ordinary words were measured at 1.36 a column), and the count stays under that so wide ones fit.
185// On a terminal (`isGrid`) a letter is exactly a column.
186export const rooms = (columns: number, isGrid = false): [number, number] => {
187 const across = columns - 5
188 // (1.3 let a first line of ordinary words run under the row's icons and lose its last letters)
189 const fit = (cols: number) => Math.max(8, Math.floor(cols * (isGrid ? 1 : 1.15)))
190
191 return [fit(across - 6), fit(across)]
192}
193
194// A name as lines, each the stretch [start, end) of it that the line holds: broken at a space
195// where it can be, and the space a line breaks at belongs to the line before it. The list draws a
196// name with this whether it is being read or typed, so its lines break in the same places in both.
197export const lay = (text: string, first: number, rest: number) => {
198 const lines: [number, number][] = []
199 let start = 0
200 while (text.length - start > (lines.length ? rest : first)) {
201 const room = lines.length ? rest : first
202 const space = text.lastIndexOf(' ', start + room)
203 const end = space > start ? space + 1 : start + room
204 lines.push([start, end])
205 start = end
206 }
207 lines.push([start, text.length])
208
209 return lines
210}
211types/index.d.ts 8 lines1export type TaskFile = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'task-list': { text: TaskFile; editing: number | null; expanded: number | null; liveRows: number; wide: number; hotIcon: string; hideDone: boolean; draft: string; field: number; clip: { n: number; text: string }; adding: 'task' | 'list' | number | null }
6 }
7}
8