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

A Claude Code mod: colors "decision for you" and summary sections in assistant replies so they stand out in long sessions.
Decision for you, Decision needed, Needs your input, Your call): bordered box, text in the theme's warning color.Summary, TL;DR, Recap): same, suggestion color.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:**`.
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" } }
claude plugin test .
.claude-plugin/types/ is generated by the engine on load and ignored.
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.MIT
hooks/register.tsx 65 lines1import 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}
65hooks/split.ts 49 lines1export 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