A pane showing the milestone and task tree of spec/*/tasks.md files; press a task to tick it

claude-md-taskview)A Claude Code mod that draws the milestone and task tree of your spec/*/tasks.md files in a pane beside the transcript, in the terminal and in the Claude Desktop Code tab. It lets you follow an agent's progress through a task list, and tick tasks yourself, without opening an editor.
It is the same product as the Markdown Tasks View extension for VS Code, on a second host. The parser is a copy of the extension's, so both read a tasks.md the same way.
Claude Code v2.1.287 or newer. Mods are an early-access part of Claude Code and their API may change between releases.
Try it from a clone, for one session:
git clone https://github.com/coreyx/claude-md-taskview.git
claude --plugin-dir ./claude-md-taskview
Install it for every session:
/plugin install md-taskview --marketplace coreyx/claude-md-taskview
The Desktop Code tab takes no --plugin-dir flag. To try a clone there, name its absolute path in CLAUDE_CODE_PLUGIN_DIRS, in the environment the app starts from or in the env block of ~/.claude/settings.json, then restart the app.
Run /md-tasks in a folder that holds spec/<name>/tasks.md files. The pane opens beside the transcript; it never opens by itself.
(done/total) count.↗ at the end of a heading or task row to open the file at that line in VS Code. This needs the code command on your PATH.r), hides or shows completed tasks (f), and expands or collapses everything (e / c).The pane re-reads a task file within about two seconds of it changing on disk, and at once after Claude edits one.
Set these in the mod's configuration (/config, or pluginConfigs in settings). They mirror the extension's settings of the same names.
The dialog shown at install does not fill in the defaults of the text settings: those fields start empty. Leave one empty and its default applies; you do not have to type it.
| Setting | Default | Description |
|---|---|---|
specPathPattern | spec/* | Where spec folders are, relative to the working directory. * matches one folder level. |
taskFileName | tasks.md | The task file inside each spec folder. |
stateTemplate | Standard (GFM) | Standard (GFM), Obsidian Tasks, or Custom. |
customTemplatePath | (empty) | Path to a *.jsonc state template, used when stateTemplate is Custom. |
strikeThroughCompleted | true | Strike through and dim completed and cancelled tasks. |
showProgressCount | true | Show (done/total) beside groups and headings. |
hideCompletedByDefault | false | Start with completed tasks hidden. |
useH1AsGroupName | false | Name a group after the first level-1 heading in its task file instead of its folder. |
milestoneKeywords | Milestone, Release, Alpha, Beta | Comma-separated keywords that mark a heading as a milestone. |
A custom template uses the same *.jsonc format as the extension; see its README.
*.** spec/* finds spec/<name>/tasks.md; it does not search deeper folders, and there are no separate include and exclude patterns.npm install
npm test # parser, templates and mutation (vitest, core/*.spec.ts)
npm run validate # claude plugin validate .
npm run test:mod # the pane, under claude plugin test . (hooks/*.test.ts)
npm run typecheck # needs the types Claude Code lays on first load
Claude Code writes its type definitions to .claude-plugin/types/ the first time it loads the mod from this folder in an interactive session (claude --plugin-dir .), so run that once before npm run typecheck.
claude plugin test . runs every *.test.ts under the folder and cannot import vitest, which is why the vitest file is core/core.spec.ts.
core/ holds the parser copied from the extension; core/UPSTREAM.md records the source commit and what was changed, so fixes can be ported between the two repositories by hand.
Two rules of the mods API shape hooks/: $ may only be passed to functions declared at the top of hooks/register.tsx, never into another file, so sync.ts and discover.ts take closures; and types/index.d.ts must be self-contained, so it repeats the types in core/types.ts.
MIT License.
hooks/register.tsx 438 lines1import { atom, read, update } from 'claude-code';
2import type { Elements, EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code';
3
4import { replaceStateChar } from '../core/mutate';
5import { getNextState } from '../core/templates';
6import type { SpecGroup, TaskNode } from '../core/types';
7import type { SpecGroup as ContractSpecGroup } from '../types';
8import { buildRows, collapsibleIds } from './rows';
9import type { Row } from './rows';
10import { readSettings } from './settings';
11import { createSession, loadTemplate, poll, refresh } from './sync';
12import type { Host, Session } from './sync';
13
14// types/index.d.ts has to repeat core's types; this line stops compiling if one changes without the other.
15type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
16export const contractMatchesCore: Same<SpecGroup, ContractSpecGroup> = true;
17
18const PANE = 'md-tasks';
19const COMMAND = 'md-tasks';
20const POLL_MS = 1500;
21const INDENT = ' ';
22const OPEN_TIMEOUT_MS = 15000;
23/** Columns the open control takes at the end of a row: a space and the arrow. */
24const OPEN_WIDTH = 2;
25// `cmd /c` re-reads its arguments as a command line, so a path holding one of these could run something else.
26const CMD_UNSAFE = /[&|<>^%!"]/;
27
28const groups = atom({ plugin: 'md-taskview', key: 'groups' } as const, []);
29const collapsed = atom({ plugin: 'md-taskview', key: 'collapsed' } as const, []);
30const hideCompleted = atom({ plugin: 'md-taskview', key: 'hideCompleted' } as const, false);
31const error = atom({ plugin: 'md-taskview', key: 'error' } as const, null);
32const focused = atom({ plugin: 'md-taskview', key: 'focused' } as const, null);
33
34/** One styled run of text in a row. */
35interface Part {
36 text: string;
37 color?: string;
38 bold?: boolean;
39 dimColor?: boolean;
40 strikethrough?: boolean;
41}
42
43/** A row the person can press: styled text before and after a label. */
44export interface Pressable {
45 key: string;
46 before: Part[];
47 label: Part;
48 after: Part[];
49 onPress: () => void;
50}
51
52/** The slice of a surface's element table the rows are built from. */
53export type RowElements = Pick<Elements[RenderSurface], 'Box' | 'Button' | 'Text'>;
54
55/**
56 * Draws a pressable row, as one plain Button holding styled Text where the
57 * build allows it.
58 *
59 * Builds before 2.1.294 refuse Text inside a Button, throwing as the element
60 * is made; the row then falls back to Text, a string-label Button and Text
61 * side by side. A Button's own label cannot be struck through, so there a
62 * struck label is only dimmed.
63 *
64 * @param el The surface's Box, Button and Text.
65 * @param row What to draw.
66 * @param nesting Remembers whether the rich form was refused, so a long list tries it once.
67 */
68export function drawPressable(el: RowElements, row: Pressable, nesting: { isRefused: boolean }): RenderElement {
69 const { Box, Button, Text } = el;
70 const text = (part: Part): RenderElement => {
71 // Only the props a part sets are passed: a surface may refuse one given as undefined.
72 const { text: content, ...style } = part;
73 return <Text {...style}>{content}</Text>;
74 };
75 const before = row.before.filter((part) => part.text !== '').map(text);
76 const after = row.after.filter((part) => part.text !== '').map(text);
77
78 if (!nesting.isRefused) {
79 try {
80 return (
81 <Button key={row.key} plain onPress={row.onPress}>
82 {before}
83 {text(row.label)}
84 {after}
85 </Button>
86 );
87 } catch {
88 nesting.isRefused = true;
89 }
90 }
91
92 return (
93 <Box>
94 {before}
95 <Button key={row.key} plain dimColor={row.label.dimColor === true} label={row.label.text} onPress={row.onPress} />
96 {after}
97 </Box>
98 );
99}
100
101/** What the module keeps between events, beside the sync session. */
102interface Mod {
103 session: Session;
104 /** True once `/md-tasks` registered as this mod's; until then a command of that name is someone else's. */
105 hasCommand: boolean;
106 /** The `$.store` key this folder's view is saved under. */
107 viewKey: string;
108 /** Per surface, whether the build refused Text inside a Button. */
109 nestingBySurface: Map<string, { isRefused: boolean }>;
110}
111
112// Claude Code follows `$` only into functions declared at the top of this
113// file, never into a closure or across an import. So everything that takes
114// `$` is declared here, and the files under hooks/ get closures instead.
115
116/** What hooks/sync.ts and hooks/discover.ts need from Claude Code, as closures over `$`. */
117function hostOf($: EngineInterface): Host {
118 return {
119 list: (path) => $.fs.list(path),
120 stat: (path) => $.fs.stat(path),
121 read: async (path) => {
122 // `$.fs.read` also has a bytes form, which this mod never asks for.
123 const content = await $.fs.read(path);
124 return typeof content === 'string' ? content : '';
125 },
126 setGroups: (parsed) => update($, groups, () => parsed),
127 setError: (message) => update($, error, () => message),
128 toast: (text) => $.ui.toast(text),
129 };
130}
131
132/** Loads what a folder's pane was left showing: its collapsed rows and the completed filter. */
133async function restoreView($: EngineInterface, mod: Mod, cwd: string): Promise<void> {
134 mod.viewKey = `view:${cwd}`;
135 const saved = ((await $.store.get(mod.viewKey)) ?? {}) as { collapsed?: unknown; hideCompleted?: unknown };
136 const ids = Array.isArray(saved.collapsed) ? saved.collapsed.filter((id) => typeof id === 'string') : [];
137 const isHidden =
138 typeof saved.hideCompleted === 'boolean' ? saved.hideCompleted : mod.session.settings.hideCompletedByDefault;
139
140 await update($, collapsed, () => ids);
141 await update($, hideCompleted, () => isHidden);
142}
143
144async function saveView($: EngineInterface, mod: Mod): Promise<void> {
145 await $.store.set(mod.viewKey, { collapsed: await read($, collapsed), hideCompleted: await read($, hideCompleted) });
146}
147
148async function setCollapsed($: EngineInterface, mod: Mod, change: (ids: string[]) => string[]): Promise<void> {
149 await update($, collapsed, (ids) => change(ids));
150 await saveView($, mod);
151}
152
153async function toggleHideCompleted($: EngineInterface, mod: Mod): Promise<void> {
154 await update($, hideCompleted, (isHidden) => !isHidden);
155 await saveView($, mod);
156}
157
158/**
159 * Writes one task's new state character to its file.
160 *
161 * There is no editor buffer or undo: the file is re-read, the bracket is
162 * checked against what the pane drew, and the whole file is written back.
163 * When the bracket is no longer there the list is refreshed and nothing is
164 * written.
165 */
166async function setTaskState($: EngineInterface, mod: Mod, task: TaskNode, nextChar: string): Promise<void> {
167 const host = hostOf($);
168 try {
169 const content = await host.read(task.filePath);
170 const changed = replaceStateChar(content, task.bracketRange, task.char, nextChar, task.rawText);
171 if (changed === null) {
172 await refresh(host, mod.session);
173 $.ui.toast('Task moved on disk; list refreshed');
174 return;
175 }
176
177 await $.fs.write(task.filePath, changed);
178 await refresh(host, mod.session);
179 } catch (failure) {
180 $.ui.toast(`Markdown Tasks: could not update the task (${failure instanceof Error ? failure.message : failure})`);
181 }
182}
183
184/**
185 * Registers `/md-tasks`.
186 *
187 * Not `/tasks`: Claude Code has a command of that name, and registering it
188 * again does not fail, it lists a second `/tasks` beside the built-in one.
189 */
190async function registerCommand($: EngineInterface, mod: Mod): Promise<void> {
191 try {
192 await $.command.register({
193 name: COMMAND,
194 description: 'Show the Markdown task tree of this folder in a pane',
195 immediate: true,
196 });
197 mod.hasCommand = true;
198 } catch (failure) {
199 $.ui.log(`Markdown Tasks: /${COMMAND} could not be registered (${failure instanceof Error ? failure.message : failure})`);
200 }
201}
202
203/**
204 * Opens a task file at a line in VS Code, through its `code` command.
205 *
206 * `code` is tried by itself first. On Windows it is a script that some builds
207 * cannot start without a shell, so `cmd /c code` is the fallback, and only
208 * for a path `cmd` would not read as anything but a path.
209 *
210 * @param filePath The task file, relative to the session's working directory, which the command runs in.
211 * @param line Zero-indexed line, as the parser counts.
212 */
213async function openSource($: EngineInterface, filePath: string, line: number): Promise<void> {
214 const target = `${filePath}:${line + 1}`;
215 const opens = (argv: string[]): Promise<boolean> =>
216 $.process
217 .run(argv, { timeoutMs: OPEN_TIMEOUT_MS })
218 .then((ran) => ran.exitCode === 0)
219 .catch(() => false);
220
221 if (await opens(['code', '-g', target])) return;
222 if (!CMD_UNSAFE.test(target) && (await opens(['cmd', '/c', 'code', '-g', target]))) return;
223
224 $.ui.toast(`Markdown Tasks: could not open ${target} (is the VS Code "code" command on PATH?)`);
225}
226
227/** Answers the mod's command: opens the pane, and prints a line only when that failed. */
228async function openPane($: EngineInterface, mod: Mod): Promise<{ text?: string }> {
229 try {
230 await refresh(hostOf($), mod.session);
231 await $.ui.open({ id: PANE, title: 'Markdown Tasks', focus: true, closeOnEscape: true });
232 return {};
233 } catch (failure) {
234 return { text: `Markdown Tasks: could not open the pane (${failure instanceof Error ? failure.message : failure})` };
235 }
236}
237
238export const register: Register = (on, options) => {
239 const mod: Mod = {
240 session: createSession(readSettings(options)),
241 hasCommand: false,
242 viewKey: 'view',
243 nestingBySurface: new Map(),
244 };
245 const { session } = mod;
246
247 // The mod's own work never fails a hook: a hook that throws is skipped, and these all sit on a chain.
248 on('session.start', async ($, e, next) => {
249 const host = hostOf($);
250 await loadTemplate(host, session);
251 await restoreView($, mod, e.cwd).catch(() => undefined);
252 await refresh(host, session);
253 $.clock.every(POLL_MS, () => void poll(host, session));
254 // Last, so a name clash cannot keep the rest from being set up.
255 await registerCommand($, mod);
256
257 return next(e);
258 });
259
260 // /clear, /resume and a fork reset $.state without a session.start.
261 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
262 await restoreView($, mod, e.cwd).catch(() => undefined);
263 await refresh(hostOf($), session);
264
265 return next(e);
266 });
267
268 on('command.run', { command: COMMAND }, async ($, e, next) => {
269 // If the name could not be registered, a command called this is not the mod's to answer.
270 if (!mod.hasCommand) return next(e);
271
272 return openPane($, mod);
273 });
274
275 // Claude's own edit to a task file shows at once, not at the next poll.
276 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
277 const ran = await next(e);
278 const fileName = e.file_path.replace(/\\/g, '/').split('/').at(-1) ?? '';
279 if (fileName.toLowerCase() === session.settings.taskFileName.toLowerCase()) {
280 await refresh(hostOf($), session).catch(() => undefined);
281 }
282
283 return ran;
284 });
285
286 // The state picker follows the focus ring, which only this event reports.
287 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
288 const moved = await next(e);
289 const key = e.element;
290 const isPicker = key !== undefined && key.startsWith('s-');
291 if (session.template.states.length > 2 && !isPicker) {
292 const taskKey = key !== undefined && key.startsWith('t-') ? key : null;
293 const held = await read($, focused).catch(() => taskKey);
294 if (held !== taskKey) {
295 await update($, focused, () => taskKey).catch(() => undefined);
296 }
297 }
298
299 return moved;
300 });
301
302 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
303 const el = $.ui.resolve(e);
304 const { Box, Button, Text } = el;
305 const Select = 'Select' in el ? el.Select : undefined;
306
307 const list = await read($, groups);
308 const collapsedIds = await read($, collapsed);
309 const isHiding = await read($, hideCompleted);
310 const failure = await read($, error);
311 const focusedKey = await read($, focused);
312
313 const { settings, template } = session;
314 const rows = buildRows(list, { collapsed: new Set(collapsedIds), hideCompleted: isHiding, template, settings });
315 const columns = Math.max(20, e.props.bodyColumns);
316 const nesting = mod.nestingBySurface.get(e.surface) ?? { isRefused: false };
317 mod.nestingBySurface.set(e.surface, nesting);
318
319 /** Shortens a label so its row stays on one line of the pane. */
320 const fit = (label: string, used: number): string => {
321 const room = Math.max(8, columns - used);
322 return label.length > room ? `${label.slice(0, room - 1)}…` : label;
323 };
324
325 /** Adds the jump-to-source control after a row; pressing the row itself still toggles it. */
326 const withOpen = (rowElement: RenderElement, key: string, filePath: string, line: number): RenderElement => (
327 <Box>
328 {rowElement}
329 <Text> </Text>
330 <Button key={`o-${key}`} plain dimColor label="↗" onPress={() => void openSource($, filePath, line)} />
331 </Box>
332 );
333
334 const drawRow = (row: Row): RenderElement | RenderElement[] => {
335 const indent = INDENT.repeat(row.depth);
336
337 if (row.kind === 'task') {
338 const lead = `${indent}${row.glyph} `;
339 const rowElement = withOpen(
340 drawPressable(
341 el,
342 {
343 key: row.key,
344 before: [{ text: lead, ...(row.color === undefined ? {} : { color: row.color }) }],
345 label: {
346 text: fit(row.task.cleanText, lead.length + OPEN_WIDTH),
347 ...(row.isStruck ? { strikethrough: true, dimColor: true } : {}),
348 },
349 after: [],
350 onPress: () => void setTaskState($, mod, row.task, getNextState(template, row.task.char).char),
351 },
352 nesting,
353 ),
354 row.key,
355 row.task.filePath,
356 row.task.line,
357 );
358 if (Select === undefined || template.states.length <= 2 || row.key !== focusedKey) return rowElement;
359
360 // Replaces the extension's right-click "Set Task State" menu.
361 return [
362 rowElement,
363 <Box paddingLeft={lead.length}>
364 <Select
365 key={`s-${row.key}`}
366 label="State"
367 options={template.states.map((state, index) => ({
368 value: String(index),
369 label: `[${state.char}] ${state.label}`,
370 }))}
371 value={String(template.states.indexOf(row.state))}
372 onSelect={(value) => {
373 const picked = template.states[Number(value)];
374 if (picked) void setTaskState($, mod, row.task, picked.char);
375 }}
376 />
377 </Box>,
378 ];
379 }
380
381 const chevron = row.isCollapsed ? '▸ ' : '▾ ';
382 const flag = row.kind === 'heading' && row.isMilestone ? (row.isComplete ? '⚑ ' : '⚐ ') : '';
383 const count = row.count === '' ? '' : ` ${row.count}`;
384 const used = indent.length + chevron.length + flag.length + count.length + OPEN_WIDTH;
385
386 const rowElement = drawPressable(
387 el,
388 {
389 key: row.key,
390 before: [
391 { text: `${indent}${chevron}` },
392 { text: flag, ...(row.kind === 'heading' && row.isComplete ? { color: 'success' } : {}) },
393 ],
394 label: { text: fit(row.kind === 'group' ? row.title : row.label, used), bold: row.kind === 'group' },
395 after: [{ text: count, dimColor: true }],
396 onPress: () =>
397 void setCollapsed($, mod, (ids) =>
398 ids.includes(row.id) ? ids.filter((id) => id !== row.id) : [...ids, row.id],
399 ),
400 },
401 nesting,
402 );
403
404 return row.kind === 'heading' ? withOpen(rowElement, row.key, row.filePath, row.line) : rowElement;
405 };
406
407 return (
408 <Box flexDirection="column" width={columns}>
409 <Box columnGap={2} flexWrap="wrap">
410 <Button key="refresh" plain hotkey="r" label="Refresh" onPress={() => void refresh(hostOf($), session)} />
411 <Button
412 key="filter"
413 plain
414 hotkey="f"
415 label={isHiding ? 'Show completed' : 'Hide completed'}
416 onPress={() => void toggleHideCompleted($, mod)}
417 />
418 <Button key="expand" plain hotkey="e" label="Expand all" onPress={() => void setCollapsed($, mod, () => [])} />
419 <Button
420 key="collapse"
421 plain
422 hotkey="c"
423 label="Collapse all"
424 onPress={() => void setCollapsed($, mod, () => collapsibleIds(list))}
425 />
426 </Box>
427 {failure !== null && <Text dimColor>{failure}</Text>}
428 {list.length === 0 && failure === null && (
429 <Text dimColor>
430 No {settings.specPathPattern}/{settings.taskFileName} found in this folder.
431 </Text>
432 )}
433 {rows.map(drawRow)}
434 </Box>
435 );
436 });
437};
438core/mutate.ts 62 lines1import type { BracketRange } from './types';
2
3/**
4 * Non-destructively replaces the character inside a task's checklist brackets.
5 *
6 * The file may have changed since it was parsed, so the bracket is checked
7 * first: the write only goes ahead when `[`, the expected character and `]`
8 * still sit at the parsed columns of the parsed line.
9 *
10 * @param content Current text of the markdown file.
11 * @param bracketRange Where the parse found the brackets.
12 * @param expectedChar Character the parse found between them.
13 * @param nextChar Character to write in its place.
14 * @param expectedLine The whole line as parsed (`TaskNode.rawText`). When
15 * given, the line must still read the same: a line inserted above would
16 * otherwise put a different task's bracket at the same columns.
17 * @returns The new content, every other byte and line ending untouched, or
18 * `null` when the bracket has moved or holds something else.
19 */
20export function replaceStateChar(
21 content: string,
22 bracketRange: BracketRange,
23 expectedChar: string,
24 nextChar: string,
25 expectedLine?: string
26): string | null {
27 // A toggle swaps one character for one: `[]` and `[xx]` have no single slot to write.
28 if (expectedChar.length !== 1 || nextChar.length !== 1) return null;
29 if (bracketRange.closeBracketCol !== bracketRange.charCol + 1) return null;
30
31 const lineStart = lineStartOffset(content, bracketRange.line);
32 if (lineStart === -1) return null;
33
34 const newline = content.indexOf('\n', lineStart);
35 const lineEnd = newline === -1 ? content.length : newline;
36 const openAt = lineStart + bracketRange.openBracketCol;
37 const charAt = lineStart + bracketRange.charCol;
38 const closeAt = lineStart + bracketRange.closeBracketCol;
39
40 if (bracketRange.openBracketCol < 0 || closeAt >= lineEnd) return null;
41 if (content[openAt] !== '[' || content[charAt] !== expectedChar || content[closeAt] !== ']') return null;
42
43 if (expectedLine !== undefined) {
44 // The parser splits on \r?\n, so a CRLF line's text stops before the \r.
45 const textEnd = content[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd;
46 if (content.slice(lineStart, textEnd) !== expectedLine) return null;
47 }
48
49 return content.slice(0, charAt) + nextChar + content.slice(charAt + 1);
50}
51
52/** Offset of the first character of a zero-indexed line, or -1 when the content has fewer lines. */
53function lineStartOffset(content: string, line: number): number {
54 let offset = 0;
55 for (let current = 0; current < line; current++) {
56 const newline = content.indexOf('\n', offset);
57 if (newline === -1) return -1;
58 offset = newline + 1;
59 }
60 return offset;
61}
62core/templates.ts 236 lines1import type { StateDefinition } from './types';
2
3export interface StateTemplate {
4 name: string;
5 description: string;
6 defaultState: string;
7 states: StateDefinition[];
8}
9
10export const STANDARD_TEMPLATE: StateTemplate = {
11 name: 'Standard (GFM)',
12 description: 'Standard GitHub-Flavored Markdown checklist states',
13 defaultState: ' ',
14 states: [
15 {
16 char: ' ',
17 id: 'pending',
18 label: 'Not Started',
19 icon: 'circle-large-outline',
20 strikethrough: false,
21 countsAsCompleted: false,
22 nextState: 'x',
23 },
24 {
25 char: 'x',
26 id: 'done',
27 label: 'Done',
28 icon: 'pass-filled',
29 color: 'charts.green',
30 strikethrough: true,
31 countsAsCompleted: true,
32 nextState: ' ',
33 },
34 ],
35};
36
37export const OBSIDIAN_TEMPLATE: StateTemplate = {
38 name: 'Obsidian Tasks',
39 description: 'Obsidian Tasks plugin multi-state task conventions',
40 defaultState: ' ',
41 states: [
42 {
43 char: ' ',
44 id: 'not_started',
45 label: 'Not Started',
46 icon: 'circle-large-outline',
47 strikethrough: false,
48 countsAsCompleted: false,
49 nextState: '/',
50 },
51 {
52 char: '/',
53 id: 'in_progress',
54 label: 'In Progress',
55 icon: 'sync',
56 color: 'charts.yellow',
57 strikethrough: false,
58 countsAsCompleted: false,
59 nextState: 'x',
60 },
61 {
62 char: 'x',
63 id: 'done',
64 label: 'Done',
65 icon: 'pass-filled',
66 color: 'charts.green',
67 strikethrough: true,
68 countsAsCompleted: true,
69 nextState: ' ',
70 },
71 {
72 char: '-',
73 id: 'cancelled',
74 label: 'Cancelled',
75 icon: 'circle-slash',
76 color: 'disabledForeground',
77 strikethrough: true,
78 countsAsCompleted: false,
79 nextState: ' ',
80 },
81 {
82 char: '?',
83 id: 'clarification',
84 label: 'Needs Clarification',
85 icon: 'question',
86 color: 'charts.purple',
87 strikethrough: false,
88 countsAsCompleted: false,
89 nextState: ' ',
90 },
91 {
92 char: '!',
93 id: 'important',
94 label: 'Important',
95 icon: 'warning',
96 color: 'charts.red',
97 strikethrough: false,
98 countsAsCompleted: false,
99 nextState: ' ',
100 },
101 ],
102};
103
104/**
105 * Returns the state a bracket character maps to in the template, matching
106 * case-insensitively so `[X]` reads as `[x]`.
107 *
108 * @param template Active state template.
109 * @param char Character found inside the checklist brackets.
110 */
111export function getState(template: StateTemplate, char: string): StateDefinition {
112 const lower = char.toLowerCase();
113
114 // Last match wins, as a later duplicate overwrote an earlier one in the upstream index.
115 for (let index = template.states.length - 1; index >= 0; index--) {
116 const state = template.states[index];
117 if (state && state.char.toLowerCase() === lower) {
118 return state;
119 }
120 }
121
122 // Fallback for unmapped bracket character
123 return {
124 char,
125 id: 'unknown',
126 label: `Custom [${char}]`,
127 icon: 'circle-small-filled',
128 strikethrough: false,
129 countsAsCompleted: false,
130 nextState: template.defaultState,
131 };
132}
133
134/**
135 * Returns the state a task moves to when toggled.
136 *
137 * @param template Active state template.
138 * @param char Character currently inside the checklist brackets.
139 */
140export function getNextState(template: StateTemplate, char: string): StateDefinition {
141 const currentState = getState(template, char);
142 const nextChar = currentState.nextState ?? template.defaultState;
143 return getState(template, nextChar);
144}
145
146/**
147 * Checks that a parsed custom template is usable and fills its optional
148 * top-level fields.
149 *
150 * @param obj Value parsed from a `*.jsonc` template file.
151 * @returns The template, or `null` when it has no usable `states`.
152 */
153export function validateTemplate(obj: unknown): StateTemplate | null {
154 if (typeof obj !== 'object' || obj === null) return null;
155
156 const candidate = obj as Partial<StateTemplate>;
157 if (!Array.isArray(candidate.states) || candidate.states.length === 0) return null;
158
159 for (const state of candidate.states as unknown[]) {
160 if (typeof state !== 'object' || state === null) return null;
161 const { char, id, label } = state as Partial<StateDefinition>;
162 // One character per state: a toggle writes exactly one character between the brackets.
163 if (typeof char !== 'string' || char.length !== 1) return null;
164 if (typeof id !== 'string' || typeof label !== 'string') return null;
165 }
166
167 return {
168 name: typeof candidate.name === 'string' ? candidate.name : 'Custom',
169 description: typeof candidate.description === 'string' ? candidate.description : '',
170 defaultState: typeof candidate.defaultState === 'string' ? candidate.defaultState : ' ',
171 states: candidate.states,
172 };
173}
174
175/**
176 * Parses JSON with comments: strips `//` and block comments and trailing
177 * commas outside strings, then hands the rest to `JSON.parse`.
178 *
179 * @param text Contents of a `*.jsonc` file.
180 * @throws SyntaxError when what remains is not valid JSON.
181 */
182export function parseJsonc(text: string): unknown {
183 let out = '';
184 // A comma is held back, with the whitespace after it, until the next token shows whether it trails.
185 let heldComma = '';
186 let index = text.charCodeAt(0) === 0xfeff ? 1 : 0;
187
188 while (index < text.length) {
189 const ch = text[index] ?? '';
190 const next = text[index + 1] ?? '';
191
192 if (ch === '/' && next === '/') {
193 while (index < text.length && text[index] !== '\n') index++;
194 continue;
195 }
196
197 if (ch === '/' && next === '*') {
198 const end = text.indexOf('*/', index + 2);
199 index = end === -1 ? text.length : end + 2;
200 continue;
201 }
202
203 if (heldComma) {
204 if (/\s/.test(ch)) {
205 heldComma += ch;
206 index++;
207 continue;
208 }
209 out += ch === '}' || ch === ']' ? heldComma.slice(1) : heldComma;
210 heldComma = '';
211 }
212
213 if (ch === ',') {
214 heldComma = ch;
215 index++;
216 continue;
217 }
218
219 if (ch === '"') {
220 const start = index;
221 index++;
222 while (index < text.length && text[index] !== '"') {
223 index += text[index] === '\\' ? 2 : 1;
224 }
225 index++;
226 out += text.slice(start, index);
227 continue;
228 }
229
230 out += ch;
231 index++;
232 }
233
234 return JSON.parse(out + heldComma);
235}
236core/types.ts 65 lines1export interface BracketRange {
2 line: number; // Zero-indexed line in document
3 openBracketCol: number; // Column index of '['
4 charCol: number; // Column index of the character inside '[ ]'
5 closeBracketCol: number; // Column index of ']'
6}
7
8export interface StateDefinition {
9 char: string;
10 id: string;
11 label: string;
12 icon: string;
13 color?: string;
14 strikethrough?: boolean;
15 countsAsCompleted?: boolean;
16 nextState?: string;
17}
18
19export interface TaskStats {
20 totalCountable: number;
21 completedCount: number;
22 inProgressCount: number;
23 cancelledCount: number;
24}
25
26export interface TaskNode {
27 type: 'task';
28 id: string;
29 filePath: string;
30 rawText: string;
31 cleanText: string;
32 char: string;
33 bracketRange: BracketRange;
34 line: number;
35 indentation: number;
36 subTasks: TaskNode[];
37 isCompleted: boolean;
38 parentHeadingId?: string;
39}
40
41export interface HeadingNode {
42 type: 'heading';
43 id: string;
44 filePath: string;
45 label: string;
46 level: number;
47 line: number;
48 children: HeadingNode[];
49 tasks: TaskNode[];
50 stats: TaskStats;
51}
52
53export interface SpecGroup {
54 type: 'specGroup';
55 id: string;
56 name: string;
57 folderPath: string;
58 taskFilePath: string;
59 headings: HeadingNode[];
60 rootTasks: TaskNode[];
61 stats: TaskStats;
62}
63
64export type TaskTreeNode = SpecGroup | HeadingNode | TaskNode;
65hooks/rows.ts 182 lines1import { isMilestoneHeading } from '../core/milestones';
2import type { StateTemplate } from '../core/templates';
3import { getState } from '../core/templates';
4import type { HeadingNode, SpecGroup, StateDefinition, TaskNode, TaskStats } from '../core/types';
5import type { Settings } from './settings';
6
7interface RowBase {
8 /** Element key: short and free of path characters, unlike `id`. */
9 key: string;
10 /** The node's id from the parse, which the collapsed list is kept by. */
11 id: string;
12 /** Nesting depth; the pane indents two columns per level. */
13 depth: number;
14}
15
16export interface GroupRow extends RowBase {
17 kind: 'group';
18 title: string;
19 count: string;
20 isCollapsed: boolean;
21}
22
23export interface HeadingRow extends RowBase {
24 kind: 'heading';
25 label: string;
26 line: number;
27 filePath: string;
28 count: string;
29 isCollapsed: boolean;
30 isMilestone: boolean;
31 /** True when the heading has tasks and all of them are completed. */
32 isComplete: boolean;
33}
34
35export interface TaskRow extends RowBase {
36 kind: 'task';
37 task: TaskNode;
38 state: StateDefinition;
39 glyph: string;
40 /** A theme key for the glyph, or undefined for the default text colour. */
41 color: string | undefined;
42 /** True when the label is drawn struck through and dimmed. */
43 isStruck: boolean;
44}
45
46export type Row = GroupRow | HeadingRow | TaskRow;
47
48export interface RowOptions {
49 collapsed: ReadonlySet<string>;
50 hideCompleted: boolean;
51 template: StateTemplate;
52 settings: Pick<Settings, 'showProgressCount' | 'useH1AsGroupName' | 'milestoneKeywords' | 'strikeThroughCompleted'>;
53}
54
55// The extension names VS Code codicons and theme colours; the pane draws text, so each maps to a glyph and a theme key.
56const GLYPH_BY_ICON: Record<string, string> = {
57 'circle-large-outline': '○',
58 sync: '◐',
59 'pass-filled': '●',
60 'circle-slash': '⊘',
61 question: '?',
62 warning: '!',
63 'circle-small-filled': '•',
64};
65
66const THEME_KEY_BY_COLOR: Record<string, string> = {
67 'charts.green': 'success',
68 'charts.yellow': 'warning',
69 'charts.red': 'error',
70 'charts.purple': 'permission',
71 'charts.blue': 'suggestion',
72 'charts.orange': 'warning',
73};
74
75/**
76 * Flattens the parsed groups into the rows the pane draws, top to bottom,
77 * leaving out what a collapsed parent or the "hide completed" filter hides.
78 *
79 * Filtering never changes a count: counts come from the parse.
80 */
81export function buildRows(groups: readonly SpecGroup[], options: RowOptions): Row[] {
82 const rows: Row[] = [];
83
84 groups.forEach((group, groupIndex) => {
85 const isCollapsed = options.collapsed.has(group.id);
86 rows.push({
87 kind: 'group',
88 key: `g-${groupIndex}`,
89 id: group.id,
90 depth: 0,
91 title: groupTitle(group, options),
92 count: countText(group.stats, options, true),
93 isCollapsed,
94 });
95 if (isCollapsed) return;
96
97 // Top-level headings always show, as in the extension; only nested ones are filtered.
98 for (const heading of group.headings) {
99 addHeading(rows, heading, groupIndex, 1, options);
100 }
101 addTasks(rows, group.rootTasks, groupIndex, 1, options);
102 });
103
104 return rows;
105}
106
107/** Ids of everything that can collapse: every spec group and heading. */
108export function collapsibleIds(groups: readonly SpecGroup[]): string[] {
109 const ids: string[] = [];
110 const visit = (heading: HeadingNode): void => {
111 ids.push(heading.id);
112 heading.children.forEach(visit);
113 };
114
115 for (const group of groups) {
116 ids.push(group.id);
117 group.headings.forEach(visit);
118 }
119 return ids;
120}
121
122function addHeading(rows: Row[], heading: HeadingNode, groupIndex: number, depth: number, options: RowOptions): void {
123 const { totalCountable, completedCount } = heading.stats;
124 const isComplete = totalCountable > 0 && completedCount === totalCountable;
125 const isCollapsed = options.collapsed.has(heading.id);
126
127 rows.push({
128 kind: 'heading',
129 key: `h-${groupIndex}-${heading.line}`,
130 id: heading.id,
131 depth,
132 label: heading.label,
133 line: heading.line,
134 filePath: heading.filePath,
135 count: countText(heading.stats, options, true),
136 isCollapsed,
137 isMilestone: isMilestoneHeading(heading.label, options.settings.milestoneKeywords),
138 isComplete,
139 });
140 if (isCollapsed) return;
141
142 for (const child of heading.children) {
143 const childDone = child.stats.totalCountable > 0 && child.stats.completedCount === child.stats.totalCountable;
144 if (options.hideCompleted && childDone) continue;
145 addHeading(rows, child, groupIndex, depth + 1, options);
146 }
147 addTasks(rows, heading.tasks, groupIndex, depth + 1, options);
148}
149
150function addTasks(rows: Row[], tasks: TaskNode[], groupIndex: number, depth: number, options: RowOptions): void {
151 for (const task of tasks) {
152 if (options.hideCompleted && task.isCompleted) continue;
153
154 const state = getState(options.template, task.char);
155 rows.push({
156 kind: 'task',
157 key: `t-${groupIndex}-${task.line}`,
158 id: task.id,
159 depth,
160 task,
161 state,
162 glyph: GLYPH_BY_ICON[state.icon] ?? (state.char.trim() || '•'),
163 color: state.color === undefined ? undefined : THEME_KEY_BY_COLOR[state.color],
164 isStruck: state.strikethrough === true && options.settings.strikeThroughCompleted,
165 });
166 addTasks(rows, task.subTasks, groupIndex, depth + 1, options);
167 }
168}
169
170function groupTitle(group: SpecGroup, options: RowOptions): string {
171 if (options.settings.useH1AsGroupName) {
172 const firstH1 = group.headings.find((heading) => heading.level === 1);
173 if (firstH1) return firstH1.label;
174 }
175 return group.name;
176}
177
178function countText(stats: TaskStats, options: RowOptions, saysWhenEmpty: boolean): string {
179 if (stats.totalCountable === 0) return saysWhenEmpty ? '(No tasks)' : '';
180 return options.settings.showProgressCount ? `(${stats.completedCount}/${stats.totalCountable})` : '';
181}
182hooks/settings.ts 53 lines1import type { PluginOptions } from 'claude-code';
2
3export type TemplateName = 'Standard (GFM)' | 'Obsidian Tasks' | 'Custom';
4
5/** The mod's `userConfig` values, typed and with defaults filled in. */
6export interface Settings {
7 specPathPattern: string;
8 taskFileName: string;
9 stateTemplate: TemplateName;
10 customTemplatePath: string;
11 strikeThroughCompleted: boolean;
12 showProgressCount: boolean;
13 hideCompletedByDefault: boolean;
14 useH1AsGroupName: boolean;
15 milestoneKeywords: string[];
16}
17
18/**
19 * Reads the options Claude Code hands `register` into typed settings.
20 *
21 * A value of the wrong type, or an empty path, falls back to the default the
22 * manifest declares, so a hand-edited settings file cannot break the pane.
23 *
24 * @param options The `userConfig` values, as given to `register(on, options)`.
25 */
26export function readSettings(options: PluginOptions): Settings {
27 const text = (key: string, fallback: string): string => {
28 const value = options[key];
29 return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback;
30 };
31 const flag = (key: string, fallback: boolean): boolean => {
32 const value = options[key];
33 return typeof value === 'boolean' ? value : fallback;
34 };
35
36 const template = text('stateTemplate', 'Standard (GFM)');
37
38 return {
39 specPathPattern: text('specPathPattern', 'spec/*'),
40 taskFileName: text('taskFileName', 'tasks.md'),
41 stateTemplate: template === 'Obsidian Tasks' || template === 'Custom' ? template : 'Standard (GFM)',
42 customTemplatePath: text('customTemplatePath', ''),
43 strikeThroughCompleted: flag('strikeThroughCompleted', true),
44 showProgressCount: flag('showProgressCount', true),
45 hideCompletedByDefault: flag('hideCompletedByDefault', false),
46 useH1AsGroupName: flag('useH1AsGroupName', false),
47 milestoneKeywords: text('milestoneKeywords', 'Milestone, Release, Alpha, Beta')
48 .split(',')
49 .map((keyword) => keyword.trim())
50 .filter((keyword) => keyword !== ''),
51 };
52}
53hooks/sync.ts 137 lines1import { parseTasks } from '../core/parse';
2import type { StateTemplate } from '../core/templates';
3import { OBSIDIAN_TEMPLATE, STANDARD_TEMPLATE, parseJsonc, validateTemplate } from '../core/templates';
4import type { SpecGroup } from '../core/types';
5import { discover } from './discover';
6import type { DiscoveredSpec, Disk } from './discover';
7import type { Settings } from './settings';
8
9/** How often the poll looks for new spec folders, in polls. Between those it only stats the known files. */
10const REDISCOVER_EVERY = 10;
11
12/**
13 * What syncing needs from Claude Code. The hooks module builds one per event
14 * as closures over `$`, because `$` itself may not cross an import.
15 */
16export interface Host extends Disk {
17 read: (path: string) => Promise<string>;
18 /** Writes the parsed groups to state, which redraws the pane. */
19 setGroups: (groups: SpecGroup[]) => Promise<unknown>;
20 /** Writes the error line to state; null clears it. */
21 setError: (message: string | null) => Promise<unknown>;
22 toast: (text: string) => void;
23}
24
25/**
26 * What the module keeps between events. These are module variables, so a hot
27 * reload starts them over; everything a drawing reads is in `$.state` instead.
28 */
29export interface Session {
30 settings: Settings;
31 template: StateTemplate;
32 /** The task files as last read, with the modification times they had then. */
33 specs: DiscoveredSpec[];
34 polls: number;
35 isPolling: boolean;
36 /** The error last written to state, so an unchanged one does not redraw the pane. */
37 shownError: string | null | undefined;
38}
39
40export function createSession(settings: Settings): Session {
41 return { settings, template: STANDARD_TEMPLATE, specs: [], polls: 0, isPolling: false, shownError: undefined };
42}
43
44/**
45 * Picks the session's state template from the settings, reading a custom one
46 * from disk. An unreadable or invalid custom template falls back to Standard.
47 */
48export async function loadTemplate(host: Host, session: Session): Promise<void> {
49 const { stateTemplate, customTemplatePath } = session.settings;
50 session.template = STANDARD_TEMPLATE;
51
52 if (stateTemplate === 'Obsidian Tasks') {
53 session.template = OBSIDIAN_TEMPLATE;
54 } else if (stateTemplate === 'Custom' && customTemplatePath !== '') {
55 const custom = await host
56 .read(customTemplatePath)
57 .then((text) => validateTemplate(parseJsonc(text)))
58 .catch(() => null);
59 if (custom) {
60 session.template = custom;
61 } else {
62 host.toast('Markdown Tasks: invalid custom template, using Standard (GFM)');
63 }
64 }
65}
66
67/**
68 * Re-reads every task file and hands the parsed groups to the host. On a read
69 * or parse error the previous groups stay and the error is shown instead.
70 */
71export async function refresh(host: Host, session: Session): Promise<void> {
72 try {
73 const specs = await discover(host, session.settings);
74 const parsed: SpecGroup[] = [];
75 for (const spec of specs) {
76 const content = await host.read(spec.taskFilePath);
77 parsed.push(parseTasks(content, spec.name, spec.taskFilePath, spec.folderPath, session.template));
78 }
79
80 session.specs = specs;
81 await host.setGroups(parsed);
82 await showError(host, session, null);
83 } catch (failure) {
84 await showError(host, session, `Could not read the task files: ${describe(failure)}`);
85 }
86}
87
88/**
89 * One tick of the live sync: refreshes only when a known task file's
90 * modification time changed, or, every tenth tick, when discovery finds a
91 * different set of spec folders. The mods API has no file watcher.
92 */
93export async function poll(host: Host, session: Session): Promise<void> {
94 // A slow disk must not stack ticks up behind one another.
95 if (session.isPolling) return;
96 session.isPolling = true;
97
98 try {
99 session.polls += 1;
100 const current =
101 session.polls % REDISCOVER_EVERY === 0 ? await discover(host, session.settings) : await restat(host, session.specs);
102 if (signature(current) !== signature(session.specs)) {
103 await refresh(host, session);
104 }
105 } catch (failure) {
106 await showError(host, session, `Could not check the task files: ${describe(failure)}`);
107 } finally {
108 session.isPolling = false;
109 }
110}
111
112/** The known task files with their modification times now; a file that has gone is left out. */
113async function restat(disk: Disk, specs: DiscoveredSpec[]): Promise<DiscoveredSpec[]> {
114 const current: DiscoveredSpec[] = [];
115 for (const spec of specs) {
116 const stat = await disk.stat(spec.taskFilePath).catch(() => undefined);
117 if (stat?.kind === 'file') {
118 current.push({ ...spec, mtimeMs: stat.mtimeMs });
119 }
120 }
121 return current;
122}
123
124function signature(specs: DiscoveredSpec[]): string {
125 return specs.map((spec) => `${spec.taskFilePath}@${spec.mtimeMs}`).join('|');
126}
127
128async function showError(host: Host, session: Session, message: string | null): Promise<void> {
129 if (session.shownError === message) return;
130 session.shownError = message;
131 await host.setError(message);
132}
133
134function describe(failure: unknown): string {
135 return failure instanceof Error ? failure.message : String(failure);
136}
137core/milestones.ts 19 lines1/**
2 * Whether a heading counts as a milestone: its label holds one of the
3 * keywords as a whole word, in any case, optionally pluralised.
4 *
5 * @param label Heading label, markdown formatting already stripped.
6 * @param keywords Configured milestone keywords.
7 */
8export function isMilestoneHeading(label: string, keywords: string[]): boolean {
9 if (!label || !keywords || keywords.length === 0) return false;
10 const normalizedLabel = label.trim().toLowerCase();
11 return keywords.some((kw) => {
12 const trimmed = kw.trim().toLowerCase();
13 if (!trimmed) return false;
14 const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15 const pattern = new RegExp(`(^|[^a-zA-Z0-9])${escaped}(?:s|es)?([^a-zA-Z0-9]|$)`, 'i');
16 return pattern.test(normalizedLabel);
17 });
18}
19core/parse.ts 281 lines1import type { BracketRange, HeadingNode, SpecGroup, TaskNode, TaskStats } from './types';
2import type { StateTemplate } from './templates';
3import { getState } from './templates';
4
5// Regular expressions for ATX headings and checklist items
6const HEADING_REGEX = /^(\#{1,6})\s+(.+)$/;
7const TASK_REGEX = /^(\s*)[-*+]\s+\[(.*?)\]\s*(.*)$/;
8
9/**
10 * Parses markdown text into a structured SpecGroup model.
11 *
12 * @param content Full text content of the markdown file.
13 * @param specName Name of the spec folder/group.
14 * @param filePath Path of the task markdown file.
15 * @param folderPath Path of the parent spec folder.
16 * @param template State template that decides which bracket characters count as completed.
17 */
18export function parseTasks(
19 content: string,
20 specName: string,
21 filePath: string,
22 folderPath: string,
23 template: StateTemplate
24): SpecGroup {
25 const lines = content.split(/\r?\n/);
26 const rootTasks: TaskNode[] = [];
27 const headings: HeadingNode[] = [];
28
29 // Heading stack to manage depth hierarchy (levels 1-6)
30 const headingStack: HeadingNode[] = [];
31
32 // Task stack within the current heading to manage indented sub-tasks
33 let taskStack: TaskNode[] = [];
34 let currentHeading: HeadingNode | undefined = undefined;
35
36 const addTask = (lineIndex: number, lineStr: string, indentLevel: number, char: string, text: string): void => {
37 const cleanText = stripMarkdownFormatting(text.trim());
38
39 // Calculate bracket range columns
40 const openBracketCol = lineStr.indexOf('[');
41 const closeBracketCol = lineStr.indexOf(']', openBracketCol);
42 const bracketRange: BracketRange = {
43 line: lineIndex,
44 openBracketCol,
45 charCol: openBracketCol + 1,
46 closeBracketCol,
47 };
48
49 const stateDef = getState(template, char);
50 const isCompleted = stateDef.countsAsCompleted ?? (char.toLowerCase() === 'x');
51
52 const taskNode: TaskNode = {
53 type: 'task',
54 id: `${filePath}#T${lineIndex}`,
55 filePath,
56 rawText: lineStr,
57 cleanText: cleanText || '(Empty task)',
58 char,
59 bracketRange,
60 line: lineIndex,
61 indentation: indentLevel,
62 subTasks: [],
63 isCompleted,
64 parentHeadingId: currentHeading?.id,
65 };
66
67 // Find parent task in taskStack with lower indentation
68 while ((taskStack.at(-1)?.indentation ?? -Infinity) >= indentLevel) {
69 taskStack.pop();
70 }
71
72 const parentTask = taskStack.at(-1);
73 if (parentTask) {
74 // Indented subtask
75 parentTask.subTasks.push(taskNode);
76 } else {
77 // Top-level task in current heading scope
78 attachTaskToScope(taskNode, currentHeading, rootTasks);
79 }
80
81 taskStack.push(taskNode);
82 };
83
84 for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
85 const lineStr = lines[lineIndex] ?? '';
86
87 // 1. Check for ATX Heading (# ... ######)
88 const headingMatch = lineStr.match(HEADING_REGEX);
89 if (headingMatch) {
90 const level = (headingMatch[1] ?? '').length;
91 const headingText = (headingMatch[2] ?? '').trim();
92 const label = stripMarkdownFormatting(headingText);
93
94 // Pop headingStack until we find a parent with a strictly lower level
95 while ((headingStack.at(-1)?.level ?? 0) >= level) {
96 headingStack.pop();
97 }
98
99 // Heading written as a checklist item (### - [x] Task 1.1) is a task, not a section
100 const headingTaskMatch = headingText.match(TASK_REGEX);
101 if (headingTaskMatch) {
102 currentHeading = headingStack.at(-1);
103 // Negative indentation: list items below nest under it, deeper heading tasks nest under shallower ones
104 addTask(lineIndex, lineStr, level - 7, headingTaskMatch[2] ?? '', headingTaskMatch[3] ?? '');
105 continue;
106 }
107
108 const newHeading: HeadingNode = {
109 type: 'heading',
110 id: `${filePath}#H${lineIndex}_L${level}`,
111 filePath,
112 label,
113 level,
114 line: lineIndex,
115 children: [],
116 tasks: [],
117 stats: { totalCountable: 0, completedCount: 0, inProgressCount: 0, cancelledCount: 0 },
118 };
119
120 // Reset task stack for the new heading
121 taskStack = [];
122 currentHeading = newHeading;
123
124 const parentHeading = headingStack.at(-1);
125 if (parentHeading) {
126 // Child heading of the top of the stack
127 parentHeading.children.push(newHeading);
128 } else {
129 // Top-level heading within this spec document
130 headings.push(newHeading);
131 }
132
133 headingStack.push(newHeading);
134 continue;
135 }
136
137 // 2. Check for Checklist Item (- [ ] ...)
138 const taskMatch = lineStr.match(TASK_REGEX);
139 if (taskMatch) {
140 const indentLevel = calculateIndentation(taskMatch[1] ?? '');
141 addTask(lineIndex, lineStr, indentLevel, taskMatch[2] ?? '', taskMatch[3] ?? '');
142 }
143 }
144
145 // Calculate recursive statistics
146 const stats = calculateAggregateStats(rootTasks, headings, template);
147
148 return {
149 type: 'specGroup',
150 id: filePath,
151 name: specName,
152 folderPath,
153 taskFilePath: filePath,
154 headings,
155 rootTasks,
156 stats,
157 };
158}
159
160function calculateIndentation(indentStr: string): number {
161 let count = 0;
162 for (const ch of indentStr) {
163 if (ch === '\t') {
164 count += 2;
165 } else {
166 count += 1;
167 }
168 }
169 return count;
170}
171
172function attachTaskToScope(
173 task: TaskNode,
174 heading: HeadingNode | undefined,
175 rootTasks: TaskNode[]
176): void {
177 if (heading) {
178 heading.tasks.push(task);
179 } else {
180 rootTasks.push(task);
181 }
182}
183
184function calculateAggregateStats(
185 rootTasks: TaskNode[],
186 headings: HeadingNode[],
187 template: StateTemplate
188): TaskStats {
189 const totalStats: TaskStats = {
190 totalCountable: 0,
191 completedCount: 0,
192 inProgressCount: 0,
193 cancelledCount: 0,
194 };
195
196 // Count root tasks
197 const rootTaskStats = computeTasksStats(rootTasks, template);
198 addStats(totalStats, rootTaskStats);
199
200 // Count heading trees recursively
201 for (const heading of headings) {
202 const headingStats = computeHeadingStats(heading, template);
203 addStats(totalStats, headingStats);
204 }
205
206 return totalStats;
207}
208
209function computeHeadingStats(heading: HeadingNode, template: StateTemplate): TaskStats {
210 const headingStats = computeTasksStats(heading.tasks, template);
211
212 for (const child of heading.children) {
213 const childStats = computeHeadingStats(child, template);
214 addStats(headingStats, childStats);
215 }
216
217 heading.stats = { ...headingStats };
218 return headingStats;
219}
220
221function computeTasksStats(tasks: TaskNode[], template: StateTemplate): TaskStats {
222 const stats: TaskStats = {
223 totalCountable: 0,
224 completedCount: 0,
225 inProgressCount: 0,
226 cancelledCount: 0,
227 };
228
229 for (const task of tasks) {
230 stats.totalCountable++;
231 const state = getState(template, task.char);
232 if (state.countsAsCompleted) {
233 stats.completedCount++;
234 } else if (state.id === 'in_progress') {
235 stats.inProgressCount++;
236 } else if (state.id === 'cancelled') {
237 stats.cancelledCount++;
238 }
239
240 if (task.subTasks.length > 0) {
241 const subStats = computeTasksStats(task.subTasks, template);
242 addStats(stats, subStats);
243 }
244 }
245
246 return stats;
247}
248
249function addStats(target: TaskStats, source: TaskStats): void {
250 target.totalCountable += source.totalCountable;
251 target.completedCount += source.completedCount;
252 target.inProgressCount += source.inProgressCount;
253 target.cancelledCount += source.cancelledCount;
254}
255
256/**
257 * Strips bold and italic markdown delimiters from text (e.g. `**text**`, `*text*`, `__text__`, `_text_`).
258 * Preserves identifiers containing underscores (snake_case) and math operators.
259 */
260export function stripMarkdownFormatting(text: string): string {
261 if (!text) return '';
262
263 let clean = text;
264
265 // 1. Triple delimiters: ***text*** or ___text___ (bold + italic)
266 clean = clean.replace(/\*\*\*([^\*\s](?:.*?[^\*\s])?)\*\*\*/g, '$1');
267 clean = clean.replace(/(?:^|(?<=[\s\p{P}\p{S}]))___([^_\s](?:.*?[^_\s])?)___(?=$|[\s\p{P}\p{S}])/gu, '$1');
268
269 // 2. Double delimiters: **text** or __text__ (bold)
270 clean = clean.replace(/\*\*([^\*\s](?:.*?[^\*\s])?)\*\*/g, '$1');
271 clean = clean.replace(/(?:^|(?<=[\s\p{P}\p{S}]))__([^_\s](?:.*?[^_\s])?)__(?=$|[\s\p{P}\p{S}])/gu, '$1');
272
273 // 3. Single delimiter: *text* (italic with asterisks)
274 clean = clean.replace(/(?<!\*)\*([^\*\s](?:.*?[^\*\s])?)\*(?!\*)/g, '$1');
275
276 // 4. Single delimiter: _text_ (italic with underscores - CommonMark word boundary rules)
277 clean = clean.replace(/(?:^|(?<=[\s\p{P}\p{S}]))_([^_\s](?:.*?[^_\s])?)_(?=$|[\s\p{P}\p{S}])/gu, '$1');
278
279 return clean.trim();
280}
281hooks/discover.ts 95 lines1import type { FsEntry, FsStat } from 'claude-code';
2
3import type { Settings } from './settings';
4
5/**
6 * The file calls discovery needs. Claude Code only follows `$` inside the
7 * hooks module's own file, so the module hands these in as closures over
8 * `$.fs` rather than passing `$` itself.
9 */
10export interface Disk {
11 list: (path: string) => Promise<FsEntry[]>;
12 stat: (path: string) => Promise<FsStat>;
13}
14
15/** A spec folder that holds a task file. Paths are relative to the working directory, with `/` separators. */
16export interface DiscoveredSpec {
17 name: string;
18 folderPath: string;
19 taskFilePath: string;
20 mtimeMs: number;
21}
22
23const SKIPPED_FOLDERS = new Set(['node_modules', 'dist', '.git']);
24
25/**
26 * Finds every spec folder that holds a task file.
27 *
28 * `$.fs` has no glob and `list` does not recurse, so the pattern is expanded
29 * one path segment at a time: a segment with `*` lists the folders found so
30 * far and keeps the names it matches, any other segment is appended as is.
31 *
32 * @param disk Lists a folder and stats a path.
33 * @param settings Supplies `specPathPattern` and `taskFileName`.
34 * @returns The spec folders, sorted by name.
35 */
36export async function discover(
37 disk: Disk,
38 settings: Pick<Settings, 'specPathPattern' | 'taskFileName'>
39): Promise<DiscoveredSpec[]> {
40 const segments = settings.specPathPattern
41 .replace(/\\/g, '/')
42 .split('/')
43 .filter((segment) => segment !== '' && segment !== '.');
44
45 let folders = ['.'];
46 for (const segment of segments) {
47 if (!segment.includes('*')) {
48 folders = folders.map((folder) => join(folder, segment));
49 continue;
50 }
51
52 const matches = segmentMatcher(segment);
53 const expanded: string[] = [];
54 for (const folder of folders) {
55 // A folder named by the pattern but absent on disk is not an error: it holds no specs.
56 const entries = await disk.list(folder).catch(() => []);
57 for (const entry of entries) {
58 if (entry.kind === 'dir' && !SKIPPED_FOLDERS.has(entry.name) && matches(entry.name)) {
59 expanded.push(join(folder, entry.name));
60 }
61 }
62 }
63 folders = expanded;
64 }
65
66 const specs: DiscoveredSpec[] = [];
67 for (const folder of folders) {
68 const taskFilePath = join(folder, settings.taskFileName);
69 const stat = await disk.stat(taskFilePath).catch(() => undefined);
70 if (stat?.kind === 'file') {
71 specs.push({ name: baseName(folder), folderPath: folder, taskFilePath, mtimeMs: stat.mtimeMs });
72 }
73 }
74
75 return specs.sort((a, b) => a.name.localeCompare(b.name));
76}
77
78/** Turns one pattern segment into a whole-name test; any run of `*` matches any characters. */
79function segmentMatcher(segment: string): (name: string) => boolean {
80 const source = segment
81 .split(/\*+/)
82 .map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
83 .join('.*');
84 const pattern = new RegExp(`^${source}$`, 'i');
85 return (name) => pattern.test(name);
86}
87
88function join(folder: string, name: string): string {
89 return folder === '.' ? name : `${folder}/${name}`;
90}
91
92function baseName(folder: string): string {
93 return folder.split('/').at(-1) ?? folder;
94}
95types/index.d.ts 72 lines1// The mod's state contract. Claude Code requires this file to be
2// self-contained (no imports), so it repeats the task tree types of
3// core/types.ts; hooks/state.ts fails to compile if the two drift apart.
4
5export interface BracketRange {
6 line: number;
7 openBracketCol: number;
8 charCol: number;
9 closeBracketCol: number;
10}
11
12export interface TaskStats {
13 totalCountable: number;
14 completedCount: number;
15 inProgressCount: number;
16 cancelledCount: number;
17}
18
19export interface TaskNode {
20 type: 'task';
21 id: string;
22 filePath: string;
23 rawText: string;
24 cleanText: string;
25 char: string;
26 bracketRange: BracketRange;
27 line: number;
28 indentation: number;
29 subTasks: TaskNode[];
30 isCompleted: boolean;
31 parentHeadingId?: string;
32}
33
34export interface HeadingNode {
35 type: 'heading';
36 id: string;
37 filePath: string;
38 label: string;
39 level: number;
40 line: number;
41 children: HeadingNode[];
42 tasks: TaskNode[];
43 stats: TaskStats;
44}
45
46export interface SpecGroup {
47 type: 'specGroup';
48 id: string;
49 name: string;
50 folderPath: string;
51 taskFilePath: string;
52 headings: HeadingNode[];
53 rootTasks: TaskNode[];
54 stats: TaskStats;
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'md-taskview': {
60 /** One parsed task file per spec folder, in the order drawn. */
61 groups: SpecGroup[];
62 /** Ids of the collapsed spec groups and headings. */
63 collapsed: string[];
64 hideCompleted: boolean;
65 /** Why the last refresh failed, or null when it did not. */
66 error: string | null;
67 /** Element key of the task row holding the focus ring, for the state picker. */
68 focused: string | null;
69 };
70 }
71}
72