Sidebar of the action items Claude hands to you in this session: upcoming, current, done. Resolve them in place and Claude is told.

A Claude Code mod that keeps a sidebar of the things you have to do during a session — the login only you can run, the design only you can approve, the device only you can test on. Claude adds them; you tick them off; Claude is told.

○ to mark a todo done, ✓ to reopen it.warning, upcoming = suggestion, done = success, high priority = error with !. Follows whatever theme you picked in /config.◂ todos 1 current 2 upcoming ctrl+x t).ctrl+x t toggles the sidebar — identical on Windows and macOS.ctrl+x t (or /human-todo) gives the sidebar the keyboard, the ring on the first open todo. 1–9 resolve/reopen the rows as numbered, c/u/d fold Current/Upcoming/Done, Tab/Enter walk and press, Esc hands the keys back (sidebar stays). After a resolve the ring moves to the next todo of that section. Collapsed: ctrl+x tab, then t or Enter, opens it. (Focus is only taken over an empty prompt; else ctrl+x tab.)/human-todo toggles the sidebar too; /human-todo add <text> adds a todo of your own.Requires a Claude Code build with function-hook mods (2.1.289 or newer). At the prompt of a terminal session, type:
/plugin install human-todo --marketplace bac83/human-todo
Answer y to add the marketplace, then pick a scope (user = every session) and set the options. The sidebar is active right away, no restart.
Update later with claude plugin update human-todo@human-todo, then /reload-plugins.
/config)| Setting | Default | What it does |
|---|---|---|
shortcut | ctrl+x t | Chord that toggles the sidebar. Empty = no shortcut, and the binding the mod added is removed. |
wakeClaude | true | On: resolving a todo starts a turn so Claude reacts at once (each toggle is a turn). Off: Claude reads it with its next request. |
Claude Code has no keybinding actions of a plugin's own, so the sidebar's toggle buttons borrow the built-in action app:toggleDiffPreSession (no default key). On load the mod adds "ctrl+x t": "app:toggleDiffPreSession" to the Global context of ~/.claude/keybindings.json (or $CLAUDE_CONFIG_DIR/keybindings.json). A binding you made for that action yourself is kept. Setting shortcut to empty takes the mod's own binding back out.
| Tool | Purpose |
|---|---|
mcp__human-todo__add_todo | Hand the user an action item (title, detail, priority, status). |
mcp__human-todo__update_todo | Move, reword or close a todo by id. |
mcp__human-todo__list_todos | List this session's todos. |
Todos live for the session (they survive a mod reload, not a restart).
Every hook is in hooks/register.tsx.
| Event | Scope | What the hook does |
|---|---|---|
session.start | the session | Registers the three tools and /human-todo, binds the shortcut, then lets the session start unchanged. |
command.run | /human-todo only | Answers the mod's own command: toggles the sidebar, or with add <text> adds a todo. No other command reaches it. |
tool.call | its three tools only | Answers add_todo, update_todo and list_todos, the mod's own tools. No other tool call reaches it. |
prompt.compose | the system prompt | Appends one section, human-todo:guide, after everything else; every other section is passed on unchanged. |
ui.close | every pane | Notes when its own sidebar closed and whether you closed it; every close is passed on unchanged. |
ui.focus | its own sidebar | Remembers which row has the focus ring; the focus change is passed on unchanged. |
ui.render | AbovePrompt | Draws the one-line band above the prompt while the sidebar is collapsed; otherwise leaves the band to Claude Code. |
ui.render | its own sidebar pane | Draws the sidebar. |
human-todo makes no network requests of its own. It reads only its own session state and keybindings.json. What it does beyond drawing the sidebar:
wakeClaude on, the line is submitted as a prompt that starts a turn; off, or when that prompt is refused, it is appended for Claude's next request. The line holds only the todo's id and title: <human-todo>The user marked their todo t3 "Run gcloud auth login" as done. Continue any work that was waiting on it.</human-todo>
<human-todo>The user reopened their todo t3 "Run gcloud auth login"; it is current again.</human-todo>
Nothing else goes into these prompts: no other conversation text, file contents or settings.
human-todo:guide, to the system prompt: when to put an action on your list, one action per todo (several steps as several todos), and when to move or close one. It is the same text in every session and holds none of your data.~/.claude/keybindings.json (or keybindings.json in $CLAUDE_CONFIG_DIR). It adds or removes only its own binding and keeps everything else in the file (see About the shortcut). It remembers the chord it added in the plugin's local store, so it can take that binding out again. It writes no other file./human-todo is its own command, which it answers itself (toggle the sidebar, or add a todo). It hooks no other tool or command, takes no permission decision and changes no other setting./config, set the human-todo shortcut to empty. The mod removes the binding it added to keybindings.json; a binding you made yourself stays./plugin uninstall human-todo@human-todoUninstalled first? Delete "ctrl+x t": "app:toggleDiffPreSession" from the Global block of ~/.claude/keybindings.json by hand. Left in place, the chord opens Claude Code's built-in diff preview instead.
Run it from a clone instead of the installed copy:
git clone https://github.com/bac83/human-todo.git
claude --plugin-dir ./human-todo
To load the clone in every session without the flag, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/human-todo" } }
(Windows: C:\\path\\to\\human-todo; several folders are separated with ; on Windows, : on macOS/Linux.)
Checks:
claude plugin validate .
claude plugin test .
tsc -p . # after one load, which lays .claude-plugin/types/
describeChange() in hooks/register.tsx decides the words Claude reads when you change a todo.
MIT, see LICENSE.
hooks/register.tsx 574 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Collapsed, Todo, TodoPriority, TodoStatus } from '../types'
5
6const PLUGIN = 'human-todo'
7const PANE = 'human-todo'
8const TITLE = 'Human todo'
9const PANE_COLUMNS = 44
10
11// The engine has no keybinding action of a plugin's own, so the sidebar's
12// toggle Buttons borrow one that has no default key and whose engine handler
13// is only mounted inside the diff panel. The chord the person binds to it
14// (written to keybindings.json on load) presses whichever toggle is mounted.
15const ACTION = 'app:toggleDiffPreSession'
16
17const todos = atom({ plugin: 'human-todo', key: 'todos' } as const, [])
18const nextId = atom({ plugin: 'human-todo', key: 'nextId' } as const, 1)
19const collapsed = atom({ plugin: 'human-todo', key: 'collapsed' } as const, {
20 upcoming: false,
21 current: false,
22 done: true,
23})
24const isOpen = atom({ plugin: 'human-todo', key: 'isOpen' } as const, false)
25const isCollapsedByUser = atom({ plugin: 'human-todo', key: 'isCollapsedByUser' } as const, false)
26// The element key the pane's focus ring is on, as last seen moving; lost on a reload.
27let ring: string | undefined
28
29const SECTIONS: { status: TodoStatus; label: string; color: string; hotkey: string }[] = [
30 { status: 'current', label: 'Current', color: 'warning', hotkey: 'c' },
31 { status: 'upcoming', label: 'Upcoming', color: 'suggestion', hotkey: 'u' },
32 { status: 'done', label: 'Done', color: 'success', hotkey: 'd' },
33]
34
35const PRIORITY_RANK: Record<TodoPriority, number> = { high: 0, normal: 1, low: 2 }
36
37// The tools may reach the model deferred, their name alone, so the when and
38// how ride in the system prompt, where the model reads them every request.
39const GUIDE = {
40 id: 'human-todo:guide',
41 scope: 'session',
42 text: [
43 '# Human todo',
44 'The user keeps a todo sidebar of the actions only they can do: mcp__human-todo__add_todo, ' +
45 'update_todo, list_todos (load them with ToolSearch first if they are deferred).',
46 '- Put such actions on the list with add_todo, not only in your reply: an interactive login, ' +
47 'a decision or approval, a test on a device, a secret to supply, a page to review. ' +
48 'Also every task the user asks you to put on their list.',
49 '- One action per todo. A task of several steps is several add_todo calls, one per step: ' +
50 'the first "current", the rest "upcoming". Never a bullet list or numbered steps in title or detail.',
51 '- Move an upcoming todo to "current" once it can be done; mark one done with update_todo when you see it happen.',
52 ].join('\n'),
53} as const
54
55export const register: Register = (on, options) => {
56 const chord = String(options.shortcut ?? 'ctrl+x t').trim()
57 const wakeClaude = options.wakeClaude !== false
58 let shortcutHint = chord
59
60 on('session.start', async ($, e, next) => {
61 await $.tool.register({
62 name: 'add_todo',
63 description:
64 'Hand the user (the human) an action item only they can do: run an interactive login, ' +
65 'approve or decide something, test on a device, supply a secret, review a page. ' +
66 'It shows in their todo sidebar; you are told when they resolve it. ' +
67 'Use status "current" for what they can do now, "upcoming" for future steps they cannot do yet ' +
68 '(they cannot tick those off); move one to "current" with update_todo once it can be done. ' +
69 'One action per todo: several steps are several calls, never a list inside one todo. ' +
70 'Do not use it for your own work.',
71 inputSchema: {
72 type: 'object',
73 properties: {
74 title: { type: 'string', description: 'Short imperative, e.g. "Run gcloud auth login"' },
75 detail: {
76 type: 'string',
77 description: 'Optional: exact command, link or context for this one step; not a list of steps',
78 },
79 priority: { type: 'string', enum: ['high', 'normal', 'low'], default: 'normal' },
80 status: { type: 'string', enum: ['current', 'upcoming'], default: 'upcoming' },
81 },
82 required: ['title'],
83 },
84 })
85 await $.tool.register({
86 name: 'update_todo',
87 description:
88 "Change one of the user's todos by id: move it between upcoming and current, " +
89 'mark it done when you saw it happen, or reword it.',
90 inputSchema: {
91 type: 'object',
92 properties: {
93 id: { type: 'string' },
94 status: { type: 'string', enum: ['upcoming', 'current', 'done'] },
95 title: { type: 'string' },
96 detail: { type: 'string' },
97 priority: { type: 'string', enum: ['high', 'normal', 'low'] },
98 },
99 required: ['id'],
100 },
101 })
102 await $.tool.register({
103 name: 'list_todos',
104 description: "List the user's todos of this session with their ids and status.",
105 })
106 await $.command.register({
107 name: 'human-todo',
108 description: 'Toggle the human-todo sidebar, or add your own: /human-todo add <text>',
109 argumentHint: '[add <text>]',
110 immediate: true,
111 })
112
113 shortcutHint = (await ensureShortcut($, chord)) ?? '/human-todo'
114 // Placed panes only: one left waiting undrawn after a reload has no toggle to press.
115 const pane = await paneState($)
116 await update($, isOpen, () => pane.isUp)
117
118 return next(e)
119 })
120
121 on('prompt.compose', async ($, e, next) => {
122 const { sections } = await next(e)
123 return { sections: [...sections, GUIDE] }
124 })
125
126 on('command.run', { command: 'human-todo' }, async ($, e) => {
127 const args = (e.args ?? '').trim()
128 const title = /^add\s+(.+)$/i.exec(args)?.[1]
129 if (title !== undefined) {
130 const todo = await addTodo($, { title, status: 'current', priority: 'normal' })
131 return { text: `Added ${todo.id}: ${todo.title}` }
132 }
133 await toggleSidebar($)
134 return { text: (await read($, isOpen)) ? 'Human todo sidebar opened.' : 'Human todo sidebar collapsed.' }
135 })
136
137 on('ui.close', async ($, e, next) => {
138 if (e.id === PANE) {
139 await update($, isOpen, () => false)
140 if (e.origin.kind === 'person') await update($, isCollapsedByUser, () => true)
141 }
142 return next(e)
143 })
144
145 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
146 const moved = await next(e)
147 if (!('deny' in moved) || moved.deny === undefined) ring = e.element
148 return moved
149 })
150
151 on('tool.call', { tool: 'mcp__human-todo__add_todo' }, async ($, e) => {
152 const input = e as unknown as Partial<Todo>
153 if (typeof input.title !== 'string' || input.title.trim() === '') {
154 return { deny: 'add_todo needs a non-empty title.' }
155 }
156 const todo = await addTodo($, {
157 title: input.title.trim(),
158 detail: input.detail?.trim() || undefined,
159 priority: asPriority(input.priority),
160 status: input.status === 'current' ? 'current' : 'upcoming',
161 })
162 if ((await read($, isOpen)) === false) {
163 if (await read($, isCollapsedByUser)) {
164 $.ui.toast(`New todo for you: ${todo.title} (${shortcutHint})`)
165 } else {
166 await openPane($)
167 }
168 }
169 return { result: `Added ${todo.id} (${todo.status}, ${todo.priority}): ${todo.title}` }
170 })
171
172 on('tool.call', { tool: 'mcp__human-todo__update_todo' }, async ($, e) => {
173 const input = e as unknown as Partial<Todo>
174 const before = await read($, todos)
175 let found: Todo | undefined
176 await update($, todos, list =>
177 list.map(todo => {
178 if (todo.id !== input.id) return todo
179 found = {
180 ...todo,
181 title: input.title?.trim() || todo.title,
182 detail: input.detail === undefined ? todo.detail : input.detail.trim() || undefined,
183 priority: input.priority ? asPriority(input.priority) : todo.priority,
184 status: asStatus(input.status) ?? todo.status,
185 }
186 if (found.status === 'done' && todo.status !== 'done') found.resolvedBy = 'claude'
187 if (found.status !== 'done') found.resolvedBy = undefined
188 return found
189 }),
190 )
191 if (found === undefined) return { deny: `No todo with id ${String(input.id)}.` }
192 // Claude moved the row the ring is on: re-home it as a press there would.
193 const was = before.find(todo => todo.id === found?.id)
194 if (was !== undefined && was.status !== found.status && ring === `toggle-${was.id}`) {
195 await moveRing($, nextFocusKey(before, was), `section-${was.status}`)
196 }
197 return { result: `Updated ${found.id} (${found.status}): ${found.title}` }
198 })
199
200 on('tool.call', { tool: 'mcp__human-todo__list_todos' }, async $ => {
201 const list = await read($, todos)
202 if (list.length === 0) return { result: 'The user has no todos in this session.' }
203 return {
204 result: list
205 .map(
206 todo =>
207 `${todo.id} [${todo.status}] (${todo.priority}) ${todo.title}${todo.detail ? ` — ${todo.detail}` : ''}`,
208 )
209 .join('\n'),
210 }
211 })
212
213 // Collapsed sidebar: one line above the prompt carrying the toggle, so the
214 // shortcut has a Button mounted to press while the pane is closed.
215 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
216 // isOpen subscribes the band to collapses; the engine's record catches a pane placed meanwhile.
217 if (e.props.hasSurvey || (await read($, isOpen)) || (await paneState($)).isUp) return next(e)
218 const { Box, Button, Text } = $.ui.resolve(e)
219 const list = await read($, todos)
220 const current = list.filter(todo => todo.status === 'current').length
221 const upcoming = list.filter(todo => todo.status === 'upcoming').length
222 const hasUrgent = list.some(todo => todo.status !== 'done' && todo.priority === 'high')
223
224 return (
225 <Box flexDirection="row" gap={1}>
226 <Button
227 key="expand"
228 label="◂ todos"
229 plain
230 hotkey="t"
231 autoFocus
232 action={ACTION}
233 onPress={() => toggleSidebar($)}
234 />
235 {current + upcoming === 0 && <Text dimColor>nothing waiting on you</Text>}
236 {current > 0 && (
237 <Text color={hasUrgent ? 'error' : 'warning'} bold>
238 {current} current
239 </Text>
240 )}
241 {upcoming > 0 && <Text color="suggestion">{upcoming} upcoming</Text>}
242 <Text dimColor>{shortcutHint}</Text>
243 </Box>
244 )
245 })
246
247 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248 const { Box, Button, Text } = $.ui.resolve(e)
249 const list = await read($, todos)
250 const folded = await read($, collapsed)
251 const width = Math.max(16, e.props.bodyColumns)
252 const open = list.filter(todo => todo.status !== 'done').length
253 // Rows with a toggle, in the order drawn: 1-9 press the first nine, the
254 // first current one takes the ring. Upcoming is the future: nothing to tick.
255 const visible = SECTIONS.flatMap(section =>
256 folded[section.status] || section.status === 'upcoming'
257 ? []
258 : sortTodos(list.filter(todo => todo.status === section.status)),
259 )
260 const firstOpen = visible.find(todo => todo.status === 'current')
261
262 const row = (todo: Todo) => {
263 const isDone = todo.status === 'done'
264 const isHigh = todo.priority === 'high' && !isDone
265 const index = visible.indexOf(todo)
266 return (
267 <Box key={todo.id} flexDirection="column" width={width}>
268 <Box flexDirection="row" gap={1}>
269 {todo.status === 'upcoming' ? (
270 <Text dimColor>·</Text>
271 ) : (
272 <Button
273 key={`toggle-${todo.id}`}
274 label={isDone ? '✓' : '○'}
275 plain
276 dimColor={isDone}
277 hotkey={index >= 0 && index < 9 ? String(index + 1) : undefined}
278 autoFocus={todo === firstOpen ? true : undefined}
279 onPress={async () => {
280 // Read now, not at draw: Claude may have changed the list since.
281 const fresh = await read($, todos)
282 const now = fresh.find(other => other.id === todo.id)
283 if (now === undefined || now.status === 'upcoming') return
284 const key = nextFocusKey(fresh, now)
285 await setStatus($, now.id, now.status === 'done' ? 'current' : 'done', wakeClaude)
286 await moveRing($, key, `section-${now.status}`)
287 }}
288 />
289 )}
290 <Text
291 color={isDone ? 'inactive' : isHigh ? 'error' : undefined}
292 bold={isHigh || (todo.status === 'current' && !isDone)}
293 dimColor={isDone || todo.priority === 'low'}
294 strikethrough={isDone}
295 wrap="wrap"
296 >
297 {isHigh ? '! ' : ''}
298 {todo.title}
299 </Text>
300 </Box>
301 {todo.detail && !isDone && (
302 <Box paddingLeft={2}>
303 <Text dimColor wrap="wrap">
304 {todo.detail}
305 </Text>
306 </Box>
307 )}
308 {isDone && todo.resolvedBy === 'claude' && (
309 <Box paddingLeft={2}>
310 <Text dimColor>closed by Claude</Text>
311 </Box>
312 )}
313 </Box>
314 )
315 }
316
317 return (
318 <Box flexDirection="column" width={width}>
319 <Box flexDirection="row" justifyContent="space-between" width={width}>
320 <Text color="claude" bold>
321 {open === 0 ? 'All clear' : `${open} waiting on you`}
322 </Text>
323 <Button
324 key="collapse"
325 label="▸"
326 plain
327 dimColor
328 autoFocus={firstOpen === undefined ? true : undefined}
329 action={ACTION}
330 onPress={() => toggleSidebar($)}
331 />
332 </Box>
333 {SECTIONS.map(section => {
334 const items = sortTodos(list.filter(todo => todo.status === section.status))
335 const isFolded = folded[section.status]
336 return (
337 <Box key={section.status} flexDirection="column" marginTop={1}>
338 <Button
339 key={`section-${section.status}`}
340 label={`${isFolded ? '▸' : '▾'} ${section.label} (${items.length})`}
341 plain
342 hotkey={section.hotkey}
343 onPress={async () => {
344 await update($, collapsed, all => ({ ...all, [section.status]: !all[section.status] }) as Collapsed)
345 // A fold by hotkey can hide the row the ring is on: hand it to the header.
346 await moveRing($, `section-${section.status}`)
347 }}
348 />
349 {!isFolded && items.length === 0 && (
350 <Box paddingLeft={2}>
351 <Text dimColor>none</Text>
352 </Box>
353 )}
354 {!isFolded && items.map(row)}
355 </Box>
356 )
357 })}
358 <Box marginTop={1}>
359 <Text dimColor wrap="wrap">
360 {shortcutHint} toggles · 1-9 ○/✓ · c/u/d fold · tab
361 </Text>
362 </Box>
363 </Box>
364 )
365 })
366}
367
368async function openPane($: EngineInterface, focus = false) {
369 const opened = await $.ui.open({
370 id: PANE,
371 title: TITLE,
372 columns: PANE_COLUMNS,
373 ...(focus && { focus: true as const }),
374 })
375 await update($, isOpen, () => opened.isPlaced)
376}
377
378/**
379 * The pane as the engine records it: up only when placed (one opened unasked
380 * on a narrow terminal waits undrawn, and is placed later when it widens).
381 * Unreadable, the isOpen atom stands in.
382 */
383async function paneState($: EngineInterface): Promise<{ isUp: boolean; isFocused: boolean }> {
384 const panes = await $.ui.panes().catch(() => undefined)
385 if (panes === undefined) return { isUp: await read($, isOpen), isFocused: true }
386 const pane = panes.find(other => other.id === PANE && other.isPlaced)
387 return { isUp: pane !== undefined, isFocused: pane?.isFocused ?? false }
388}
389
390async function toggleSidebar($: EngineInterface) {
391 const pane = await paneState($)
392 if (pane.isUp && pane.isFocused) {
393 try {
394 await $.ui.close({ id: PANE })
395 } catch (error) {
396 $.ui.toast(`human-todo: sidebar not collapsed (${String(error)})`)
397 return
398 }
399 await update($, isCollapsedByUser, () => true)
400 // Our ui.close hook did not hear this close in the test kit; set it here too.
401 await update($, isOpen, () => false)
402 return
403 }
404 // Closed, or up without the keys (Claude opened it, Esc handed them back):
405 // open it, or give it the keys, before a second press collapses it.
406 await update($, isCollapsedByUser, () => false)
407 await openPane($, true)
408}
409
410/** Moves the pane's focus ring onto `key`, else `fallback`; nothing while the pane lacks the keys. */
411async function moveRing($: EngineInterface, key: string, fallback?: string) {
412 const moved = await $.ui.focus({ requestId: PANE, key }).catch((error: unknown) => ({ deny: String(error) }))
413 if (!('deny' in moved) || moved.deny === undefined) {
414 ring = key
415 return
416 }
417 if (fallback !== undefined && fallback !== key) await moveRing($, fallback)
418}
419
420async function tellClaude($: EngineInterface, text: string, wakeClaude: boolean) {
421 const note = `<human-todo>${text}</human-todo>`
422 if (wakeClaude) {
423 // A refused wake (a hook's drop, or a rejection) still tells Claude, with its next request.
424 void $.prompt.submit({ text: note }).then(
425 submitted => (submitted.drop === undefined ? undefined : appendNote($, note)),
426 () => appendNote($, note),
427 )
428 return
429 }
430 await appendNote($, note)
431}
432
433async function appendNote($: EngineInterface, note: string) {
434 const message = { type: 'user' as const, content: [{ type: 'text' as const, text: note }] }
435 const appended = await $.session.append({ message }).catch((error: unknown) => ({ deny: String(error) }))
436 if ('deny' in appended && appended.deny !== undefined) {
437 $.ui.toast(`human-todo: Claude was not told (${appended.deny}). Tell it yourself.`)
438 }
439}
440
441async function setStatus($: EngineInterface, id: string, status: TodoStatus, wakeClaude: boolean) {
442 let changed: Todo | undefined
443 await update($, todos, list =>
444 list.map(todo => {
445 if (todo.id !== id || todo.status === status) return todo
446 changed = { ...todo, status, resolvedBy: status === 'done' ? 'human' : undefined }
447 return changed
448 }),
449 )
450 if (changed === undefined) return
451 await tellClaude($, describeChange(changed), wakeClaude)
452}
453
454async function addTodo($: EngineInterface, fields: Omit<Todo, 'id'>): Promise<Todo> {
455 let id = 0
456 await update($, nextId, n => {
457 id = n
458 return n + 1
459 })
460 const todo: Todo = { ...fields, id: `t${id}` }
461 await update($, todos, list => [...list, todo])
462 return todo
463}
464
465/**
466 * The words Claude reads when the person changes a todo in the sidebar.
467 * Rewrite to taste: how much context Claude needs to pick the work back up.
468 */
469function describeChange(todo: Todo): string {
470 if (todo.status === 'done') {
471 return `The user marked their todo ${todo.id} "${todo.title}" as done. Continue any work that was waiting on it.`
472 }
473 return `The user reopened their todo ${todo.id} "${todo.title}"; it is ${todo.status} again.`
474}
475
476/**
477 * Where the focus ring lands after `todo` is resolved or reopened and leaves
478 * its section: the next todo there, else the one before it, else the header.
479 */
480function nextFocusKey(list: Todo[], todo: Todo): string {
481 const peers = sortTodos(list.filter(other => other.status === todo.status))
482 const at = peers.findIndex(other => other.id === todo.id)
483 const neighbour = peers[at + 1] ?? peers[at - 1]
484 return neighbour === undefined ? `section-${todo.status}` : `toggle-${neighbour.id}`
485}
486
487function sortTodos(list: Todo[]): Todo[] {
488 return [...list].sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority])
489}
490
491function asPriority(value: unknown): TodoPriority {
492 return value === 'high' || value === 'low' ? value : 'normal'
493}
494
495function asStatus(value: unknown): TodoStatus | undefined {
496 return value === 'upcoming' || value === 'current' || value === 'done' ? value : undefined
497}
498
499type Keybindings = { bindings?: { context: string; bindings: Record<string, string | null> }[] }
500
501/**
502 * Binds `chord` to the borrowed action in ~/.claude/keybindings.json (Global
503 * context), keeping any binding the person made for it themselves. An empty
504 * `chord` takes out the binding the mod installed. Answers the chord in force,
505 * or undefined when there is none.
506 */
507async function ensureShortcut($: EngineInterface, chord: string): Promise<string | undefined> {
508 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await homeClaudeDir($))
509 if (home === undefined) return undefined
510 // The file name stays literal in each $.fs call, so the directory can read which file it is.
511 const dir = home.replace(/[\\/]+$/, '')
512 const path = `${dir}/keybindings.json`
513
514 let file: Keybindings = {}
515 if (await $.fs.exists(`${dir}/keybindings.json`)) {
516 try {
517 file = JSON.parse(await $.fs.read(`${dir}/keybindings.json`)) as Keybindings
518 } catch {
519 $.ui.log(`${PLUGIN}: ${path} is not valid JSON; shortcut not installed, use /human-todo`)
520 return undefined
521 }
522 }
523 const blocks = Array.isArray(file.bindings) ? file.bindings : []
524 const installed = (await $.store.get('installedChord')) as string | undefined
525 const bound = blocks
526 .flatMap(block => Object.entries(block.bindings ?? {}))
527 .find(([, action]) => action === ACTION)?.[0]
528
529 if (bound !== undefined && (bound === chord || bound !== installed)) return bound
530 if (chord === '') {
531 if (bound === undefined) return undefined
532 for (const block of blocks) delete block.bindings[bound]
533 await writeKeybindings($, dir, file, blocks)
534 await $.store.delete('installedChord')
535 $.ui.log(`${PLUGIN}: removed ${bound} (in ${path})`)
536 return undefined
537 }
538
539 let global = blocks.find(block => block.context === 'Global')
540 if (global === undefined) {
541 global = { context: 'Global', bindings: {} }
542 blocks.push(global)
543 }
544 const taken = global.bindings[chord]
545 if (taken !== undefined && taken !== null && taken !== ACTION) {
546 $.ui.log(`${PLUGIN}: ${chord} is already bound to ${taken}; pick another shortcut in /config`)
547 return undefined
548 }
549 if (bound !== undefined) {
550 for (const block of blocks) delete block.bindings[bound]
551 }
552 global.bindings[chord] = ACTION
553
554 await writeKeybindings($, dir, file, blocks)
555 await $.store.set('installedChord', chord)
556 $.ui.log(`${PLUGIN}: bound ${chord} to toggle the sidebar (in ${path})`)
557 return chord
558}
559
560async function writeKeybindings($: EngineInterface, dir: string, file: Keybindings, blocks: NonNullable<Keybindings['bindings']>) {
561 const next = {
562 $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
563 $docs: 'https://code.claude.com/docs/en/keybindings',
564 ...file,
565 bindings: blocks,
566 }
567 await $.fs.write(`${dir}/keybindings.json`, JSON.stringify(next, null, 2) + '\n')
568}
569
570async function homeClaudeDir($: EngineInterface): Promise<string | undefined> {
571 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
572 return home === undefined ? undefined : `${home}/.claude`
573}
574types/index.d.ts 29 lines1export type TodoStatus = 'upcoming' | 'current' | 'done'
2export type TodoPriority = 'high' | 'normal' | 'low'
3
4export type Todo = {
5 id: string
6 title: string
7 detail?: string
8 priority: TodoPriority
9 status: TodoStatus
10 /** Who moved it to done: the person in the sidebar, or Claude through the tool. */
11 resolvedBy?: 'human' | 'claude'
12}
13
14export type Collapsed = Record<TodoStatus, boolean>
15
16declare module 'claude-code' {
17 interface PluginState {
18 'human-todo': {
19 todos: Todo[]
20 nextId: number
21 collapsed: Collapsed
22 /** The sidebar pane is shown; the collapsed one-liner draws otherwise. */
23 isOpen: boolean
24 /** The person closed the sidebar: new todos toast instead of reopening it. */
25 isCollapsedByUser: boolean
26 }
27 }
28}
29