SLOPSHOPPER

open-todos

Side pane listing unfinished and in-progress todos from all Claude Code sessions, grouped by project

newpaneguardcommandpromptprocess
v0.1.0no licenseupdated 2026-10-06letswritetw/claude-mod-open-todos/open-todos
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-todos
│ ┃ Todos ✕ › fix the failing auth test and add an audit log call │ ┃ 沒有未完成的待辦,也沒有執行中的 session │ ┃ 所有 session ⏺ Read(src/auth.ts) │ ┃ 的待辦都已完成,或已被排除清單略過。之後 ⎿ Read 6 lines │ ┃ Claude ⏺ Update(src/auth.ts) │ ┃ 新增待辦或開始執行時,會顯示在這裡。 ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todos │ ⎿ open-todos: Todos pane refreshed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Todos
沒有未完成的待辦,也沒有執行中的 session 所有 session 的待辦都已完成,或已被排除清單略過。之後 Claude 新增待辦或開始執行時,會顯示在這裡。
README

open-todos

Claude Desktop(Code 分頁)側欄面板:列出你所有 Claude Code session 中未完成與進行中的待辦,依專案分組。輸入 /todos 開啟與重新整理。Claude 建立或更新待辦、每個回合結束、送出 prompt 時會自動更新,另外每 20 秒檢查一次其他 session 的變動。

安裝

  • macOS:bash install.sh
  • Windows:在 PowerShell 執行 powershell -ExecutionPolicy Bypass -File install.ps1

完成後完全關閉並重開 Claude Desktop。腳本會備份並合併 ~/.claude/settings.json(只在 env 內新增或補上兩個變數;已有的 CLAUDE_CODE_PLUGIN_DIRS,例如 token-usage,會保留)。

需求

  • 支援 mod 功能的新版 Claude Code / Claude Desktop(開發時為 2.1.290)
  • 電腦上有 node(掃描程式用它讀 session 紀錄)
  • 待辦:只會顯示用過 Claude 待辦工具(TaskCreate / TodoWrite)建立的項目
  • 執行中:最近 5 分鐘內有寫入紀錄的 session 會標示「執行中」,即使它沒有待辦,並顯示最後一則 prompt 和正在使用的工具

隱藏不想看的專案

專案資料夾已經刪除的專案會自動略過(Claude 的 session 紀錄不會隨資料夾一起刪除)。claude-mem 外掛在背景開的 observer-sessions 也會自動略過。其他想隱藏的: 在 ~/.claude/open-todos-ignore.txt 一行寫一個要略過的項目(專案名稱、專案資料夾路徑,或 8 碼 session 編號),# 開頭是註解。不會動到 session 的紀錄檔,刪掉那一行就會重新出現。

更新

重新執行一次安裝腳本即可。

解除安裝

刪除 ~/.claude/mods/open-todos,並從 settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS 移除該路徑。

