SLOPSHOPPER

readable-layout

Lays out the terminal transcript so it reads like a conversation: your messages and Claude's answers in labeled boxes, Claude's work as quiet lines between…

newspinnerrows
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · readable-layout
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ You ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail · Done. I made refresh reject expired claims and added an audit call. ── ✻ Worked for 42s ─────────────────────────────────────────────────────────────────────────────────────────────────… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
── ✻ Worked for 42s ───────────────────────────────────────────────────────────────────────────────…
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ You
Claude's reply
· Done. I made refresh reject expired claims and added an audit call.
README

Readable Layout

In the terminal, a Claude Code session is one long stream. Your message, Claude's short notes ("I'll check the config first"), its tool calls and its final answer all start with the same bullet in the same style. That makes it hard to tell who is talking, what Claude is doing, and which part is the answer you need to read.

Readable Layout gives every part of a turn its own look. Here is the same conversation in stock Claude Code and with the mod, both with the Ember skin:

Before

The conversation in stock Claude Code: the prompt, Claude's notes and its answer all start with the same white dot, and the bold names look like normal text

After

The same conversation with Readable Layout: the prompt and the answer sit in boxes labeled You and Claude, the notes are quiet grey lines, the bold names are teal, and a rule ends the turn

What changes

  • Your messages sit in a box labeled You, in your theme's blue.
  • Claude's answers sit in a box labeled Claude, in Claude's orange. Anything longer than a line or two counts as an answer. Inside the box it renders as before: headings, lists, tables, code blocks and links.
  • Bold text stands out. Claude Code draws bold text in the same color as everything else, only thicker, and at terminal font sizes that is easy to miss. Readable Layout draws it bold and in a soft teal, so key words and the lead-in of each list item catch your eye. Teal sits opposite Claude's orange on the color wheel, so bold text never blends into the orange boxes and labels, and inline code keeps its own blue.
  • Claude's short notes between steps become quiet, indented lines with a blank line above each one, so every note sits with the step it describes.
  • Tool activity ("Read 1 file", "Searched for 1 pattern") keeps Claude Code's own quiet style, so you can skim past it.
  • Each turn ends with a rule across the screen that shows how long it took, so you can see where one exchange stops and the next begins.

Notifications from background tasks, messages from other agents, permission prompts, diffs and command output are left exactly as Claude Code draws them.

Installing

Type this at the Claude Code prompt:

/plugin install readable-layout --marketplace GarvitNangru/claude-code-mods

It asks whether to add this repo as a marketplace. Say y and pick a scope; the user scope loads it in every session. It starts working right away, no restart needed.

To update it later, run claude plugin update readable-layout@garvit-mods, then /reload-plugins in an open session. To remove it, run /plugin uninstall readable-layout@garvit-mods. To run it from a clone instead, see Trying one from a clone.

Settings

Claude text to box in /config has two choices:

  • answers (the default) boxes replies of more than a line or two and shows one-line notes as quiet lines.
  • all boxes everything Claude writes, notes included.

Bold text has three:

  • color (the default) draws bold text in your theme's cyan: a soft teal in Ember, verdigris in Firelight, a deeper cyan in Claude Code's own dark and light themes.
  • highlight puts a soft background behind bold text, like a marker pen, if you'd rather keep the text color unchanged.
  • off leaves bold text exactly as Claude Code draws it.

For a mod loaded from a folder, set them in ~/.claude/settings.json instead:

{ "pluginConfigs": { "readable-layout": { "options": { "boxes": "all", "bold": "highlight" } } } }

Good to know

  • Only the terminal changes. The desktop app, VS Code and mobile already lay a conversation out on their own, so they're left alone.
  • The box and rule colors come from your theme, so they match whatever theme you use. It's designed alongside the Ember skin.
  • To color bold text, the mod draws the paragraphs, headings and lists that contain it itself. Everything else is still drawn by Claude Code: code blocks, tables, quotes, and any part of a reply without bold. Bold text inside a table or a quote stays plain bold.
  • It only changes how the transcript is drawn. What you and Claude wrote, and what's saved, stay exactly the same. Ctrl+O still shows the full transcript.

