SLOPSHOPPER

todo-md

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

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-06YeomYuJun/claude-mods/todo-md
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-md
│ ┃ TODO ✕ › fix the failing auth test and add an audit log call │ ┃ /work/app/TODO.md │ ┃ File not found. It shows up here once it is ⏺ Read(src/auth.ts) │ ┃ created ⎿ Read 6 lines │ ⏺ 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 │ │ › /todo │ ⎿ todo-md: Opened the TODO panel: /work/app/TODO.md │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TODO
/work/app/TODO.md File not found. It shows up here once it is created
README

English | 한국어

todo-md

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.

Install

claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install todo-md@claude-mods

The full steps are in the repository README.

Commands

InputBehavior
/todoOpens 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.

Display rules

  • Shows headings from # to ######, indented two spaces per level relative to the highest level present.
  • Shows checklist items (- [ ], - [x], - [X], including */+/1. lists). Indented sub-items are indented one more step.
  • Hides headings with no checklist items, including their sub-headings.
  • Does not show plain paragraphs, lists without checkboxes, content inside code blocks (``, ~~~), or content inside HTML comments (<!-- -->`).
  • Notations at the end of a line such as ~3d #feat @john are left as written.
  • The first line shows the file path and done/total.
  • Handles BOM and CRLF.

Refresh

  • While the panel is open, it checks the file's modification time and size every second and rereads it only when they change.
  • When the Edit or Write tool changes that file, it rereads it immediately.
  • If the file does not exist, the panel says so, and shows it at the next check once it is created. Files larger than 1 MiB are not read.

Language

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:

  1. A language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this key
  2. The LC_ALL environment variable
  3. The LANG 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.

Development

claude plugin test "<repo-path>/todo-md"
claude plugin validate "<repo-path>/todo-md"
Source 13 files
hooks/register.ts 185 lines
1import 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}
185
hooks/messages/index.ts 4 lines
1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4
hooks/names/index.ts 11 lines
1export 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 '.'
11
hooks/parse/index.ts 3 lines
1export * from './parse-todo.js'
2export * as default from '.'
3
hooks/target/index.ts 3 lines
1export * from './target.js'
2export * as default from '.'
3
hooks/views/index.ts 3 lines
1export * from './todo-view.jsx'
2export * as default from '.'
3
hooks/messages/types.ts 13 lines
1export 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}
13
hooks/messages/language.ts 34 lines
1import { 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}
34
hooks/parse/parse-todo.ts 90 lines
1export 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}
90
hooks/target/target.ts 44 lines
1export 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}
44
hooks/views/todo-view.tsx 62 lines
1/* @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}
62
hooks/messages/en.ts 15 lines
1import 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