Source 2 files
hooks/register.tsx 408 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TodoProject, TodoSession } from '../types'
5
6// Design: "Spine". No cards, no backgrounds. Each project is a vertical line whose stroke
7// thins and turns rose as its sessions age. Status is carried by glyph shape first, colour second.
8const PANE = 'open-todos'
9const state = atom({ plugin: 'open-todos', key: 'projects' } as const, [] as TodoProject[])
10const err = atom({ plugin: 'open-todos', key: 'error' } as const, '')
11
12// Soft palette, tuned for dark themes (Tailwind "300" level, a touch desaturated). Cool hues identify projects;
13// the warm/green/rose accents are reserved for status. Light themes get lower contrast: swap these values there.
14const HUES = ['#7FD6CB', '#9DB0FF', '#BFA9FF', '#8EDDB0', '#85CBF0', '#DDAEEB']
15const WIP = '#F3CB82' // in progress
16const FAINT = '#8E96AD' // waiting
17const RUN = '#86DDAE' // running now
18const ROSE = '#EB96A8' // untouched for a long time
19const TRACK = '#4A5168' // empty part of a progress bar
20const STALE_DAYS = 14
21
22function hueOf(name: string): string {
23  let h = 0
24  for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
25  return HUES[h % HUES.length]
26}
27
28function daysSince(ms: number): number {
29  return Math.floor((Date.now() - ms) / 86400000)
30}
31
32function ago(d: number): string {
33  if (d < 1) return '今天'
34  if (d === 1) return '昨天'
35  if (d >= 60) return `${Math.floor(d / 30)} 個月前`
36  return `${d} 天前`
37}
38
39// done / in-progress / waiting as 10 cells (largest remainder); fewer than 10 tasks get one cell each.
40function bars(done: number, wip: number, wait: number): [number, number, number] {
41  const total = done + wip + wait
42  if (total <= 10) return [done, wip, wait]
43  const raw = [done, wip, wait].map(n => (n * 10) / total)
44  const out = raw.map(Math.floor)
45  let left = 10 - out.reduce((a, b) => a + b, 0)
46  const order = [0, 1, 2].sort((a, b) => raw[b] - out[b] - (raw[a] - out[a]))
47  for (const i of order) if (left > 0) { out[i]++; left-- }
48  for (let i = 0; i < 3; i++) {
49    if ([done, wip, wait][i] > 0 && out[i] === 0) {
50      out[out.indexOf(Math.max(...out))]--
51      out[i] = 1
52    }
53  }
54  return [out[0], out[1], out[2]]
55}
56
57function has(x: string | undefined): boolean {
58  return x !== undefined && x !== ''
59}
60
61// a running session that shows more than its title line
62function rich(r: { title: string; last: string; tool: string }): boolean {
63  return (r.title !== '' && r.last !== '') || r.tool !== ''
64}
65
66function wipCount(s: TodoSession): number {
67  return s.items.filter(t => t.status === 'in_progress').length
68}
69
70// Desktop progress bar: one slim capsule per state, all on the same centre line, so nothing can step out of
71// alignment the way block glyphs of different heights do. The SVG is as tall as a text line, which leaves
72// even space above and below the 6px bar.
73function barSvg(done: number, wip: number, wait: number, hue: string, faded: boolean): string {
74  const W = 96, H = 18, h = 6, y = (H - h) / 2, gap = 2
75  const parts = ([[done, hue], [wip, WIP], [wait, TRACK]] as [number, string][]).filter(([n]) => n > 0)
76  const total = parts.reduce((a, [n]) => a + n, 0)
77  const room = W - gap * (parts.length - 1)
78  const raw = parts.map(([n]) => Math.max(h, (n / total) * room))
79  const sum = raw.reduce((a, b) => a + b, 0)
80  let x = 0
81  let rects = ''
82  parts.forEach(([, c], i) => {
83    const w = (raw[i] * room) / sum
84    rects += `<rect x="${x.toFixed(2)}" y="${y}" width="${w.toFixed(2)}" height="${h}" rx="${h / 2}" fill="${c}"/>`
85    x += w + gap
86  })
87  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><g opacity="${faded ? 0.5 : 1}">${rects}</g></svg>`
88}
89
90// Desktop project heading: Text has no font size, so the heading is drawn as SVG text, 2px larger than the
91// pane's ~12px body; its box is 6px taller than the ~19px line, and all the extra room sits under the text.
92// Long names are cut with an ellipsis from an estimated width (full-width characters 1em, others 0.62em).
93const TITLE_FS = 14
94const TITLE_H = 25
95const TITLE_MAX = 280
96
97function titleSvg(text: string, color: string): { svg: string; width: number } {
98  const adv = (ch: string) => (ch.charCodeAt(0) > 0x2e7f ? TITLE_FS : TITLE_FS * 0.62)
99  let out = ''
100  let width = 0
101  for (const ch of text) {
102    if (width + adv(ch) > TITLE_MAX) {
103      out += '…'
104      width += TITLE_FS
105      break
106    }
107    out += ch
108    width += adv(ch)
109  }
110  const safe = out.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
111  const w = Math.ceil(width) + 4
112  return {
113    width: w,
114    svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${TITLE_H}" viewBox="0 0 ${w} ${TITLE_H}"><text x="0" y="14.5" font-family="Segoe UI, Microsoft JhengHei, PingFang TC, -apple-system, BlinkMacSystemFont, sans-serif" font-size="${TITLE_FS}" font-weight="700" fill="${color}">${safe}</text></svg>`,
115  }
116}
117
118function dotSvg(color: string): string {
119  return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="18" viewBox="0 0 16 18"><circle cx="5" cy="9" r="3.5" fill="${color}"/></svg>`
120}
121
122// Overlapping requests collapse into one follow-up scan; an unchanged result is not redrawn.
123let busy = false
124let dirty = false
125let last = ''
126
127async function refresh($: any) {
128  if (busy) {
129    dirty = true
130    return
131  }
132  busy = true
133  try {
134    const r = await $.process.run(['node', `${$.plugin.root}/hooks/scan.js`], { timeoutMs: 60000 })
135    if (r.exitCode !== 0) throw new Error(r.stderr.slice(0, 200))
136    if (r.stdout !== last) {
137      last = r.stdout
138      await update($, state, () => JSON.parse(r.stdout))
139    }
140    await update($, err, () => '')
141  } catch (x) {
142    await update($, err, () => String(x))
143  } finally {
144    busy = false
145    if (dirty) {
146      dirty = false
147      void refresh($)
148    }
149  }
150}
151
152const TODO_TOOLS = ['TaskCreate', 'TaskUpdate', 'TodoWrite']
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    await $.command.register({ name: 'todos', description: 'Open pane of unfinished todos across all sessions' })
157    void $.ui.open({ id: PANE, title: 'Todos' })
158    void refresh($)
159    // other sessions change todos too: look again every 20 s (scans only re-read changed session files)
160    $.clock.every(20_000, () => void refresh($))
161    return next(e)
162  })
163
164  // Claude created or changed a todo: refresh once the tool has run and the transcript has the entry
165  on('tool.call', async ($, e, next) => {
166    const ran = await next(e)
167    if (TODO_TOOLS.includes(e.tool)) void refresh($)
168    return ran
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    void refresh($)
173    return next(e)
174  })
175
176  on('command.run', { command: 'todos' }, async $ => {
177    await $.ui.open({ id: PANE, title: 'Todos' })
178    await refresh($)
179    return { text: 'Todos pane refreshed.' }
180  })
181
182  // ponytail: rescans on every prompt; add a timer/cache if the session history gets big
183  on('prompt.submit', async ($, e, next) => {
184    void refresh($)
185    return next(e)
186  })
187
188  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
189    const ui = $.ui.resolve(e)
190    const { Box, Text } = ui
191    const Svg = 'Svg' in ui ? ui.Svg : undefined
192    const raw = await read($, state)
193    const error = await read($, err)
194
195    if (error !== '') {
196      return (
197        <Box flexDirection="column" paddingX={1}>
198          <Text bold>無法讀取待辦資料</Text>
199          <Text dimColor wrap="truncate-end">{error}</Text>
200          <Text dimColor>請確認 ~/.claude 目錄存在且可讀取,再重新開啟面板。</Text>
201        </Box>
202      )
203    }
204    if (raw.length === 0) {
205      return (
206        <Box flexDirection="column" paddingX={1}>
207          <Text bold>沒有未完成的待辦,也沒有執行中的 session</Text>
208          <Text dimColor>所有 session 的待辦都已完成,或已被排除清單略過。之後 Claude 新增待辦或開始執行時,會顯示在這裡。</Text>
209        </Box>
210      )
211    }
212
213    // Projects with work in progress first, then by latest activity (scan.js already sorts by activity).
214    const live = (p: TodoProject) => p.running.length + p.sessions.filter(s => s.running).length
215    const projects = [...raw].sort(
216      (a, b) =>
217        Number(live(b) > 0) - Number(live(a) > 0) ||
218        Number(b.sessions.some(s => wipCount(s) > 0)) - Number(a.sessions.some(s => wipCount(s) > 0)),
219    )
220    const many = projects.length > 4
221    const sessionsAll = projects.flatMap(p => p.sessions)
222    const wipTotal = sessionsAll.reduce((n, s) => n + wipCount(s), 0)
223    const waitTotal = sessionsAll.reduce((n, s) => n + s.items.length, 0) - wipTotal
224    const staleTotal = sessionsAll.filter(s => daysSince(s.mtime) > STALE_DAYS).length
225    const liveTotal = projects.reduce((n, p) => n + live(p), 0)
226
227    // Layout rules (so long text can never squeeze a sibling to zero width):
228    //  - fixed things (spine, gap, right-hand label) are flexShrink 0
229    //  - text lives in a box that starts at width 1 and grows, so its natural width never counts
230    const fill = (child: any) => (
231      <Box width={1} flexGrow={1} flexShrink={1}>
232        {child}
233      </Box>
234    )
235    const stat = (glyph: string, color: string, n: number, label: string) => (
236      <Box gap={1} flexShrink={0}>
237        <Text color={n > 0 ? color : undefined} bold={n > 0} dimColor={n === 0}>{glyph}</Text>
238        <Text bold={n > 0} dimColor={n === 0}>{n}</Text>
239        <Text dimColor={n === 0}>{label}</Text>
240      </Box>
241    )
242    const titleRow = (text: string, dim: boolean, right: any) => (
243      <Box>
244        {fill(<Text dimColor={dim} wrap="truncate-end">{text}</Text>)}
245        <Box flexShrink={0} marginLeft={1}>{right}</Box>
246      </Box>
247    )
248    const runLabel = <Text color={RUN} bold>▶ 執行中</Text>
249    const recentLine = (last: string) => <Text dimColor wrap="truncate-end">最近:{last}</Text>
250    const toolLine = (tool: string) => (
251      <Box>
252        <Text dimColor>正在使用 </Text>
253        {fill(<Text wrap="truncate-end">{tool}</Text>)}
254      </Box>
255    )
256
257    return (
258      <Box flexDirection="column" paddingX={1}>
259        <Box gap={2}>
260          {stat('▶', RUN, liveTotal, '執行中')}
261          {stat('◐', WIP, wipTotal, '進行')}
262          {stat('○', FAINT, waitTotal, '待辦')}
263        </Box>
264        {staleTotal > 0 && (
265          <Box marginTop={1}>
266            {Svg !== undefined ? (
267              <Svg source={dotSvg(ROSE)} alt="提醒" width={16} height={18} />
268            ) : (
269              <Text color={ROSE}>• </Text>
270            )}
271            {fill(<Text color={ROSE} wrap="truncate-end">{staleTotal} 個 session 超過 {STALE_DAYS} 天沒動</Text>)}
272          </Box>
273        )}
274
275        {projects.map(p => {
276          const hue = hueOf(p.project)
277          const count = p.sessions.reduce((n, s) => n + s.items.length, 0)
278          const newest = daysSince(p.sessions.length > 0 ? p.sessions[0].mtime : p.running[0].mtime)
279          const hasWip = p.sessions.some(s => wipCount(s) > 0)
280
281          if (many && !hasWip && live(p) === 0 && newest > STALE_DAYS) {
282            return (
283              <Box marginTop={1}>
284                <Box width={1} flexShrink={0} backgroundColor={ROSE} marginRight={1} />
285                {fill(<Text wrap="truncate-end">{p.project}</Text>)}
286                <Box flexShrink={0} marginLeft={1} gap={2}>
287                  <Text dimColor>{count} 項</Text>
288                  <Text color={ROSE}>{ago(newest)}</Text>
289                </Box>
290              </Box>
291            )
292          }
293
294          const cap = many ? 2 : 3
295          const shown = p.sessions.slice(0, cap)
296          const hidden = p.sessions.length - shown.length
297          const heading = titleSvg(p.project, hue)
298
299          // The project name is a heading: it sits outdented above its spine, in the project colour,
300          // so it reads as the label of the block rather than one more row inside it.
301          return (
302            <Box flexDirection="column" marginTop={1}>
303              <Box alignItems="flex-start">
304                {fill(
305                  Svg !== undefined ? (
306                    <Svg source={heading.svg} alt={p.project} width={heading.width} height={TITLE_H} />
307                  ) : (
308                    <Text bold color={hue} wrap="truncate-end">{p.project}</Text>
309                  ),
310                )}
311                {count > 0 && (
312                  <Box flexShrink={0} marginLeft={1}>
313                    <Text dimColor>{count} 項</Text>
314                  </Box>
315                )}
316              </Box>
317              <Box>
318              <Box width={1} flexShrink={0} backgroundColor={hue} />
319              <Box width={1} flexShrink={0} />
320              <Box width={1} flexGrow={1} flexShrink={1} flexDirection="column">
321                {shown.map((s, i) => {
322                  const d = daysSince(s.mtime)
323                  const old = d > STALE_DAYS
324                  const wip = wipCount(s)
325                  const [bd, bw, bt] = bars(s.done, wip, Math.max(0, s.total - s.done - wip))
326                  const itemCap = old ? 2 : many ? 3 : 5
327                  const sorted = [...s.items].sort(
328                    (a, b) => Number(b.status === 'in_progress') - Number(a.status === 'in_progress'),
329                  )
330                  const rows = sorted.slice(0, Math.min(6, Math.max(itemCap, wip)))
331                  const folded = sorted.length - rows.length
332                  const named = s.title !== ''
333
334                  return (
335                    <Box flexDirection="column" marginTop={i > 0 ? 1 : 0}>
336                      {titleRow(
337                        named ? s.title : `未命名 #${s.id.slice(0, 4)}`,
338                        !named || (old && !s.running),
339                        s.running ? runLabel : <Text color={old ? ROSE : undefined} dimColor={!old}>{ago(d)}</Text>,
340                      )}
341                      {s.running && has(s.last) && named && recentLine(s.last as string)}
342                      {s.running && has(s.tool) && toolLine(s.tool as string)}
343                      {s.total > 1 && (
344                        <Box gap={1}>
345                          {Svg !== undefined ? (
346                            <Svg
347                              source={barSvg(s.done, wip, Math.max(0, s.total - s.done - wip), hue, old)}
348                              alt={`完成 ${s.done}/${s.total}`}
349                              width={96}
350                              height={18}
351                            />
352                          ) : (
353                            <Box>
354                              <Text color={hue}>{'━'.repeat(bd)}</Text>
355                              <Text color={WIP}>{'━'.repeat(bw)}</Text>
356                              <Text color={TRACK}>{'─'.repeat(bt)}</Text>
357                            </Box>
358                          )}
359                          <Text dimColor>{s.done}/{s.total}</Text>
360                        </Box>
361                      )}
362                      {rows.map(t => (
363                        <Box>
364                          <Text>  </Text>
365                          <Text color={t.status === 'in_progress' ? WIP : FAINT} bold>
366                            {t.status === 'in_progress' ? '◐ ' : '○ '}
367                          </Text>
368                          {fill(<Text bold={t.status === 'in_progress'} wrap="truncate-end">{t.subject}</Text>)}
369                        </Box>
370                      ))}
371                      {folded > 0 && <Text dimColor>    還有 {folded} 項待辦</Text>}
372                    </Box>
373                  )
374                })}
375                {hidden > 0 && (
376                  <Box marginTop={1}>
377                    <Text dimColor>另有 {hidden} 個 session</Text>
378                  </Box>
379                )}
380
381                {p.running.slice(0, 3).map((r, i, list) => {
382                  const named = r.title !== ''
383                  const text = named ? r.title : r.last !== '' ? r.last : `未命名 #${r.id.slice(0, 4)}`
384                  // one-line entries stack as a tight list; a blank line only separates multi-line blocks
385                  const gap = p.sessions.length > 0 ? i === 0 || rich(r) || rich(list[i - 1]) : i > 0 && (rich(r) || rich(list[i - 1]))
386                  return (
387                    <Box flexDirection="column" marginTop={gap ? 1 : 0}>
388                      {titleRow(text, !named && r.last === '', runLabel)}
389                      {named && r.last !== '' && recentLine(r.last)}
390                      {r.tool !== '' && toolLine(r.tool)}
391                    </Box>
392                  )
393                })}
394                {p.running.length > 3 && (
395                  <Box marginTop={rich(p.running[2]) ? 1 : 0}>
396                    <Text dimColor>另有 {p.running.length - 3} 個執行中</Text>
397                  </Box>
398                )}
399              </Box>
400              </Box>
401            </Box>
402          )
403        })}
404      </Box>
405    )
406  })
407}
408
types/index.d.ts 11 lines
1export type TodoItem = { subject: string; status: 'pending' | 'in_progress' }
2export type TodoSession = { id: string; title: string; mtime: number; done: number; total: number; items: TodoItem[]; running?: boolean; last?: string; tool?: string }
3export type RunningSession = { id: string; title: string; last: string; tool: string; mtime: number }
4export type TodoProject = { project: string; cwd: string; sessions: TodoSession[]; running: RunningSession[] }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'open-todos': { projects: TodoProject[]; error: string; at: number }
9  }
10}
11