SLOPSHOPPER

compact-render

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

newrows
v0.1.0no licenseupdated 2026-10-12willgaozx/dotfiles/claude/mods/compact-render
A shopper browsing a rack in a slop shop
README

compact-render

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.

What it does

RowTriggerDrawn as
Assistant textFirst line starts with ✅ ⚠️ ❌ ❓That line bold, colored success / warning / error / suggestion
Assistant textLast line starts with Next:Dimmed → ... line
Assistant textBare Jira key, e.g. ASB-5015Link to <jiraBaseUrl>/browse/ASB-5015 (skips code, existing links, feature/ASB-1)
Bash resultOutput 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.

Options

Set under /config (compact-render rows):

OptionDefault
jiraBaseUrlhttps://nordnetbank.atlassian.net
jiraProjectsASB (comma-separated, e.g. ASB,PAY)

Loading

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.

Development

claude plugin validate .
claude plugin test .

Limits

  • Works per text block, not per reply: a mid-reply block starting with ✅ is colored too.
  • Maven summary needs the BUILD ... line in the output; runs piped through tail -n that cut it show raw output.
Source 2 files
hooks/register.tsx 66 lines
1import 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}
66
hooks/format.ts 94 lines
1export 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