Shows only the headings and checklist items of TODO.md in the cwd (or a given md file) in a docked panel, and redraws when the file changes

English | 한국어
Picks only the headings and checklist items from TODO.md in the cwd (or a given md file) and shows them in the /todo docked panel. Redraws when the file changes.
The panel text and command replies are in English or Korean. See Language.
claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install todo-md@claude-mods
The full steps are in the repository README.
| Input | Behavior |
|---|---|
/todo | Opens TODO.md in the current cwd. Follows the cwd when it changes with /cd. If the panel is already showing the cwd's TODO.md, closes the panel |
/todo <path> | Opens that md file. Accepts absolute and relative paths (relative to the cwd at that moment). Given a folder, opens the TODO.md inside it. Wrap paths containing spaces in quotes |
Only one file is shown at a time. Calling /todo with another path switches the target to that file.
In the fullscreen renderer (/tui fullscreen) it docks next to the conversation when the terminal is 110 columns or wider; in the classic renderer (/tui default) it sits above the input box. It works in both renderers.
# to ######, indented two spaces per level relative to the highest level present.- [ ], - [x], - [X], including */+/1. lists). Indented sub-items are indented one more step., ~~~), or content inside HTML comments (<!-- -->`).~3d #feat @john are left as written.done/total.The panel text, the /todo description and the command replies follow the plugin's language option: auto (default), en or ko. Change it in /config; the plugin reloads with the new value.
auto takes the first of these that names Korean or English, and falls back to English:
language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this keyLC_ALL environment variableLANG environment variable (ko_KR.UTF-8)On Windows LC_ALL and LANG are usually unset, so without such a key the plugin shows English. A change to the settings key takes effect in the next session.
claude plugin test "<repo-path>/todo-md"
claude plugin validate "<repo-path>/todo-md"hooks/register.ts 185 lines1import type { EngineInterface, FsStat, On, PluginOptions, Timer } from 'claude-code'
2
3import Messages, { type Language } from './messages'
4import Names from './names'
5import Parse from './parse'
6import Target, { type Target as TargetValue } from './target'
7import Views, { type TodoView } from './views'
8
9type Host = {
10 cwd: () => Promise<string>
11 stat: (path: string) => Promise<FsStat | undefined>
12 read: (path: string) => Promise<string>
13 every: (ms: number, fn: () => void) => Timer
14 invalidate: () => void
15}
16
17function messageOf(error: unknown): string {
18 return error instanceof Error ? error.message : String(error)
19}
20
21// userConfig 의 language 가 auto 면 settings 의 language, LC_ALL, LANG 순으로 본다. $ 는 훅 안에만 있어 session.start 에서 부른다
22async function languageIn($: Pick<EngineInterface, 'settings' | 'env'>, option: unknown): Promise<Language> {
23 if (option === 'en' || option === 'ko') return option
24 const valueOf = async (read: () => Promise<unknown>) => {
25 try {
26 return await read()
27 } catch {
28 return undefined
29 }
30 }
31 return Messages.languageOf(option, [
32 await valueOf(async () => (await $.settings.read()).language),
33 await valueOf(() => $.env.get('LC_ALL')),
34 await valueOf(() => $.env.get('LANG')),
35 ])
36}
37
38// 엔진이 기록한 이 플러그인의 열린 패널. 모듈을 다시 불러와도(언어 변경 등) 패널은 떠 있고 이 기록에 남는다
39async function isPaneUp($: Pick<EngineInterface, 'ui'>, id: string): Promise<boolean> {
40 try {
41 return (await $.ui.panes()).some(pane => pane.id === id)
42 } catch {
43 return false
44 }
45}
46
47export function register(on: On, options: PluginOptions) {
48 let messages = Messages.messagesOf(Messages.languageOf(options.language))
49 let host: Host | undefined
50 let isOpen = false
51 let target: TargetValue = { kind: 'cwd' }
52 let view: TodoView | undefined
53 let signature = ''
54 let poll: Timer | undefined
55 let isReading = false
56 let isForcePending = false
57
58 async function pathOf(h: Host): Promise<string> {
59 return target.kind === 'cwd' ? Target.join(await h.cwd(), Names.FILE_NAME) : target.path
60 }
61
62 // 수정 시각과 크기가 그대로면 다시 읽지 않는다. 경로가 바뀌었거나 force 면 읽는다.
63 // 타이머의 읽기가 도는 중에 온 force 는 버리지 않고 그 읽기가 끝난 뒤 한 번 더 읽는다.
64 async function refresh(h: Host, force = false) {
65 if (isReading) {
66 if (force) isForcePending = true
67 return
68 }
69 isReading = true
70 try {
71 const path = await pathOf(h)
72 const stat = await h.stat(path)
73 const next = stat ? `${stat.kind}:${stat.mtimeMs}:${stat.size}` : 'missing'
74 if (!force && view?.path === path && next === signature) return
75 signature = next
76 if (!stat || stat.kind !== 'file') view = { kind: 'missing', path }
77 else if (stat.size > Names.MAX_READ_BYTES) view = { kind: 'large', path, size: stat.size }
78 else {
79 try {
80 view = { kind: 'doc', path, doc: Parse.todoOf(await h.read(path)) }
81 } catch (error) {
82 view = { kind: 'error', path, message: messageOf(error) }
83 }
84 }
85 h.invalidate()
86 } finally {
87 isReading = false
88 }
89 if (isForcePending) {
90 isForcePending = false
91 await refresh(h, true)
92 }
93 }
94
95 function stopPoll() {
96 poll?.cancel()
97 poll = undefined
98 }
99
100 function startPoll(h: Host) {
101 poll ??= h.every(Names.POLL_MS, () => { void refresh(h) })
102 }
103
104 async function afterEdit(path: string) {
105 if (isOpen && host && view && Target.isSameFile(path, view.path)) await refresh(host, true)
106 }
107
108 on('session.start', async ($, e, next) => {
109 host = {
110 cwd: () => $.session.cwd(),
111 stat: async path => {
112 try {
113 return await $.fs.stat(path)
114 } catch {
115 return undefined
116 }
117 },
118 read: path => $.fs.read(path),
119 every: (ms, fn) => $.clock.every(ms, fn),
120 invalidate: () => $.ui.invalidate('ui.render'),
121 }
122 messages = Messages.messagesOf(await languageIn($, options.language))
123 await $.command.register({ name: Names.COMMAND_NAME, description: messages.commandDescription, argumentHint: messages.argumentHint })
124 // 다시 불러온 모듈은 패널을 닫힌 것으로 안다. 패널이 떠 있으면 cwd 의 TODO.md 로 다시 읽고 감시를 잇는다
125 if (await isPaneUp($, Names.PANE_ID)) {
126 isOpen = true
127 await refresh(host, true)
128 startPoll(host)
129 }
130 return next(e)
131 })
132
133 // 인자 없이 부르면 cwd 의 TODO.md 를 보고, 이미 그것을 보고 있으면 닫는다.
134 // 경로를 주면 감시 대상을 그 파일 하나로 바꾼다. 폴더를 주면 그 안의 TODO.md 를 본다.
135 on('command.run', { command: Names.COMMAND_NAME }, async ($, e) => {
136 if (!host) return { text: messages.notReady }
137 const arg = Target.argOf(e.args)
138 if (arg === '') {
139 if (isOpen && target.kind === 'cwd') {
140 await $.ui.close({ id: Names.PANE_ID })
141 isOpen = false
142 stopPoll()
143 return { text: messages.panelHidden }
144 }
145 target = { kind: 'cwd' }
146 } else {
147 const path = Target.resolve(await host.cwd(), arg)
148 const stat = await host.stat(path)
149 target = { kind: 'file', path: stat?.kind === 'dir' ? Target.join(path, Names.FILE_NAME) : path }
150 }
151 await refresh(host, true)
152 await $.ui.open({ id: Names.PANE_ID, title: Names.PANE_TITLE })
153 isOpen = true
154 startPoll(host)
155 return { text: messages.panelShown(await pathOf(host)) }
156 })
157
158 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
159 const result = await next(e)
160 if (result.deny === undefined) {
161 isOpen = false
162 stopPoll()
163 }
164 return result
165 })
166
167 on('ui.render', { component: 'Pane' }, ($, e, next) => {
168 if (e.requestId !== Names.PANE_ID || !view) return next(e)
169 const { Box, Text } = $.ui.resolve(e)
170 return Views.todoView({ kit: { Box, Text }, messages, view })
171 })
172
173 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
174 const result = await next(e)
175 await afterEdit(e.file_path)
176 return result
177 })
178
179 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
180 const result = await next(e)
181 await afterEdit(e.file_path)
182 return result
183 })
184}
185hooks/messages/index.ts 4 lines1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4hooks/names/index.ts 11 lines1export const PLUGIN_NAME = 'todo-md'
2export const PANE_ID = 'todo'
3export const COMMAND_NAME = 'todo'
4export const PANE_TITLE = 'TODO'
5
6export const FILE_NAME = 'TODO.md'
7export const POLL_MS = 1000
8export const MAX_READ_BYTES = 1024 * 1024
9
10export * as default from '.'
11hooks/parse/index.ts 3 lines1export * from './parse-todo.js'
2export * as default from '.'
3hooks/target/index.ts 3 lines1export * from './target.js'
2export * as default from '.'
3hooks/views/index.ts 3 lines1export * from './todo-view.jsx'
2export * as default from '.'
3hooks/messages/types.ts 13 lines1export type Messages = {
2 commandDescription: string
3 argumentHint: string
4 panelShown: (path: string) => string
5 panelHidden: string
6 notReady: string
7 progress: (done: number, total: number) => string
8 missing: string
9 tooLarge: (kib: number) => string
10 readFailed: (message: string) => string
11 noItems: string
12}
13hooks/messages/language.ts 34 lines1import { en } from './en.js'
2import { ko } from './ko.js'
3import type { Messages } from './types.js'
4
5export type Language = 'en' | 'ko'
6
7export const DEFAULT_LANGUAGE: Language = 'en'
8
9export const MESSAGES: Readonly<Record<Language, Messages>> = { en, ko }
10
11// settings 의 language 처럼 사람이 적은 이름(Korean, 한국어)과 LANG 값(ko_KR.UTF-8)을 앞부분으로 판정한다
12export function languageOfText(text: unknown): Language | undefined {
13 if (typeof text !== 'string') return undefined
14 const value = text.trim().toLowerCase()
15 if (/^(ko([_.@-]|$)|korean|한국)/.test(value)) return 'ko'
16 if (/^(en([_.@-]|$)|english)/.test(value)) return 'en'
17 return undefined
18}
19
20// userConfig 의 language 가 en·ko 면 그 값이다. auto 거나 비어 있으면 candidates(settings 의 language, LC_ALL, LANG 순)에서
21// 처음 판정되는 값, 아무것도 판정되지 않으면 기본값이다
22export function languageOf(option: unknown, candidates: readonly unknown[] = []): Language {
23 if (option === 'en' || option === 'ko') return option
24 for (const candidate of candidates) {
25 const language = languageOfText(candidate)
26 if (language) return language
27 }
28 return DEFAULT_LANGUAGE
29}
30
31export function messagesOf(language: Language): Messages {
32 return MESSAGES[language]
33}
34hooks/parse/parse-todo.ts 90 lines1export type TodoLine =
2 | { kind: 'heading'; level: number; text: string }
3 | { kind: 'task'; depth: number; isDone: boolean; text: string }
4
5export type TodoDoc = { lines: TodoLine[]; done: number; total: number }
6
7const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/
8const FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
9const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/
10const CLOSING_HASHES = /(?:^|[ \t]+)#+$/
11const TASK = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])[ \t]+\[([ xX])\](?:[ \t]+(.*?))?[ \t]*$/
12
13function widthOf(indent: string): number {
14 let width = 0
15 for (const char of indent) width += char === '\t' ? 4 : 1
16 return width
17}
18
19// 한 줄에서 HTML 주석 구간을 지운다. 줄 끝까지 닫히지 않으면 다음 줄로 넘긴다.
20function withoutComments(line: string, inComment: boolean): { text: string; inComment: boolean } {
21 let rest = line
22 let text = ''
23 let open = inComment
24 while (rest !== '') {
25 if (open) {
26 const end = rest.indexOf('-->')
27 if (end < 0) return { text, inComment: true }
28 rest = rest.slice(end + 3)
29 open = false
30 continue
31 }
32 const start = rest.indexOf('<!--')
33 if (start < 0) return { text: text + rest, inComment: false }
34 text += rest.slice(0, start)
35 rest = rest.slice(start + 4)
36 open = true
37 }
38 return { text, inComment: open }
39}
40
41// 하위 제목까지 포함해 체크리스트 항목이 하나라도 있는 제목만 남긴다.
42function withTasksOnly(lines: TodoLine[]): TodoLine[] {
43 return lines.filter((line, index) => {
44 if (line.kind === 'task') return true
45 for (const next of lines.slice(index + 1)) {
46 if (next.kind === 'task') return true
47 if (next.level <= line.level) return false
48 }
49 return false
50 })
51}
52
53export function todoOf(source: string): TodoDoc {
54 const lines: TodoLine[] = []
55 let fence: { char: string; length: number } | undefined
56 let inComment = false
57 let indents: number[] = []
58 for (const raw of source.replace(/^/, '').split(/\r?\n/)) {
59 if (fence) {
60 const close = FENCE_CLOSE.exec(raw)?.[1]
61 if (close && close[0] === fence.char && close.length >= fence.length) fence = undefined
62 continue
63 }
64 if (!inComment) {
65 const open = FENCE_OPEN.exec(raw)?.[1]
66 if (open) {
67 fence = { char: open[0] ?? '`', length: open.length }
68 continue
69 }
70 }
71 const stripped = withoutComments(raw, inComment)
72 inComment = stripped.inComment
73 const heading = HEADING.exec(stripped.text)
74 if (heading) {
75 lines.push({ kind: 'heading', level: heading[1]?.length ?? 1, text: (heading[2] ?? '').replace(CLOSING_HASHES, '').trim() })
76 indents = []
77 continue
78 }
79 const task = TASK.exec(stripped.text)
80 if (!task) continue
81 const indent = widthOf(task[1] ?? '')
82 while (indents.length > 0 && (indents[indents.length - 1] ?? 0) >= indent) indents.pop()
83 lines.push({ kind: 'task', depth: indents.length, isDone: task[2] !== ' ', text: task[3] ?? '' })
84 indents.push(indent)
85 }
86 const kept = withTasksOnly(lines)
87 const tasks = kept.filter(line => line.kind === 'task')
88 return { lines: kept, done: tasks.filter(line => line.isDone).length, total: tasks.length }
89}
90hooks/target/target.ts 44 lines1export type Target = { kind: 'cwd' } | { kind: 'file'; path: string }
2
3// 명령 인자를 경로 하나로 읽는다. 공백 경로를 감싼 따옴표는 벗긴다.
4export function argOf(args: string): string {
5 const trimmed = args.trim()
6 const quoted = /^(["'])(.*)\1$/.exec(trimmed)
7 return quoted ? (quoted[2] ?? '').trim() : trimmed
8}
9
10export function isAbsolute(path: string): boolean {
11 return /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]/.test(path)
12}
13
14// 구분자를 / 로 맞추고 . 과 .. 을 푼다. 드라이브 문자와 루트는 남긴다.
15export function normalize(path: string): string {
16 const match = /^([A-Za-z]:)?(\/)?(.*)$/.exec(path.replace(/\\/g, '/'))
17 const drive = match?.[1] ?? ''
18 const isRooted = match?.[2] !== undefined
19 const parts: string[] = []
20 for (const segment of (match?.[3] ?? '').split('/')) {
21 if (segment === '' || segment === '.') continue
22 if (segment === '..') {
23 if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
24 else if (!isRooted) parts.push('..')
25 continue
26 }
27 parts.push(segment)
28 }
29 return `${drive}${isRooted ? '/' : ''}${parts.join('/')}`
30}
31
32export function join(dir: string, name: string): string {
33 return normalize(`${dir}/${name}`)
34}
35
36export function resolve(cwd: string, arg: string): string {
37 return isAbsolute(arg) ? normalize(arg) : join(cwd, arg)
38}
39
40// Windows 경로는 대소문자를 가리지 않는다.
41export function isSameFile(a: string, b: string): boolean {
42 return normalize(a).toLowerCase() === normalize(b).toLowerCase()
43}
44hooks/views/todo-view.tsx 62 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import type { Messages } from '../messages'
7import type { TodoDoc } from '../parse'
8
9export type Kit = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text'>
10
11export type TodoView =
12 | { kind: 'doc'; path: string; doc: TodoDoc }
13 | { kind: 'missing'; path: string }
14 | { kind: 'large'; path: string; size: number }
15 | { kind: 'error'; path: string; message: string }
16
17function headOf(messages: Messages, view: TodoView): string {
18 return view.kind === 'doc' ? `${view.path} · ${messages.progress(view.doc.done, view.doc.total)}` : view.path
19}
20
21function bodyOf(kit: Kit, messages: Messages, view: TodoView): RenderElement {
22 const { Box, Text: T } = kit
23 if (view.kind === 'missing') return <T dimColor>{messages.missing}</T>
24 if (view.kind === 'large') return <T dimColor>{messages.tooLarge(Math.ceil(view.size / 1024))}</T>
25 if (view.kind === 'error') return <T color="red">{messages.readFailed(view.message)}</T>
26 if (view.doc.total === 0) return <T dimColor>{messages.noItems}</T>
27 const levels = view.doc.lines.flatMap(line => (line.kind === 'heading' ? [line.level] : []))
28 const top = levels.length > 0 ? Math.min(...levels) : 1
29 let under = 0
30 return (
31 <Box flexDirection="column">
32 {view.doc.lines.map((line, index) => {
33 if (line.kind === 'heading') {
34 const indent = (line.level - top) * 2
35 under = indent + 2
36 return (
37 <Box key={`h:${index}`} paddingLeft={indent}>
38 <T bold wrap="wrap">{line.text}</T>
39 </Box>
40 )
41 }
42 return (
43 <Box key={`t:${index}`} flexDirection="row" paddingLeft={under + line.depth * 2}>
44 <T color={line.isDone ? 'green' : undefined}>{line.isDone ? '[x] ' : '[ ] '}</T>
45 <T dimColor={line.isDone} wrap="wrap">{line.text}</T>
46 </Box>
47 )
48 })}
49 </Box>
50 )
51}
52
53export function todoView(input: { kit: Kit; messages: Messages; view: TodoView }): RenderElement {
54 const { Box, Text: T } = input.kit
55 return (
56 <Box flexDirection="column">
57 <T dimColor wrap="truncate-end">{headOf(input.messages, input.view)}</T>
58 {bodyOf(input.kit, input.messages, input.view)}
59 </Box>
60 )
61}
62hooks/messages/en.ts 15 lines1import type { Messages } from './types.js'
2
3export const en = {
4 commandDescription: 'Open the TODO.md checklist panel. Pass an md file path to watch that file instead',
5 argumentHint: '[path]',
6 panelShown: path => `Opened the TODO panel: ${path}`,
7 panelHidden: 'Closed the TODO panel',
8 notReady: 'The plugin is still loading. Try again in a moment',
9 progress: (done, total) => `${done}/${total} done`,
10 missing: 'File not found. It shows up here once it is created',
11 tooLarge: kib => `File is ${kib} KiB, too large to read`,
12 readFailed: message => `Could not read: ${message}`,
13 noItems: 'No checklist items',
14} satisfies Messages
15