Colors the Compact style's status line, links Jira keys, and folds Maven output into a one-line summary.

A Claude Code mod that restyles how transcript rows are drawn. Display only: the model still reads the original text, and it costs no tokens. Press ctrl+o to see the raw rows.
| Row | Trigger | Drawn as |
|---|---|---|
| Assistant text | First line starts with ✅ ⚠️ ❌ ❓ | That line bold, colored success / warning / error / suggestion |
| Assistant text | Last line starts with Next: | Dimmed → ... line |
| Assistant text | Bare Jira key, e.g. ASB-5015 | Link to <jiraBaseUrl>/browse/ASB-5015 (skips code, existing links, feature/ASB-1) |
| Bash result | Output contains BUILD SUCCESS / BUILD FAILURE | ✅ BUILD SUCCESS · 314 tests · 01:12 min, plus up to 10 failing tests or compile errors |
The status and Next: lines come from the Compact output style (~/.claude/output-styles/compact.md); with other styles only the Jira links and Maven summary apply.
Set under /config (compact-render rows):
| Option | Default |
|---|---|
jiraBaseUrl | https://nordnetbank.atlassian.net |
jiraProjects | ASB (comma-separated, e.g. ASB,PAY) |
settings.json loads it with "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/compact-render". Interactive sessions reload it on save. One-off: claude --plugin-dir ~/.claude/mods/compact-render.
claude plugin validate .
claude plugin test .
BUILD ... line in the output; runs piped through tail -n that cut it show raw output.hooks/register.tsx 66 lines1import type { Register } from 'claude-code'
2
3import { formatMavenLine, linkJiraKeys, splitReply, summarizeMaven } from './format'
4
5export const register: Register = (on, options) => {
6 const jiraBaseUrl = String(options.jiraBaseUrl ?? 'https://nordnetbank.atlassian.net')
7 const jiraProjects = String(options.jiraProjects ?? 'ASB')
8 .split(',')
9 .map(p => p.trim().toUpperCase())
10 .filter(p => /^[A-Z][A-Z0-9]+$/.test(p))
11
12 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
13 if (e.props.isSummary) return next(e)
14
15 const reply = splitReply(e.props.text)
16 const body = linkJiraKeys(reply.body, jiraBaseUrl, jiraProjects)
17 if (!reply.result && !reply.next) {
18 return body === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text: body } })
19 }
20
21 const { Box, Text } = $.ui.resolve(e)
22 const drawnBody = body === '' ? null : await next({ ...e, props: { ...e.props, text: body } })
23 return (
24 <Box flexDirection="column">
25 {reply.result && (
26 <Text bold color={reply.result.status}>
27 {reply.result.line}
28 </Text>
29 )}
30 {drawnBody}
31 {reply.next && (
32 <Box marginTop={1}>
33 <Text dimColor>→ {reply.next}</Text>
34 </Box>
35 )}
36 </Box>
37 )
38 })
39
40 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, ($, e, next) => {
41 const summary = summarizeMaven(bashOutputText(e.props.output))
42 if (!summary) return next(e)
43
44 const { Box, Text } = $.ui.resolve(e)
45 return (
46 <Box flexDirection="column">
47 <Text bold color={summary.isSuccess ? 'success' : 'error'}>
48 {formatMavenLine(summary)}
49 </Text>
50 {summary.problems.map((problem, i) => (
51 <Text key={String(i)} color="error">
52 {' '}• {problem}
53 </Text>
54 ))}
55 </Box>
56 )
57 })
58}
59
60// A non-zero exit (a failed build) stores the error text as a string, not the { stdout, stderr } record.
61function bashOutputText(output: unknown): string {
62 if (typeof output === 'string') return output
63 const record = output as { stdout?: string; stderr?: string } | undefined
64 return `${record?.stdout ?? ''}\n${record?.stderr ?? ''}`
65}
66hooks/format.ts 94 lines1export type Status = 'success' | 'warning' | 'error' | 'suggestion'
2
3const MARKERS: ReadonlyArray<readonly [string, Status]> = [
4 ['✅', 'success'],
5 ['⚠️', 'warning'],
6 ['❌', 'error'],
7 ['❓', 'suggestion'],
8]
9
10export type SplitReply = {
11 result?: { line: string; status: Status }
12 body: string
13 next?: string
14}
15
16export function splitReply(text: string): SplitReply {
17 const lines = text.split('\n')
18 const first = lines[0]?.trim() ?? ''
19 const marker = MARKERS.find(([glyph]) => first.startsWith(glyph))
20 const result = marker ? { line: first, status: marker[1] } : undefined
21 if (result) lines.shift()
22
23 let next: string | undefined
24 const lastIndex = findLastNonBlank(lines)
25 if (lastIndex >= 0 && /^\**Next\**:/.test(lines[lastIndex]!.trim())) {
26 next = lines[lastIndex]!.trim().replace(/^\**Next\**:\s*/, '')
27 lines.splice(lastIndex, 1)
28 }
29
30 return { result, body: lines.join('\n').trim(), next }
31}
32
33function findLastNonBlank(lines: readonly string[]): number {
34 for (let i = lines.length - 1; i >= 0; i--) {
35 if (lines[i]!.trim() !== '') return i
36 }
37 return -1
38}
39
40// Skips fenced blocks, inline code and existing markdown links so only bare keys become links.
41export function linkJiraKeys(text: string, baseUrl: string, projects: readonly string[]): string {
42 if (projects.length === 0) return text
43 const key = new RegExp(`(?<![\\w/\\[-])((?:${projects.join('|')})-\\d+)(?![\\w\\]])`, 'g')
44 const base = baseUrl.replace(/\/+$/, '')
45 return text
46 .split(/(```[\s\S]*?```|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\))/)
47 .map((part, i) => (i % 2 === 1 ? part : part.replace(key, `[$1](${base}/browse/$1)`)))
48 .join('')
49}
50
51export type MavenSummary = {
52 isSuccess: boolean
53 tests?: { run: number; failures: number; errors: number; skipped: number }
54 time?: string
55 problems: string[]
56}
57
58const MAX_PROBLEMS = 10
59
60export function summarizeMaven(output: string): MavenSummary | undefined {
61 const outcome = output.match(/BUILD (SUCCESS|FAILURE)/)
62 if (!outcome) return undefined
63
64 const totals = [...output.matchAll(/Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)(?!,)/g)].at(-1)
65 const time = output.match(/Total time:\s+([^\n]+)/)?.[1]?.trim()
66 const problems = [
67 ...output.matchAll(/^\[ERROR\]\s+(?:Failures|Errors):\s*$|^\[ERROR\]\s{2,}(\S.*)$/gm),
68 ]
69 .map(m => m[1])
70 .filter((line): line is string => line !== undefined)
71 const compileErrors = [...output.matchAll(/^\[ERROR\] (\/\S+\.java:\[\d+,\d+\] .+)$/gm)].map(m => m[1]!)
72
73 return {
74 isSuccess: outcome[1] === 'SUCCESS',
75 tests: totals
76 ? { run: Number(totals[1]), failures: Number(totals[2]), errors: Number(totals[3]), skipped: Number(totals[4]) }
77 : undefined,
78 time,
79 problems: [...new Set([...problems, ...compileErrors])].slice(0, MAX_PROBLEMS),
80 }
81}
82
83export function formatMavenLine(summary: MavenSummary): string {
84 const parts = [summary.isSuccess ? '✅ BUILD SUCCESS' : '❌ BUILD FAILURE']
85 const t = summary.tests
86 if (t) {
87 const failed = t.failures + t.errors
88 parts.push(failed > 0 ? `${failed}/${t.run} tests failed` : `${t.run} tests`)
89 if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
90 }
91 if (summary.time) parts.push(summary.time)
92 return parts.join(' · ')
93}
94