Development

claude plugin validate mods/readable-layout
claude plugin test mods/readable-layout
Source 2 files
hooks/register.tsx 72 lines
1import type { Register, RenderElement } from 'claude-code'
2import { drawMarkdown, type Emphasis } from './markdown'
3
4const SHORT_NOTE_CHARS = 160
5
6const isShortNote = (text: string): boolean => !text.includes('\n') && text.length <= SHORT_NOTE_CHARS
7
8const fmtDuration = (ms: number): string => {
9  const s = Math.max(1, Math.round(ms / 1000))
10  const m = Math.floor(s / 60)
11  return m ? `${m}m ${s % 60}s` : `${s}s`
12}
13
14export const register: Register = (on, options) => {
15  const boxesAll = options.boxes === 'all'
16  const emphasis: Emphasis = options.bold === 'highlight' || options.bold === 'off' ? options.bold : 'color'
17
18  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
19    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer' || !e.props.text.trim()) return next(e)
20    const { Box, Text } = $.ui.resolve(e)
21    return framed(Box, Text, 'You', 'suggestion', <Text>{e.props.text.trim()}</Text>)
22  })
23
24  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
25    const text = e.props.text.trim()
26    if (e.surface !== 'terminal' || e.props.isSummary || !text) return next(e)
27    const ui = $.ui.resolve(e)
28    const { Box, Text } = ui
29
30    if (!boxesAll && isShortNote(text)) {
31      return (
32        <Box flexDirection="row" marginTop={1} paddingLeft={2}>
33          <Text color="subtle">{'· '}</Text>
34          {drawMarkdown(ui, text, emphasis, true)}
35        </Box>
36      )
37    }
38    return framed(Box, Text, 'Claude', 'claude', drawMarkdown(ui, text, emphasis))
39  })
40
41  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
42    if (e.surface !== 'terminal') return next(e)
43    const { Box, Text } = $.ui.resolve(e)
44    const label = ` ✻ ${e.props.word} for ${fmtDuration(e.props.durationMs)} `
45    const width = Math.max(24, (e.viewport?.columns ?? 80) - 2)
46    return (
47      <Box marginTop={1}>
48        <Text color="subtle" wrap="truncate-end">
49          {`──${label}${'─'.repeat(Math.max(0, width - label.length - 2))}`}
50        </Text>
51      </Box>
52    )
53  })
54}
55
56type Element = (props: Record<string, unknown>, ...children: unknown[]) => RenderElement
57
58function framed(Box: Element, Text: Element, title: string, color: string, body: RenderElement): RenderElement {
59  return (
60    <Box flexDirection="column" marginTop={1}>
61      <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
62        {body}
63      </Box>
64      <Box position="absolute" top={0} left={2}>
65        <Text color={color} bold>
66          {` ${title} `}
67        </Text>
68      </Box>
69    </Box>
70  )
71}
72
hooks/markdown.tsx 435 lines
1import type { Elements, RenderChildren, RenderElement, TextProps } from 'claude-code'
2
3export type Emphasis = 'color' | 'highlight' | 'off'
4
5type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Link' | 'Markdown'>
6
7type Span =
8  | string
9  | { kind: 'strong' | 'em' | 'del'; children: Span[] }
10  | { kind: 'code'; text: string }
11  | { kind: 'link'; href: string; children: Span[] }
12
13type Item = { paras: string[][]; gaps: boolean[]; contentCol: number; lists: List[] }
14type List = { ordered: boolean; start: number; markerCol: number; items: Item[] }
15type Block =
16  | { kind: 'heading'; depth: number; lines: string[] }
17  | { kind: 'paragraph'; lines: string[] }
18  | { kind: 'list'; list: List }
19type Line = { text: string; start: number; end: number }
20type Chunk = { start: number; end: number; blocks?: Block[] }
21type Found = { span: Span; end: number } | undefined
22
23const STRONG_STYLE: Record<Exclude<Emphasis, 'off'>, TextProps> = {
24  // The one cool accent every built-in theme defines in light, dark and ANSI variants, clear of Claude's orange.
25  color: { bold: true, color: 'cyan_FOR_SUBAGENTS_ONLY' },
26  highlight: { bold: true, backgroundColor: 'selectionBg' },
27}
28
29const FENCE = /^ {0,3}(`{3,}|~{3,})/
30const LEFT_TO_ENGINE = [
31  FENCE,
32  /^ {0,3}>/,
33  /^ {0,3}\|/,
34  /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/,
35  /^ {0,3}(?:=+|-+|(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})\s*$/,
36  /^ {0,3}<\/?[A-Za-z!?]/,
37]
38const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
39const ITEM = /^( *)([-*+]|\d{1,9}[.)])(?:([ \t]+)(.*))?$/
40const DELIMS = '*_~'
41const PUNCT = /[!-/:-@[-`{-~]/
42const WORD = /[\p{L}\p{N}]/u
43const LINKABLE = /^(?:https?|file):/i
44const MAX_INDENT = 32
45
46export function drawMarkdown(ui: Ui, text: string, emphasis: Emphasis, dimColor = false): RenderElement {
47  const { Box, Markdown } = ui
48  const native = (t: string) => (dimColor ? <Markdown text={t} dimColor /> : <Markdown text={t} />)
49  if (emphasis === 'off') return native(text)
50
51  const segments: Chunk[] = []
52  for (const c of splitChunks(text)) {
53    const prev = segments.at(-1)
54    if (prev && !prev.blocks && !c.blocks) prev.end = c.end
55    else segments.push({ ...c })
56  }
57  if (!segments.some(s => s.blocks)) return native(text)
58
59  const blocks = blockDrawer(ui, STRONG_STYLE[emphasis], dimColor)
60  return (
61    <Box flexDirection="column">
62      {segments.map((s, i) => (
63        <Box flexDirection="column" marginTop={i ? 1 : 0}>
64          {s.blocks ? blocks(s.blocks) : native(text.slice(s.start, s.end))}
65        </Box>
66      ))}
67    </Box>
68  )
69}
70
71function blockDrawer({ Box, Text, Link }: Ui, strong: TextProps, dim: boolean) {
72  const spans = (src: Span[]): RenderChildren =>
73    src.map(s => {
74      if (typeof s === 'string') return s
75      if (s.kind === 'code') return <Text color="permission">{s.text}</Text>
76      const inner = spans(s.children)
77      switch (s.kind) {
78        case 'strong':
79          return <Text {...strong}>{inner}</Text>
80        case 'em':
81          return <Text italic>{inner}</Text>
82        case 'del':
83          return <Text strikethrough>{inner}</Text>
84        case 'link':
85          if (!LINKABLE.test(s.href)) return <Text>{inner}</Text>
86          return s.children.length ? <Link href={s.href}>{inner}</Link> : <Link href={s.href} />
87      }
88    })
89  const prose = (lines: string[] = []) => spans(parseInline(lines.join('\n')))
90
91  const list = (l: List, indent: number, depth: number): RenderChildren => {
92    const last = l.start + l.items.length - 1
93    return l.items.map((item, i) => {
94      const marker = l.ordered ? `${ordinal(depth, l.start + i, l.start, last)}.` : '-'
95      const cell = marker.length + 1
96      return [
97        <Box flexDirection="row" paddingLeft={indent} marginTop={item.gaps[0] ? 1 : 0}>
98          <Box width={cell} flexShrink={0}>
99            <Text dimColor={dim}>{marker}</Text>
100          </Box>
101          <Text dimColor={dim}>{prose(item.paras[0])}</Text>
102        </Box>,
103        item.paras.slice(1).map((p, k) => (
104          <Box paddingLeft={indent + cell} marginTop={item.gaps[k + 1] ? 1 : 0}>
105            <Text dimColor={dim}>{prose(p)}</Text>
106          </Box>
107        )),
108        item.lists.map(sub => list(sub, Math.min(indent + cell, MAX_INDENT), depth + 1)),
109      ]
110    })
111  }
112
113  return (blocks: Block[]): RenderChildren =>
114    blocks.map((b, i) => {
115      const marginTop = blocks[i - 1]?.kind === 'heading' ? 1 : 0
116      if (b.kind === 'list') {
117        return (
118          <Box flexDirection="column" marginTop={marginTop}>
119            {list(b.list, 0, 0)}
120          </Box>
121        )
122      }
123      const h1 = b.kind === 'heading' && b.depth === 1
124      return (
125        <Box marginTop={marginTop}>
126          <Text dimColor={dim} bold={b.kind === 'heading'} italic={h1} underline={h1}>
127            {prose(b.lines)}
128          </Text>
129        </Box>
130      )
131    })
132}
133
134function ordinal(depth: number, n: number, first: number, last: number): string {
135  if (depth === 1 && first >= 1) return letters(n)
136  if (depth === 2 && first >= 1 && last <= 3999) return roman(n)
137  return String(n)
138}
139
140function letters(n: number): string {
141  let s = ''
142  while (n > 0) {
143    n--
144    s = String.fromCharCode(97 + (n % 26)) + s
145    n = Math.floor(n / 26)
146  }
147  return s
148}
149
150const ROMAN: [number, string][] = [
151  [1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'],
152  [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
153]
154
155function roman(n: number): string {
156  let s = ''
157  for (const [value, numeral] of ROMAN) {
158    while (n >= value) {
159      s += numeral
160      n -= value
161    }
162  }
163  return s
164}
165
166function splitChunks(text: string): Chunk[] {
167  const chunks: Chunk[] = []
168  let cur: Line[] = []
169  let fence: RegExp | undefined
170  let blankBefore = false
171  const close = () => {
172    while (cur.length && !cur.at(-1)?.text.trim()) cur.pop()
173    const first = cur[0]
174    const last = cur.at(-1)
175    if (first && last) chunks.push({ start: first.start, end: last.end, blocks: parseChunk(cur.map(l => l.text)) })
176    cur = []
177  }
178
179  let start = 0
180  for (const raw of text.split('\n')) {
181    const line: Line = { text: raw.replace(/\r$/, '').replace(/\t/g, '    '), start, end: start + raw.length }
182    start = line.end + 1
183    if (fence) {
184      cur.push(line)
185      if (fence.test(line.text)) fence = undefined
186    } else if (!line.text.trim()) {
187      blankBefore = cur.length > 0
188      if (blankBefore) cur.push(line)
189    } else {
190      if (blankBefore && !(/^ {2,}\S/.test(line.text) && cur.some(l => ITEM.test(l.text)))) close()
191      blankBefore = false
192      const open = FENCE.exec(line.text)?.[1]
193      if (open) fence = new RegExp(`^ {0,3}${open.charAt(0)}{${open.length},}\\s*$`)
194      cur.push(line)
195    }
196  }
197  close()
198  return chunks
199}
200
201function parseChunk(lines: string[]): Block[] | undefined {
202  if (!/\*\*|__/.test(lines.join('\n'))) return
203  if (/^ {4}/.test(lines[0] ?? '') || lines.some(t => LEFT_TO_ENGINE.some(re => re.test(t)))) return
204
205  const blocks: Block[] = []
206  const stack: List[] = []
207  const deepest = () => stack.at(-1)?.items.at(-1)
208  let para: string[] | undefined
209  let blank = false
210
211  for (const line of lines) {
212    if (!line.trim()) {
213      blank = true
214      continue
215    }
216    const indent = line.length - line.trimStart().length
217    const heading = HEADING.exec(line)
218    const item = ITEM.exec(line)
219
220    if (heading && !(stack.length && indent >= 2)) {
221      stack.length = 0
222      para = undefined
223      blocks.push({ kind: 'heading', depth: (heading[1] ?? '#').length, lines: [heading[2] ?? ''] })
224    } else if (item && (stack.length || !para || interrupts(item))) {
225      const [, lead = '', marker = '-', pad = ' ', content = ''] = item
226      const ordered = /\d/.test(marker)
227      para = [content.trim()]
228      const next: Item = { paras: [para], gaps: [blank], contentCol: lead.length + marker.length + Math.min(pad.length, 4), lists: [] }
229      place(stack, blocks, next, { ordered, start: ordered ? parseInt(marker, 10) : 1, markerCol: lead.length })
230    } else if (stack.length && blank) {
231      while (stack.length > 1 && indent < (deepest()?.contentCol ?? 0)) stack.pop()
232      const owner = deepest()
233      if (!owner || indent < owner.contentCol) return
234      para = [line.trim()]
235      owner.paras.push(para)
236      owner.gaps.push(true)
237    } else if (blank) return
238    else if (para) para.push(line.trim())
239    else {
240      para = [line.trim()]
241      blocks.push({ kind: 'paragraph', lines: para })
242    }
243    blank = false
244  }
245  return hasStrong(blocks) ? blocks : undefined
246}
247
248function interrupts(item: RegExpExecArray): boolean {
249  return /^(?:[-*+]|1[.)])$/.test(item[2] ?? '') && (item[4] ?? '').trim() !== ''
250}
251
252function place(stack: List[], blocks: Block[], item: Item, shape: Omit<List, 'items'>): void {
253  const fresh = (): List => ({ ...shape, items: [item] })
254  for (let top = stack.at(-1); top; top = stack.at(-1)) {
255    const last = top.items.at(-1)
256    if (last && shape.markerCol >= last.contentCol) {
257      const list = fresh()
258      last.lists.push(list)
259      stack.push(list)
260      return
261    }
262    if (shape.markerCol >= top.markerCol || stack.length === 1) {
263      if (top.ordered === shape.ordered) {
264        top.items.push(item)
265        return
266      }
267      stack.pop()
268      const list = fresh()
269      const parent = stack.at(-1)?.items.at(-1)
270      if (parent) parent.lists.push(list)
271      else blocks.push({ kind: 'list', list })
272      stack.push(list)
273      return
274    }
275    stack.pop()
276  }
277  const list = fresh()
278  blocks.push({ kind: 'list', list })
279  stack.push(list)
280}
281
282function hasStrong(blocks: Block[]): boolean {
283  const texts: string[] = []
284  const collect = (l: List): void => {
285    for (const item of l.items) {
286      for (const p of item.paras) texts.push(p.join('\n'))
287      item.lists.forEach(collect)
288    }
289  }
290  for (const b of blocks) {
291    if (b.kind === 'list') collect(b.list)
292    else texts.push(b.lines.join('\n'))
293  }
294  return texts.some(t => strongIn(parseInline(t)))
295}
296
297function strongIn(spans: Span[]): boolean {
298  return spans.some(s => typeof s !== 'string' && s.kind !== 'code' && (s.kind === 'strong' || strongIn(s.children)))
299}
300
301function parseInline(src: string): Span[] {
302  const out: Span[] = []
303  let buf = ''
304  const push = (span: Span) => {
305    if (buf) out.push(buf)
306    buf = ''
307    out.push(span)
308  }
309  let i = 0
310  while (i < src.length) {
311    const c = src.charAt(i)
312    const next = src.charAt(i + 1)
313    if (c === '\\' && next && (PUNCT.test(next) || next === '\n')) {
314      buf += next
315      i += 2
316      continue
317    }
318    if (c === '`') {
319      const run = runLength(src, i, '`')
320      const close = codeClose(src, i + run, run)
321      if (close < 0) {
322        buf += src.slice(i, i + run)
323        i += run
324        continue
325      }
326      push({ kind: 'code', text: trimCode(src.slice(i + run, close)) })
327      i = close + run
328      continue
329    }
330    const delim = DELIMS.includes(c)
331    const found: Found = c === '[' ? link(src, i) : c === '<' ? autolink(src, i) : delim ? delimited(src, i, c) : undefined
332    if (found) {
333      push(found.span)
334      i = found.end
335      continue
336    }
337    const run = delim ? runLength(src, i, c) : 1
338    buf += src.slice(i, i + run)
339    i += run
340  }
341  if (buf) out.push(buf)
342  return out
343}
344
345function delimited(src: string, i: number, ch: string): Found {
346  const run = runLength(src, i, ch)
347  const size = ch === '~' ? (run === 2 ? 2 : 0) : run === 1 ? 1 : run <= 3 ? 2 : 0
348  const after = src.charAt(i + run)
349  if (!size || !after || /\s/.test(after)) return
350  if (ch === '_' && WORD.test(src.charAt(i - 1))) return
351
352  let j = i + run
353  while (j < src.length) {
354    const c = src.charAt(j)
355    if (c === '\\') {
356      j += 2
357      continue
358    }
359    if (c === '`') {
360      j = skipCode(src, j)
361      continue
362    }
363    if (c !== ch) {
364      j++
365      continue
366    }
367    const len = runLength(src, j, ch)
368    const fits = size === 1 ? len === 1 || len >= 3 : len >= 2
369    if (fits && !/\s/.test(src.charAt(j - 1)) && !(ch === '_' && WORD.test(src.charAt(j + len)))) {
370      const inner = src.slice(i + size, j + len - size)
371      if (!inner.trim()) return
372      const kind = ch === '~' ? 'del' : size === 2 ? 'strong' : 'em'
373      return { span: { kind, children: parseInline(inner) }, end: j + len }
374    }
375    j += len
376  }
377}
378
379function link(src: string, i: number): Found {
380  let depth = 0
381  let j = i
382  for (; j < src.length; j++) {
383    const c = src.charAt(j)
384    if (c === '\\') j++
385    else if (c === '`') j = skipCode(src, j) - 1
386    else if (c === '[') depth++
387    else if (c === ']' && --depth === 0) break
388  }
389  if (j >= src.length || src.charAt(j + 1) !== '(') return
390  let k = j + 2
391  for (let parens = 1; k < src.length; k++) {
392    const c = src.charAt(k)
393    if (c === '\\') k++
394    else if (c === '(') parens++
395    else if (c === ')' && --parens === 0) break
396  }
397  if (k >= src.length) return
398  const dest = src.slice(j + 2, k).trim()
399  const href = /^<([^>]*)>/.exec(dest)?.[1] ?? dest.split(/\s+/)[0]
400  if (!href) return
401  return { span: { kind: 'link', href, children: parseInline(src.slice(i + 1, j)) }, end: k + 1 }
402}
403
404function autolink(src: string, i: number): Found {
405  const m = /^<((?:https?|file):[^\s<>]+)>/i.exec(src.slice(i))
406  if (!m?.[1]) return
407  return { span: { kind: 'link', href: m[1], children: [m[1]] }, end: i + m[0].length }
408}
409
410function runLength(src: string, i: number, ch: string): number {
411  let n = 0
412  while (src.charAt(i + n) === ch) n++
413  return n
414}
415
416function codeClose(src: string, from: number, run: number): number {
417  for (let k = src.indexOf('`', from); k >= 0; ) {
418    const len = runLength(src, k, '`')
419    if (len === run) return k
420    k = src.indexOf('`', k + len)
421  }
422  return -1
423}
424
425function skipCode(src: string, j: number): number {
426  const run = runLength(src, j, '`')
427  const close = codeClose(src, j + run, run)
428  return close < 0 ? j + run : close + run
429}
430
431function trimCode(code: string): string {
432  const flat = code.replace(/\n/g, ' ')
433  return /^ .*[^ ].* $/.test(flat) ? flat.slice(1, -1) : flat
434}
435