A pane over the session's task list that both the person and Claude edit

hooks/register.tsx 416 lines1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolInputs, EngineInterface, Register } from 'claude-code'
3
4import type { Draft, NewTask, Task, TaskEdit, TaskStatus } from '../types'
5
6const PANE = 'task-board'
7const tasks = atom({ plugin: 'task-board', key: 'tasks' } as const, [])
8const draft = atom({ plugin: 'task-board', key: 'draft' } as const, { edits: {}, added: [] })
9const editingId = atom({ plugin: 'task-board', key: 'editingId' } as const, null)
10const focusedKey = atom({ plugin: 'task-board', key: 'focusedKey' } as const, null)
11
12const statusMark: Record<TaskStatus, string> = {
13 pending: '[ ]',
14 in_progress: '[~]',
15 completed: '[x]',
16}
17
18const statusAfter: Record<TaskStatus, TaskStatus> = {
19 pending: 'in_progress',
20 in_progress: 'completed',
21 completed: 'pending',
22}
23
24type BoardRow = {
25 source: { kind: 'saved'; id: string } | { kind: 'new'; draftId: string }
26 key: string
27 label: string
28 subject: string
29 status: TaskStatus
30 blockedBy: string[]
31 isEdited: boolean
32}
33
34const composeRows = (saved: Task[], pending: Draft): BoardRow[] => [
35 ...saved.flatMap(task => {
36 const edit = pending.edits[task.id]
37 const status = edit?.status ?? task.status
38 if (status === 'deleted') {
39 return []
40 }
41
42 return [
43 {
44 source: { kind: 'saved', id: task.id } as const,
45 key: task.id,
46 label: `#${task.id}`,
47 subject: edit?.subject ?? task.subject,
48 status,
49 blockedBy: task.blockedBy,
50 isEdited: edit !== undefined,
51 },
52 ]
53 }),
54 ...pending.added.map(added => ({
55 source: { kind: 'new', draftId: added.draftId } as const,
56 key: added.draftId,
57 label: 'new',
58 subject: added.subject,
59 status: added.status,
60 blockedBy: [],
61 isEdited: true,
62 })),
63]
64
65const editRow = (pending: Draft, row: BoardRow, change: TaskEdit): Draft => {
66 const { source } = row
67 const { subject, status } = change
68 switch (source.kind) {
69 case 'saved':
70 return {
71 ...pending,
72 edits: { ...pending.edits, [source.id]: { ...pending.edits[source.id], ...change } },
73 }
74 case 'new':
75 return {
76 ...pending,
77 added:
78 status === 'deleted'
79 ? pending.added.filter(added => added.draftId !== source.draftId)
80 : pending.added.map(added =>
81 added.draftId === source.draftId
82 ? {
83 ...added,
84 subject: subject ?? added.subject,
85 status: status ?? added.status,
86 }
87 : added,
88 ),
89 }
90 default: {
91 const unknown: never = source
92 throw new Error(`Unknown board row source: ${JSON.stringify(unknown)}`)
93 }
94 }
95}
96
97const syncTasks = async ($: EngineInterface) => {
98 const listed = await $.tool.call({ tool: 'TaskList' })
99 if (listed.deny !== undefined) {
100 $.ui.toast(`Task list refused: ${listed.deny}`)
101 } else if (listed.isError) {
102 $.ui.toast(`Task list failed: ${listed.text}`)
103 } else {
104 await update($, tasks, () => listed.result.tasks)
105 }
106}
107
108const updateTask = async ($: EngineInterface, change: BuiltinToolInputs['TaskUpdate']) => {
109 const updated = await $.tool.call({ tool: 'TaskUpdate', ...change })
110 if (updated.deny !== undefined) {
111 $.ui.toast(`Task change refused: ${updated.deny}`)
112 } else if (updated.isError) {
113 $.ui.toast(`Task change failed: ${updated.text}`)
114 } else if (!updated.result.success) {
115 $.ui.toast(`Task change failed: ${updated.result.error ?? 'no reason given'}`)
116 }
117}
118
119const createTask = async ($: EngineInterface, added: NewTask) => {
120 const created = await $.tool.call({
121 tool: 'TaskCreate',
122 subject: added.subject,
123 description: added.subject,
124 })
125 if (created.deny !== undefined) {
126 $.ui.toast(`Task add refused: ${created.deny}`)
127 } else if (created.isError) {
128 $.ui.toast(`Task add failed: ${created.text}`)
129 } else if (added.status !== 'pending') {
130 await updateTask($, { taskId: created.result.task.id, status: added.status })
131 }
132}
133
134// The pane's own tool calls skip this plugin's tool.call hook, so saving syncs the board itself.
135const saveDraft = async ($: EngineInterface) => {
136 const pending = await read($, draft)
137 for (const [taskId, edit] of Object.entries(pending.edits)) {
138 await updateTask($, { taskId, ...edit })
139 }
140 for (const added of pending.added) {
141 await createTask($, added)
142 }
143 await update($, draft, () => ({ edits: {}, added: [] }))
144 await syncTasks($)
145}
146
147// The pane's own $.ui.close skips this plugin's ui.close hook, so its buttons discard here.
148const closeBoard = async ($: EngineInterface) => {
149 await update($, draft, () => ({ edits: {}, added: [] }))
150 await update($, editingId, () => null)
151 await $.ui.close({ id: PANE })
152}
153
154export const register: Register = on => {
155 on('session.start', async ($, e, next) => {
156 await $.command.register({
157 name: 'task-board',
158 description: 'Show the task list in a pane you and Claude both edit',
159 immediate: true,
160 })
161
162 return next(e)
163 })
164
165 on('command.run', { command: 'task-board' }, async $ => {
166 await syncTasks($)
167 await $.ui.open({ id: PANE, title: 'Tasks', focus: true })
168
169 return {}
170 })
171
172 // Claude's task changes pass here and redraw the board.
173 on('tool.call', { tool: ['TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
174 const ran = await next(e)
175 await syncTasks($)
176
177 return ran
178 }).catch(($, e, next) => next(e))
179
180 // Closing the pane with Ctrl+X X or Esc discards what was not saved.
181 on('ui.close', { id: PANE }, async ($, e, next) => {
182 await update($, draft, () => ({ edits: {}, added: [] }))
183 await update($, editingId, () => null)
184
185 return next(e)
186 }).catch(($, e, next) => next(e))
187
188 // The hotkey buttons join the ring like any Button, so the ring wraps past them.
189 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
190 const element = e.element === 'edit' ? 'new' : e.element === 'delete' ? 'close' : e.element
191 const moved = await next({ ...e, element })
192 if (moved.deny === undefined) {
193 await update($, focusedKey, () => element ?? null)
194 }
195
196 return moved
197 }).catch(($, e, next) => next(e))
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
200 if (e.surface === 'mobile') {
201 const { Text } = $.ui.resolve(e)
202
203 return <Text dimColor>The task board needs a surface with text input.</Text>
204 }
205
206 const { Box, Button, Input, Text } = $.ui.resolve(e)
207 const pending = await read($, draft)
208 const rows = composeRows(await read($, tasks), pending)
209 // While the prompt has the keyboard the board is a dimmed, read-only list.
210 const isActive = e.props.isFocused
211 const editing = isActive ? await read($, editingId) : null
212 const focused = isActive ? await read($, focusedKey) : null
213 const focusedRow = rows.find(row => `task-${row.key}` === focused)
214 const doneCount = rows.filter(row => row.status === 'completed').length
215 const unsavedCount = Object.keys(pending.edits).length + pending.added.length
216 const labelWidth = Math.max(3, ...rows.map(row => row.label.length))
217
218 return (
219 <Box flexDirection="column" paddingX={1}>
220 <Box columnGap={2} marginBottom={1}>
221 <Text bold dimColor={!isActive}>
222 Tasks
223 </Text>
224 <Text dimColor>
225 {rows.length - doneCount} open · {doneCount} done
226 </Text>
227 {unsavedCount > 0 && (
228 <Text color="warning" dimColor={!isActive}>
229 {unsavedCount} unsaved
230 </Text>
231 )}
232 </Box>
233 {/* Above the list, so a new row does not shift the focus ring off this field. */}
234 {isActive && (
235 <Box marginBottom={1}>
236 <Input
237 key="new"
238 label="+ "
239 placeholder="New task"
240 value=""
241 autoFocus={editing === null ? true : undefined}
242 submitLabel="add"
243 onSubmit={async subject => {
244 if (subject.trim() !== '') {
245 const added: NewTask = {
246 draftId: `new-${crypto.randomUUID()}`,
247 subject: subject.trim(),
248 status: 'pending',
249 }
250 await update($, draft, current => ({
251 ...current,
252 added: [...current.added, added],
253 }))
254 }
255 }}
256 />
257 </Box>
258 )}
259 {rows.length === 0 && <Text dimColor>No tasks yet.</Text>}
260 {rows.map(row =>
261 isActive && row.key !== editing ? (
262 <Button
263 key={`task-${row.key}`}
264 plain
265 onPress={() =>
266 update($, draft, current =>
267 editRow(current, row, { status: statusAfter[row.status] }),
268 )
269 }
270 >
271 <Text dimColor>{`${statusMark[row.status]} ${row.label.padEnd(labelWidth)} `}</Text>
272 <Text
273 bold={row.status === 'in_progress'}
274 italic={row.isEdited}
275 dimColor={row.status === 'completed'}
276 strikethrough={row.status === 'completed'}
277 >
278 {row.subject}
279 </Text>
280 {row.blockedBy.length > 0 && (
281 <Text color="warning">
282 {` blocked by ${row.blockedBy.map(id => `#${id}`).join(', ')}`}
283 </Text>
284 )}
285 </Button>
286 ) : (
287 <Box key={`row-${row.key}`} columnGap={1}>
288 <Text dimColor>{statusMark[row.status]}</Text>
289 <Box width={labelWidth} flexShrink={0}>
290 <Text dimColor>{row.label}</Text>
291 </Box>
292 {row.key === editing ? (
293 <>
294 <Box flexGrow={1} flexShrink={1} minWidth={0}>
295 <Input
296 key={`subject-${row.key}`}
297 value={row.subject}
298 autoFocus
299 submitLabel="keep"
300 onSubmit={async subject => {
301 await update($, draft, current => editRow(current, row, { subject }))
302 await update($, editingId, () => null)
303 }}
304 />
305 </Box>
306 <Box flexShrink={0} marginLeft={2}>
307 <Button
308 key={`cancel-${row.key}`}
309 plain
310 dimColor
311 label="cancel"
312 onPress={() => update($, editingId, () => null)}
313 />
314 </Box>
315 </>
316 ) : (
317 <Text dimColor strikethrough={row.status === 'completed'}>
318 {row.subject}
319 {row.blockedBy.length > 0 &&
320 ` blocked by ${row.blockedBy.map(id => `#${id}`).join(', ')}`}
321 </Text>
322 )}
323 </Box>
324 ),
325 )}
326 {!isActive && (
327 <Box marginTop={1}>
328 <Text dimColor>Ctrl+X Tab to edit</Text>
329 </Box>
330 )}
331 {isActive && (
332 <>
333 <Box marginTop={1} flexDirection="column" alignItems="flex-start">
334 <Button
335 key="save"
336 variant="primary"
337 label="save"
338 onPress={async () => {
339 await saveDraft($)
340 await closeBoard($)
341 }}
342 />
343 <Button
344 key="fill"
345 label="prompt"
346 dimColor={rows.length === 0}
347 onPress={async () => {
348 await saveDraft($)
349 const saved = await read($, tasks)
350 await closeBoard($)
351 if (saved.length === 0) {
352 $.ui.toast('No tasks to put in the prompt.')
353 return
354 }
355 const filled = await $.prompt.fill({
356 mode: 'insert',
357 text: [
358 'User updated tasks:',
359 ...saved.map(
360 task =>
361 `#${task.id} [${task.status}] ${task.subject}` +
362 (task.blockedBy.length > 0
363 ? ` (blocked by ${task.blockedBy.map(id => `#${id}`).join(', ')})`
364 : ''),
365 ),
366 ].join('\n'),
367 })
368 if (!filled.isFilled) {
369 $.ui.toast(`Prompt fill refused: ${filled.refusal ?? 'no reason given'}`)
370 }
371 }}
372 />
373 <Button
374 key="close"
375 role="dismiss"
376 label={unsavedCount > 0 ? 'discard' : 'close'}
377 onPress={() => closeBoard($)}
378 />
379 </Box>
380 <Box columnGap={2}>
381 <Button
382 key="edit"
383 plain
384 hotkey="e"
385 label="edit"
386 dimColor={focusedRow === undefined}
387 onPress={async () => {
388 if (focusedRow !== undefined) {
389 await update($, editingId, () => focusedRow.key)
390 await $.ui.focus({ requestId: PANE, key: `subject-${focusedRow.key}` })
391 }
392 }}
393 />
394 <Button
395 key="delete"
396 plain
397 hotkey="d"
398 label="delete"
399 dimColor={focusedRow === undefined}
400 onPress={async () => {
401 if (focusedRow !== undefined) {
402 await update($, draft, current =>
403 editRow(current, focusedRow, { status: 'deleted' }),
404 )
405 }
406 }}
407 />
408 <Text dimColor>Enter cycles status · Esc back to prompt</Text>
409 </Box>
410 </>
411 )}
412 </Box>
413 )
414 })
415}
416types/index.d.ts 29 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4 id: string
5 subject: string
6 status: TaskStatus
7 owner?: string
8 blockedBy: string[]
9}
10
11// The fields the person changed on a saved task, in TaskUpdate's own terms.
12export type TaskEdit = { subject?: string; status?: TaskStatus | 'deleted' }
13
14// A task added in the pane that has no id until the draft is saved.
15export type NewTask = { draftId: string; subject: string; status: TaskStatus }
16
17export type Draft = { edits: Record<string, TaskEdit>; added: NewTask[] }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'task-board': {
22 tasks: Task[]
23 draft: Draft
24 editingId: string | null
25 focusedKey: string | null
26 }
27 }
28}
29