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

Claude Desktop(Code 分頁)側欄面板:列出你所有 Claude Code session 中未完成與進行中的待辦,依專案分組。輸入 /todos 開啟與重新整理。Claude 建立或更新待辦、每個回合結束、送出 prompt 時會自動更新,另外每 20 秒檢查一次其他 session 的變動。
bash install.shpowershell -ExecutionPolicy Bypass -File install.ps1完成後完全關閉並重開 Claude Desktop。腳本會備份並合併 ~/.claude/settings.json(只在 env 內新增或補上兩個變數;已有的 CLAUDE_CODE_PLUGIN_DIRS,例如 token-usage,會保留)。
node(掃描程式用它讀 session 紀錄)專案資料夾已經刪除的專案會自動略過(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 移除該路徑。
hooks/register.tsx 408 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
408types/index.d.ts 11 lines1export 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