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…

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

After

code keeps its own blue.Notifications from background tasks, messages from other agents, permission prompts, diffs and command output are left exactly as Claude Code draws them.
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.
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" } } } }
claude plugin validate mods/readable-layout
claude plugin test mods/readable-layouthooks/register.tsx 72 lines1import 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}
72hooks/markdown.tsx 435 lines1import 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