Рамки с вкладкой-меткой вокруг итоговых секций ответа: «Требуется решение» (оранжевая), «Сделано» (зелёная), «Не сделано» (мягкая красная)

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 170 lines1import type { PromptOrigin, Register } from 'claude-code'
2
3// The closing sections a reply may carry, each drawn in a frame of its color
4// under a filled tab. `ru` and `en` list the headings that open a section,
5// matched case-insensitively; the first is the tab's label in that language.
6type SectionKind = 'done' | 'notDone' | 'decision'
7
8type Titles = [label: string, ...aliases: string[]]
9
10const SECTIONS: Record<SectionKind, { ru: Titles; en: Titles; option: string }> = {
11 done: { ru: ['Сделано'], en: ['Done'], option: 'doneColor' },
12 notDone: { ru: ['Не сделано'], en: ['Not done'], option: 'notDoneColor' },
13 decision: { ru: ['Требуется решение'], en: ['Decision needed'], option: 'decisionColor' },
14}
15
16// On a tab with no fill (its color option cleared) the label keeps the theme's text color.
17const TAB_TEXT = '#000000'
18
19const RULE =
20 '# Closing sections of a reply\n' +
21 "The user's interface draws three marked sections of a reply as colored frames. " +
22 'When a turn ends with a report on the task, close the reply with these sections, in this order, ' +
23 'each opened by its own level-3 heading written exactly as below, leaving out a section with nothing in it:\n' +
24 '- `### Сделано` (in an English reply `### Done`): what was actually done for this task, with its links and statuses (PR, commit, checks).\n' +
25 '- `### Не сделано` (`### Not done`): what was asked or planned and not done, and why.\n' +
26 '- `### Требуется решение` (`### Decision needed`): the points the user has to decide, as a numbered list.\n' +
27 'A reply that only asks the user to decide something carries the decision section alone. ' +
28 'Write nothing after the last section, and use these headings for nothing else.'
29
30// Prompts that report on work rather than ask for it, as in session-progress:
31// a denylist, since the desktop's own prompts may arrive unstamped.
32const REPORTS = new Set<PromptOrigin['kind']>(['task-notification', 'peer-send-message', 'observer', 'observer-activity'])
33
34const fromPerson = (origin: PromptOrigin | undefined): boolean =>
35 !origin || (origin.kind === 'plugin' ? origin.asUser === true : !REPORTS.has(origin.kind))
36
37// ---------------------------------------------------------------------------
38// Splitting a reply's markdown into plain parts and marked sections.
39
40type Segment = { kind: 'text'; text: string } | { kind: SectionKind; label: string; text: string }
41
42const normalize = (s: string): string =>
43 s
44 .replace(/[*_`]/g, '')
45 .replace(/[::.!]+\s*$/, '')
46 .trim()
47 .toLowerCase()
48
49const TITLES = new Map<string, { kind: SectionKind; label: string }>()
50for (const [kind, s] of Object.entries(SECTIONS) as [SectionKind, (typeof SECTIONS)[SectionKind]][])
51 for (const titles of [s.ru, s.en]) for (const t of titles) TITLES.set(normalize(t), { kind, label: titles[0] })
52
53const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
54const BOLD_LINE = /^ {0,3}(\*\*|__)(.+?)\1\s*:?\s*$/
55const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/
56
57// A marked section runs from its heading to the next heading at its level or
58// above, the next marked heading, or the end. Headings inside code fences are text.
59function split(markdown: string): Segment[] {
60 const out: Segment[] = []
61 let kind: SectionKind | 'text' = 'text'
62 let label = ''
63 let level = 0
64 let lines: string[] = []
65 let fence = ''
66
67 const flush = () => {
68 const text = lines.join('\n').replace(/^\s*\n|\s+$/g, '')
69 if (text.trim()) out.push(kind === 'text' ? { kind, text } : { kind, label, text })
70 lines = []
71 }
72
73 for (const line of markdown.split('\n')) {
74 const [, marker = '', info = ''] = FENCE.exec(line) ?? []
75 if (fence || marker) {
76 if (!fence) fence = marker
77 else if (marker[0] === fence[0] && marker.length >= fence.length && !info.trim()) fence = ''
78 lines.push(line)
79 continue
80 }
81 const [, hashes = '', heading = ''] = HEADING.exec(line) ?? []
82 const marked = TITLES.get(normalize(hashes ? heading : (BOLD_LINE.exec(line)?.[2] ?? '')))
83 if (marked) {
84 flush()
85 kind = marked.kind
86 label = marked.label
87 level = hashes ? hashes.length : 7
88 continue
89 }
90 if (hashes && kind !== 'text' && hashes.length <= level) {
91 flush()
92 kind = 'text'
93 }
94 lines.push(line)
95 }
96 flush()
97 return out
98}
99
100// ---------------------------------------------------------------------------
101// The tab on surfaces that draw SVG: a browser-like tab with rounded top
102// corners, set in past the frame's rounded corner and sitting on its top edge.
103// The label's width is estimated, so the tab is a little wider than the text.
104// Only a hex color goes into the SVG: a theme key means nothing there, and such
105// a color keeps the text tab, which the surface resolves.
106
107const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
108
109const TAB = { height: 24, radius: 6, inset: 12, padding: 12, charWidth: 8, fontSize: 13 }
110
111const escapeXml = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
112
113function tabSvg(label: string, fill: string): { source: string; width: number; height: number } {
114 const { height: h, radius: r, inset: x, padding, charWidth, fontSize } = TAB
115 const w = Math.ceil(label.length * charWidth) + 2 * padding
116 const path = `M${x} ${h}V${r}Q${x} 0 ${x + r} 0H${x + w - r}Q${x + w} 0 ${x + w} ${r}V${h}Z`
117 const source =
118 `<svg xmlns="http://www.w3.org/2000/svg" width="${x + w}" height="${h}" viewBox="0 0 ${x + w} ${h}">` +
119 `<path d="${path}" fill="${escapeXml(fill)}"/>` +
120 `<text x="${x + w / 2}" y="${h / 2}" dominant-baseline="central" text-anchor="middle" fill="${TAB_TEXT}" ` +
121 `font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="${fontSize}" font-weight="600">` +
122 `${escapeXml(label)}</text></svg>`
123 return { source, width: x + w, height: h }
124}
125
126// ---------------------------------------------------------------------------
127
128export const register: Register = (on, options) => {
129 const color = (kind: SectionKind) => String(options[SECTIONS[kind].option] ?? '').trim() || undefined
130
131 on('prompt.submit', ($, e, next) =>
132 fromPerson(e.origin) ? next({ ...e, context: [...(e.context ?? []), RULE] }) : next(e),
133 )
134
135 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
136 if (e.props.isSummary || !/^ {0,3}(#|\*\*|__)/m.test(e.props.text)) return next(e)
137 const segments = split(e.props.text)
138 if (!segments.some(s => s.kind !== 'text')) return next(e)
139
140 const { Box, Text, Markdown } = $.ui.resolve(e)
141 // Every surface but the terminal draws SVG; resolved again so the narrowed
142 // `e` narrows the table (`'Svg' in table` holds on the terminal too).
143 const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
144 return (
145 <Box flexDirection="column">
146 {segments.map(s => {
147 if (s.kind === 'text') return <Markdown text={s.text} />
148 const c = color(s.kind)
149 return (
150 <Box flexDirection="column" marginTop={1}>
151 {Svg && c && HEX.test(c) ? (
152 <Svg alt={s.label} {...tabSvg(s.label, c)} />
153 ) : (
154 <Box alignSelf="flex-start" marginLeft={2} paddingX={1} backgroundColor={c}>
155 <Text color={c && TAB_TEXT} backgroundColor={c} bold>
156 {s.label}
157 </Text>
158 </Box>
159 )}
160 <Box flexDirection="column" borderStyle="round" borderColor={c} paddingX={1}>
161 <Markdown text={s.text} />
162 </Box>
163 </Box>
164 )
165 })}
166 </Box>
167 )
168 })
169}
170