SLOPSHOPPER

session-progress

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…

newbandguardprompttool
★ 1v0.2.2no licenseupdated 2026-10-11nerevar48/nerevar-mods/plugins/session-progress
A shopper browsing a rack in a slop shop
README

nerevar-mods

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
}
Source 2 files
hooks/register.tsx 432 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
432
types/index.d.ts 26 lines
1export 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