SLOPSHOPPER

visual-answer-improve

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

newrowsprompt
★ 1v0.1.0no licenseupdated 2026-10-11nerevar48/nerevar-mods/plugins/visual-answer-improve
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 1 files
hooks/register.tsx 170 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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