SLOPSHOPPER

Human Todo

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

newpanebandguardcommandtoast
★ 1v0.1.6MITupdated 2026-10-08bac83/human-todo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · human-todo
│ ┃ Human todo ✕ › fix the failing auth test and add an audit log call │ ┃ All clear │ ┃ ● human-todo: human-todo: bound ctrl+x t to toggle the sidebar (in /U │ ┃ c: ▾ Current (0) ⏺ Read(src/auth.ts) │ ┃ none ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ u: ▾ Upcoming (0) ⎿ Added 2 lines, removed 1 line │ ┃ none ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ d: ▸ Done (0) │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ctrl+x t toggles · 1-9 ○/✓ · c/u/d fold · ta │ ✻ Worked for 42s · done 4:20 PM │ │ › /human-todo │ ⎿ human-todo: Human todo sidebar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Human todo
All clear ▸ c: ▾ Current (0) none u: ▾ Upcoming (0) none d: ▸ Done (0) ctrl+x t toggles · 1-9 ○/✓ · c/u/d fold · tab
README

human-todo

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.

human-todo sidebar next to a Claude Code session: six todos waiting on the user, grouped into Current and Upcoming

Features

  • Three groups: Current, Upcoming, Done — each collapsible (click or Enter on its header).
  • Resolve in place: press ○ to mark a todo done, ✓ to reopen it.
  • Claude gets told: every change wakes Claude, which picks up the work that was waiting on you right away. Or have it read the change with your next message (see Settings).
  • Theme colors: current = theme warning, upcoming = suggestion, done = success, high priority = error with !. Follows whatever theme you picked in /config.
  • Collapsible sidebar: collapses to a single line above the prompt (◂ todos 1 current 2 upcoming ctrl+x t).
  • Shortcut: ctrl+x t toggles the sidebar — identical on Windows and macOS.
  • Keyboard only: opening with 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.

Install

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.

Settings (/config)

SettingDefaultWhat it does
shortcutctrl+x tChord that toggles the sidebar. Empty = no shortcut, and the binding the mod added is removed.
wakeClaudetrueOn: resolving a todo starts a turn so Claude reacts at once (each toggle is a turn). Off: Claude reads it with its next request.

About the shortcut

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.

Tools Claude gets

ToolPurpose
mcp__human-todo__add_todoHand the user an action item (title, detail, priority, status).
mcp__human-todo__update_todoMove, reword or close a todo by id.
mcp__human-todo__list_todosList this session's todos.

Todos live for the session (they survive a mod reload, not a restart).

What the mod hooks

Every hook is in hooks/register.tsx.

EventScopeWhat the hook does
session.startthe sessionRegisters the three tools and /human-todo, binds the shortcut, then lets the session start unchanged.
command.run/human-todo onlyAnswers the mod's own command: toggles the sidebar, or with add <text> adds a todo. No other command reaches it.
tool.callits three tools onlyAnswers add_todo, update_todo and list_todos, the mod's own tools. No other tool call reaches it.
prompt.composethe system promptAppends one section, human-todo:guide, after everything else; every other section is passed on unchanged.
ui.closeevery paneNotes when its own sidebar closed and whether you closed it; every close is passed on unchanged.
ui.focusits own sidebarRemembers which row has the focus ring; the focus change is passed on unchanged.
ui.renderAbovePromptDraws the one-line band above the prompt while the sidebar is collapsed; otherwise leaves the band to Claude Code.
ui.renderits own sidebar paneDraws the sidebar.

Data & privacy

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:

  • Todos live in the session's state and are gone when the session ends.
  • Notes to Claude. When you resolve or reopen a todo, the mod adds one line to your conversation with Claude, which reaches Claude like any message you send. With 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.

  • A section of the system prompt. Claude may see the three tools by name only, so the mod appends a fixed section, 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.
  • One settings file. To bind the shortcut, the mod edits Claude Code's keybindings file, ~/.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.
  • Its own tools and command. The three tools above are the mod's own: it registers them at session start and answers them itself. /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.

Uninstall

  1. In /config, set the human-todo shortcut to empty. The mod removes the binding it added to keybindings.json; a binding you made yourself stays.
  2. /plugin uninstall human-todo@human-todo

Uninstalled 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.

Develop

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.

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 574 lines
1import { 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}
574
types/index.d.ts 29 lines
1export 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