SLOPSHOPPER

callout-colors

Colors 'decision for you' and summary text in assistant replies so they stand out

newrows
v0.1.0MITupdated 2026-10-16Tyru5/claude-callout-colors
A shopper browsing a rack in a slop shop
README

callout-colors

A Claude Code mod: colors "decision for you" and summary sections in assistant replies so they stand out in long sessions.

What it does

  • Decision sections (Decision for you, Decision needed, Needs your input, Your call): bordered box, text in the theme's warning color.
  • Summary sections (Summary, TL;DR, Recap): same, suggestion color.
  • The engine's between-tool-call summaries: whole block colored as summary.
  • Subagent blocks (Agent tool call row and its result): indented 2 cells, bordered box in the theme's merged color.

A section opens on a heading, bold label or plain label matching the above and runs to the next heading / bold label or the end of the reply. Labels inside code fences are ignored. Text before the first callout keeps the engine's own drawing; callout text is drawn plain (bold and # stripped).

Works best when the agent labels sections consistently, e.g. in ~/.claude/CLAUDE.md:

Always label sections `**Decision for you:**` (last in reply) and `**Summary:**`.

Install

git clone https://github.com/Tyru5/claude-callout-colors ~/claude-code-mods/callout-colors
claude --plugin-dir ~/claude-code-mods/callout-colors

Or set it permanently in ~/.claude/settings.json (:-separated for several):

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-callout-colors" } }

Develop

claude plugin test .

.claude-plugin/types/ is generated by the engine on load and ignored.

Layout

  • hooks/register.tsx: ui.render hooks on AssistantMessage, ToolUse, ToolResult.
  • hooks/split.ts: callout detection; label patterns at the top.
  • hooks/*.test.ts: suite run by claude plugin test.

License

MIT

Source 2 files
hooks/register.tsx 65 lines
1import type { Register } from 'claude-code'
2
3import { plain, split, type Kind } from './split'
4
5// Theme keys, so colors follow the person's theme.
6const COLOR: Record<Kind, string> = { decision: 'warning', summary: 'suggestion' }
7const SUBAGENT_COLOR = 'merged'
8
9// Subagent calls: the Agent tool (`Task` in older transcripts).
10const SUBAGENT_TOOLS = new Set(['Agent', 'Task'])
11const SUBAGENT_BOX = {
12  marginLeft: 2,
13  borderStyle: 'round',
14  borderColor: SUBAGENT_COLOR,
15  paddingX: 1,
16  flexDirection: 'column',
17} as const
18
19export const register: Register = on => {
20  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
21    const { Box, Text, Markdown } = $.ui.resolve(e)
22
23    const callout = (kind: Kind, text: string, key: string) => (
24      <Box key={key} borderStyle="round" borderColor={COLOR[kind]} paddingX={1}>
25        <Text color={COLOR[kind]}>{plain(text)}</Text>
26      </Box>
27    )
28
29    // Between-tool-call summaries: whole block is a summary.
30    if (e.props.isSummary) {
31      return <Box flexDirection="column">{callout('summary', e.props.text, 's')}</Box>
32    }
33
34    const parts = split(e.props.text)
35    if (!parts.some(p => p.kind !== 'plain')) return next(e)
36
37    // Leading plain text keeps the engine's own drawing (and its reply bullet).
38    const [head, ...rest] = parts
39    const first =
40      head?.kind === 'plain' ? await next({ ...e, props: { ...e.props, text: head.text } }) : undefined
41    const tail = first ? rest : parts
42
43    return (
44      <Box flexDirection="column">
45        {first}
46        {tail.map((p, i) =>
47          p.kind === 'plain' ? <Markdown key={`p${i}`} text={p.text} /> : callout(p.kind, p.text, `c${i}`),
48        )}
49      </Box>
50    )
51  })
52
53  // Subagent rows: call row and its result, each indented in its own bordered box.
54  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
55    if (!SUBAGENT_TOOLS.has(e.props.tool)) return next(e)
56    const { Box } = $.ui.resolve(e)
57    return <Box {...SUBAGENT_BOX}>{await next(e)}</Box>
58  })
59  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
60    if (!SUBAGENT_TOOLS.has(e.props.tool)) return next(e)
61    const { Box } = $.ui.resolve(e)
62    return <Box {...SUBAGENT_BOX}>{await next(e)}</Box>
63  })
64}
65
hooks/split.ts 49 lines
1export type Kind = 'decision' | 'summary'
2export type Part = { kind: 'plain' | Kind; text: string }
3
4// A line opening a callout: heading, bold label, or plain label.
5const OPENER =
6  /^\s*(?:#{1,6}\s+|\*\*|__)?\s*(decisions? (?:for you|needed|required)|needs? your (?:decision|input|call)|your (?:call|decision)|summary|tl;?dr|recap)\b/i
7
8// A line that starts a new section, ending the callout before it.
9const SECTION = /^\s*(?:#{1,6}\s+\S|(?:\*\*|__)[^*_]+(?:\*\*|__)\s*:?\s*$|(?:\*\*|__)[^*_]+:(?:\*\*|__))/
10
11function kindOf(line: string): Kind | undefined {
12  const m = OPENER.exec(line)
13  if (!m) return undefined
14  return /summary|tl;?dr|recap/i.test(m[1] ?? '') ? 'summary' : 'decision'
15}
16
17export function split(text: string): Part[] {
18  const parts: Part[] = []
19  let cur: Part = { kind: 'plain', text: '' }
20  let inFence = false
21
22  const push = () => {
23    if (cur.text.trim()) parts.push({ kind: cur.kind, text: cur.text.replace(/^\n+|\n+$/g, '') })
24  }
25
26  for (const line of text.split('\n')) {
27    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
28    const opens = inFence ? undefined : kindOf(line)
29    if (opens) {
30      push()
31      cur = { kind: opens, text: '' }
32    } else if (!inFence && cur.kind !== 'plain' && SECTION.test(line)) {
33      push()
34      cur = { kind: 'plain', text: '' }
35    }
36    cur.text += line + '\n'
37  }
38  push()
39  return parts
40}
41
42// Markdown markers that read as noise once drawn as plain colored text.
43export function plain(md: string): string {
44  return md
45    .split('\n')
46    .map(l => l.replace(/^(\s*)#{1,6}\s+/, '$1').replace(/\*\*|__/g, ''))
47    .join('\n')
48}
49