Progress bar above the prompt for the current session's task: follows the todo list (TodoWrite, TaskCreate/TaskUpdate) and the plan the model reports through…

Marketplace модов Claude Code.
| Мод | Что делает |
|---|---|
turn-stats | Полоса над полем ввода: время работы сессии, токены (включая субагентов), квота |
pr-merge | Полоса с PR текущей ветки и кнопкой Merge через gh (настройки: mergeMethod, deleteBranch) |
session-progress | Прогресс-бар задачи сессии по todo-списку и плану модели (настройка: language) |
copy-session-link | Кнопка «🔗 Ссылка» в футере поля ввода Claude Code Desktop: копирует ссылку claude:// на текущую сессию |
visual-answer-improve | Итоговые секции ответа в цветных рамках с вкладкой-меткой: «Требуется решение» (оранжевая), «Сделано» (зелёная), «Не сделано» (мягкая красная). Правило про секции прикладывается к промптам пользователя (настройки: decisionColor, doneColor, notDoneColor) |
image-library | Панель «Библиотека» — галерея изображений (png/jpg/webp/gif/svg) из папки проекта (без папок сборки и зависимостей: node_modules, .git, Library, bin, dist, build и т. п.), папок сессий Claude Code этого проекта и Codex (~/.codex/generated_images): миниатюры, фильтр по источнику и сессии, сортировка по дате, постраничная навигация; «🔍» или имя — просмотр на всю панель с листанием, «📋 путь» — полный путь к файлу в буфер обмена, «↗» — открыть в системном приложении. Открывается кнопкой «🖼 Библиотека» в футере поля ввода или командой /library. Только Windows: сканирование и миниатюры через PowerShell и System.Drawing, кэш в %LOCALAPPDATA%\claude-image-library (настройки: includeCodex, scanLimit) |
/plugin marketplace add nerevar48/nerevar-mods
/plugin install turn-stats@nerevar-mods
/plugin install pr-merge@nerevar-mods
/plugin install session-progress@nerevar-mods
/plugin install copy-session-link@nerevar-mods
/plugin install visual-answer-improve@nerevar-mods
/plugin install image-library@nerevar-mods
Или в ~/.claude/settings.json:
"extraKnownMarketplaces": {
"nerevar-mods": { "source": { "source": "github", "repo": "nerevar48/nerevar-mods" } }
},
"enabledPlugins": {
"turn-stats@nerevar-mods": true,
"pr-merge@nerevar-mods": true,
"session-progress@nerevar-mods": true,
"copy-session-link@nerevar-mods": true,
"visual-answer-improve@nerevar-mods": true,
"image-library@nerevar-mods": true
}hooks/register.tsx 432 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Plan, TaskItem } from '../types'
5
6const plan = atom({ plugin: 'session-progress', key: 'plan' } as const, null)
7const tasks = atom({ plugin: 'session-progress', key: 'tasks' } as const, [])
8
9// The band is shared, and the engine chains mods in no fixed order: each mod
10// files its section under a rank (time 10, PR 20, progress 30), the sections
11// beneath come up as one column, and the column is kept sorted by rank, the
12// rest (core's own drawing) last.
13const COLUMN = 'band-sections'
14const SECTION = 'band-section:'
15const RANK = 30
16
17function rankOf(node: RenderNode): number {
18 const key = typeof node === 'object' && node.type === 'Box' ? node.props?.key : undefined
19 const rank = typeof key === 'string' && key.startsWith(SECTION) ? Number(key.slice(SECTION.length)) : NaN
20 return Number.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER
21}
22
23function stacked(below: RenderElement, section: RenderElement): RenderElement {
24 const rest = below.type === 'Box' && below.props?.key === COLUMN ? (below.children ?? []) : [below]
25 const children = [section, ...rest].sort((a, b) => rankOf(a) - rankOf(b))
26 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children }
27}
28
29const TOOL = 'mcp__session-progress__progress'
30
31const RULE =
32 '# Progress bar\n' +
33 `The user follows the task through a progress bar above the prompt, drawn by the \`${TOOL}\` tool. ` +
34 'When a request takes more than a couple of tool calls (investigating, editing several files, running checks, committing), ' +
35 'call it before the work starts, with a short title and the planned steps; call it again as each step finishes, ' +
36 'and once more with finished: true when the task is done. ' +
37 'A routine-looking task still gets the bar; only a direct answer or a single edit goes without it. ' +
38 'When TodoWrite or TaskCreate/TaskUpdate already track the work, the bar follows that list instead.'
39
40// Prompts that report on work rather than ask for it. A denylist: the desktop's
41// own prompts may arrive unstamped, and an engine without origins stamps none.
42const REPORTS = new Set<PromptOrigin['kind']>(['task-notification', 'peer-send-message', 'observer', 'observer-activity'])
43
44const asksForWork = (origin: PromptOrigin | undefined): boolean =>
45 !origin || (origin.kind === 'plugin' ? origin.asUser === true : !REPORTS.has(origin.kind))
46
47const ACCENT = '#8f8cf4'
48const DONE = '#5fbf8f'
49
50type ProgressInput = {
51 title?: string
52 steps?: string[]
53 done?: number
54 current?: string
55 finished?: boolean
56}
57
58type TodoInput = { todos?: { content?: string; status?: string; activeForm?: string }[] }
59
60// ---------------------------------------------------------------------------
61// Language: the `language` option, else Claude Code's `language` setting, else
62// the process locale; English when nothing says Russian.
63
64type Lang = 'en' | 'ru'
65
66const STRINGS = {
67 en: { title: 'Current task', done: 'Done', planning: 'Plan' },
68 ru: { title: 'Текущая задача', done: 'Готово', planning: 'План' },
69} as const
70
71// Module scope is fine here: session.start sets it again on every (re)load.
72let lang: Lang = 'en'
73const tr = () => STRINGS[lang]
74
75const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
76
77async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
78 if (option === 'en' || option === 'ru') return option
79 try {
80 const settings = (await $.settings.read()) as Record<string, unknown>
81 if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
82 } catch {
83 // No settings: fall through to the locale.
84 }
85 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
86 return isRussian(locale) ? 'ru' : 'en'
87}
88
89// ---------------------------------------------------------------------------
90// The plan, from whichever source spoke last.
91
92const clean = (s: unknown): string => (typeof s === 'string' ? s.trim() : '')
93
94const blank = (): Plan => ({ title: tr().title, steps: [], done: 0, total: 0, current: '', isFinished: false, source: 'tool' })
95
96// A todo list or the task list keeps the title of the plan it continues.
97const titleFor = (prev: Plan | null): string => (prev && !prev.isFinished ? prev.title : tr().title)
98
99const fromTool = (prev: Plan | null, input: ProgressInput): Plan => {
100 const title = clean(input.title)
101 const isNew = !prev || prev.isFinished || (title !== '' && title !== prev.title)
102 const base = isNew || !prev ? blank() : prev
103 const steps = input.steps?.map(clean).filter(Boolean) ?? base.steps
104 const total = input.steps ? steps.length : base.total
105 const done = Math.min(total, Math.max(0, Math.round(input.done ?? base.done)))
106 // Moving `done` without naming the step points `current` at the next planned one.
107 const current =
108 input.current !== undefined ? clean(input.current) : done !== base.done ? steps[done] ?? '' : base.current
109 const isFinished = input.finished === true
110 return {
111 title: title || base.title,
112 steps,
113 total,
114 done: isFinished ? total : done,
115 current: isFinished ? '' : current,
116 isFinished,
117 source: 'tool',
118 }
119}
120
121const fromList = (
122 prev: Plan | null,
123 items: { title: string; active: string; status: string }[],
124 source: Plan['source'],
125): Plan | null => {
126 if (!items.length) {
127 // An emptied list closes the plan it drew; anything else stays as it is.
128 return prev && prev.source === source && !prev.isFinished ? { ...prev, done: prev.total, current: '', isFinished: true } : prev
129 }
130 const done = items.filter(i => i.status === 'completed').length
131 const active = items.find(i => i.status === 'in_progress')
132 return {
133 title: titleFor(prev),
134 steps: items.map(i => i.title),
135 total: items.length,
136 done,
137 current: active ? active.active || active.title : '',
138 isFinished: done === items.length,
139 source,
140 }
141}
142
143const fromTasks = (prev: Plan | null, list: TaskItem[]): Plan | null =>
144 fromList(prev, list.map(t => ({ title: t.subject, active: t.activeForm, status: t.status })), 'tasks')
145
146const ratio = (p: Plan): number => (p.isFinished ? 1 : p.total ? p.done / p.total : 0)
147
148const label = (p: Plan): string => (p.isFinished ? tr().done : p.total ? `${p.done}/${p.total}` : tr().planning)
149
150// ---------------------------------------------------------------------------
151// The row, drawn as savvy-progress draws its band: one SVG, since the desktop
152// wraps sibling elements onto new lines.
153
154const H = 22
155const BAR_H = 16
156const CRAB_W = 26
157const CELL = 3
158const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
159
160// Deterministic noise so the dither does not shimmer between redraws.
161const noise = (x: number, y: number): number => {
162 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
163 return s - Math.floor(s)
164}
165
166const xml = (s: string): string =>
167 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
168
169// Rough advance of system UI text, in em; good enough to size a text slot.
170const charEm = (ch: string): number =>
171 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
172
173const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
174
175// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
176const fitText = (s: string, size: number, maxW: number): string => {
177 if (textWidth(s, size) <= maxW) return s
178 let out = ''
179 for (const ch of s) {
180 if (textWidth(out + ch + '…', size) > maxW) break
181 out += ch
182 }
183 return out + '…'
184}
185
186// Pixel Clawd: it walks while a turn runs.
187const CLAY = '#D97757'
188const INK = '#1F1E1D'
189
190const CRAB_CSS = `<style>
191.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
192.run .bd{animation:bob .5s steps(1) infinite -.125s}
193.run g{transform-box:fill-box}
194@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
195@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
196</style>`
197
198const crab = (x: number, isWalking: boolean): string => {
199 const r = (cx: number, cy: number, w: number, h: number, c: string) =>
200 `<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`
201 const body = r(7, 10, 16, 12, CLAY) + r(3, 14, 4, 4, CLAY) + r(23, 14, 4, 4, CLAY) + r(9, 12, 2, 2, INK) + r(19, 12, 2, 2, INK)
202 const legsA = r(7, 22, 2, 4, CLAY) + r(17, 22, 2, 4, CLAY)
203 const legsB = r(11, 22, 2, 4, CLAY) + r(21, 22, 2, 4, CLAY)
204 return `<g transform="translate(${x},0) scale(0.8)" shape-rendering="crispEdges"><g${isWalking ? ' class="run"' : ''}><g class="bd">${body}</g><g class="la">${legsA}</g><g class="lb">${legsB}</g></g></g>`
205}
206
207const rowSvg = (p: Plan, W: number, isWorking: boolean): string => {
208 // Title and the step in progress share up to 45% of the row; the bar takes the rest.
209 const title = fitText(p.title, 13, Math.max(60, W * 0.25))
210 const titleW = textWidth(title, 13)
211 const current = p.current ? fitText(p.current, 12, Math.max(0, W * 0.45 - titleW - 14)) : ''
212 const currentX = 16 + titleW + 8
213 const BAR_X = Math.round(current ? currentX + textWidth(current, 12) + 12 : 16 + titleW + 12)
214 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
215 const color = p.isFinished ? DONE : ACCENT
216 const y0 = (H - BAR_H) / 2
217 const fillW = Math.round(BAR_W * ratio(p))
218 // The step in progress: a faint second layer up to its end.
219 const runW = p.total && p.current && !p.isFinished ? Math.round((BAR_W * Math.min(p.total, p.done + 1)) / p.total) : 0
220 const cols = Math.floor(fillW / CELL)
221 const rows = Math.floor(BAR_H / CELL)
222
223 // Dithered fill: sparse at the start, dense toward the head.
224 const dots: string[] = []
225 for (let c = 0; c < cols; c++) {
226 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
227 for (let r = 0; r < rows; r++) {
228 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
229 }
230 }
231 const faint: string[] = []
232 for (let c = cols; c < Math.floor(runW / CELL); c++) {
233 for (let r = 0; r < rows; r++) {
234 if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
235 }
236 }
237 const ticks: string[] = []
238 for (let i = 1; i < p.total; i++) {
239 const x = Math.round((BAR_W * i) / p.total)
240 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
241 }
242
243 const text = label(p)
244 const pillW = Math.round(18 + text.length * 6.6)
245 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
246 const percent = `${Math.round(ratio(p) * 100)}%`
247
248 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
249<style>
250.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
251@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
252/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
253.t0,.t1,.t2,.t3{animation:tw ${p.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
254.t1{animation-duration:${p.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${p.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${p.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
255@keyframes tw{0%,100%{opacity:1}50%{opacity:${p.isFinished ? 0.8 : 0.3}}}
256@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
257</style>
258<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
259<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
260<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
261${current ? `<text class="m" x="${currentX}" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="12">${xml(current)}</text>` : ''}
262<g transform="translate(${BAR_X},${y0})">
263<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
264<g clip-path="url(#c)">
265<g fill="${color}">${dots.join('')}</g>
266<g fill="${color}" opacity="0.45">${faint.join('')}</g>
267<g class="tk">${ticks.join('')}</g>
268</g>
269<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
270<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
271</g>
272<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
273${CRAB_CSS}${crab(W - CRAB_W + 1, isWorking && !p.isFinished)}
274</svg>`
275}
276
277const barText = (p: Plan, width: number): string => {
278 const filled = Math.round(width * ratio(p))
279 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
280}
281
282// ---------------------------------------------------------------------------
283
284export const register: Register = (on, options) => {
285 on('session.start', async ($, e, next) => {
286 const started = await next(e)
287 lang = await detectLang($, options.language)
288 await $.tool.register({
289 name: 'progress',
290 isDeferred: false,
291 // Not deferred: the model reads this description in every session, so it carries the rule.
292 description:
293 'Shows the user how far along the current task is, as a progress bar above the prompt. ' +
294 'When a request takes more than a couple of tool calls, call it once the plan is clear (title, steps), ' +
295 'again as each step finishes (done; current names the step now in progress), and once at the end with finished: true. ' +
296 'If you track the work with TodoWrite or TaskCreate/TaskUpdate, the bar follows that list and this tool is not needed. ' +
297 'Skip it for quick answers and single edits. Fields left out keep their previous value; a new title starts a new plan. ' +
298 'Cheap and silent: it only draws the bar.',
299 inputSchema: {
300 type: 'object',
301 properties: {
302 title: { type: 'string', description: 'Short name of the task, a few words.' },
303 steps: { type: 'array', items: { type: 'string' }, description: 'The planned steps in order, a few words each.' },
304 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
305 current: { type: 'string', description: 'The step now in progress, a few words; defaults to the next planned step.' },
306 finished: { type: 'boolean', description: 'True once the task is complete.' },
307 },
308 },
309 })
310 return started
311 })
312
313 // The tool description alone reads as optional, and the model skips it: each
314 // prompt carries the rule beside it, where the model plans the work.
315 on('prompt.submit', ($, e, next) =>
316 asksForWork(e.origin) ? next({ ...e, context: [...(e.context ?? []), RULE] }) : next(e),
317 )
318
319 // A new main-loop turn hides a finished bar; the next plan starts clean.
320 on('turn.start', async ($, e, next) => {
321 const p = await read($, plan)
322 if (p?.isFinished) {
323 await update($, plan, () => null)
324 await update($, tasks, () => [])
325 }
326 return next(e)
327 })
328
329 on('tool.call', { tool: TOOL }, async ($, e) => {
330 if (e.agentId) return { result: 'ignored: the bar follows the main session only' }
331 const p = await update($, plan, prev => fromTool(prev, e as unknown as ProgressInput))
332 return { result: `ok: ${p ? label(p) : ''}` }
333 })
334
335 // Subagents keep lists of their own: only the main loop's list moves the bar.
336 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
337 const result = await next(e)
338 if (e.agentId || result.deny !== undefined || result.isError) return result
339 const items = ((e as unknown as TodoInput).todos ?? [])
340 .map(t => ({ title: clean(t.content), active: clean(t.activeForm), status: clean(t.status) }))
341 .filter(t => t.title)
342 await update($, plan, prev => fromList(prev, items, 'todo'))
343 return result
344 })
345
346 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
347 const result = await next(e)
348 if (e.agentId || result.deny !== undefined || result.isError) return result
349 const id = clean(result.result?.task?.id)
350 if (!id) return result
351 const list = await update($, tasks, (prev): TaskItem[] => [
352 ...prev.filter(t => t.id !== id),
353 { id, subject: clean(e.subject), activeForm: clean(e.activeForm), status: 'pending' },
354 ])
355 await update($, plan, prev => fromTasks(prev, list))
356 return result
357 })
358
359 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
360 const result = await next(e)
361 if (e.agentId || result.deny !== undefined || result.isError || !result.result?.success) return result
362 const id = clean(e.taskId)
363 const list = await update($, tasks, (prev): TaskItem[] =>
364 e.status === 'deleted'
365 ? prev.filter(t => t.id !== id)
366 : prev.map(t =>
367 t.id !== id
368 ? t
369 : {
370 ...t,
371 subject: clean(e.subject) || t.subject,
372 activeForm: clean(e.activeForm) || t.activeForm,
373 status: e.status && e.status !== 'deleted' ? e.status : t.status,
374 },
375 ),
376 )
377 await update($, plan, prev => fromTasks(prev, list))
378 return result
379 })
380
381 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
382 const p = await read($, plan)
383 if (p === null || e.props.hasSurvey) return next(e)
384
385 const below = await next(e)
386 const ui = $.ui.resolve(e)
387 const { Box, Text, Button } = ui
388 const percent = `${Math.round(ratio(p) * 100)}%`
389 const dismiss = <Button key="session-progress-dismiss" label="✕" plain role="dismiss" onPress={() => update($, plan, () => null)} />
390
391 if (e.surface !== 'terminal' && 'Svg' in ui) {
392 const { Svg } = ui
393 // About 8 CSS px per reported column; the rest is the dismiss and its gap.
394 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 48))
395 const alt = `${p.title}${p.current ? ` · ${p.current}` : ''}: ${label(p)}, ${percent}`
396 return stacked(
397 below,
398 <Box key={`${SECTION}${RANK}`} flexDirection="column">
399 <Box flexDirection="row" alignItems="center" gap={1}>
400 <Svg source={rowSvg(p, width, e.props.isWorking)} alt={alt} width={width} height={H} />
401 {dismiss}
402 </Box>
403 </Box>,
404 )
405 }
406
407 const cols = e.props.bodyColumns
408 const head = p.current ? `${p.title} · ${p.current}` : p.title
409 const titleW = Math.max(8, Math.min(40, head.length + 2, Math.floor(cols / 3)))
410 const width = Math.max(6, Math.min(40, cols - titleW - 24))
411 const color = p.isFinished ? DONE : ACCENT
412 return stacked(
413 below,
414 <Box key={`${SECTION}${RANK}`} flexDirection="column">
415 <Box flexDirection="row" gap={2}>
416 <Box width={titleW} flexShrink={0}>
417 <Text color={color}>● </Text>
418 <Text wrap="truncate-end">
419 {p.title}
420 {p.current ? <Text dimColor> · {p.current}</Text> : ''}
421 </Text>
422 </Box>
423 <Text color={color}>{barText(p, width)}</Text>
424 <Text bold>{label(p)}</Text>
425 <Text dimColor>{percent}</Text>
426 {dismiss}
427 </Box>
428 </Box>,
429 )
430 })
431}
432types/index.d.ts 26 lines1export type PlanSource = 'tool' | 'todo' | 'tasks'
2
3export type Plan = {
4 title: string
5 steps: string[]
6 done: number
7 total: number
8 /** The step in progress, a few words; empty when none is. */
9 current: string
10 isFinished: boolean
11 source: PlanSource
12}
13
14export type TaskItem = {
15 id: string
16 subject: string
17 activeForm: string
18 status: 'pending' | 'in_progress' | 'completed'
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'session-progress': { plan: Plan | null; tasks: TaskItem[] }
24 }
25}
26