Restyles replies and tool rows: orange emphasis, purple structure, orange-headed tables

Mods for Claude Code. Each mod has one job and works on its own: none imports, reads or calls another.
| Mod | Its job |
|---|---|
mods/statusdeck | The status row under the prompt: live stats and their colours |
mods/inkstyle | Colours inside Claude's replies and on tool-call rows |
mods/prettier | The layout of the whole chat: prompts, replies, code, tool rows, the spinner |
Copy the mods into ~/.claude/mods, then point Claude Code at them with the CLAUDE_CODE_PLUGIN_DIRS environment variable, prettier first (see Load order). Restart Claude Code after installing.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
New-Item -ItemType Directory -Force "$env:USERPROFILE\.claude\mods" | Out-Null
Copy-Item -Recurse -Force .\claude-code-utilities\mods\* "$env:USERPROFILE\.claude\mods\"
[Environment]::SetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS',
"$env:USERPROFILE\.claude\mods\prettier;$env:USERPROFILE\.claude\mods\statusdeck;$env:USERPROFILE\.claude\mods\inkstyle",
'User')
Then quit and reopen your whole terminal app (Windows Terminal, VS Code, any IDE), not just the tab: a new tab inherits the app's old copy of the environment and won't see the variable. Check it before starting claude:
echo $env:CLAUDE_CODE_PLUGIN_DIRS # must start with ...\mods\prettier
Or refresh a single terminal by hand:
$env:CLAUDE_CODE_PLUGIN_DIRS = [Environment]::GetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS','User')
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -r claude-code-utilities/mods/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/statusdeck:$HOME/.claude/mods/inkstyle"' >> ~/.bashrc
Open a new shell (or source ~/.bashrc) before starting claude.
git clone https://github.com/MoizAsif616/claude-code-utilities.git
mkdir -p ~/.claude/mods
cp -R claude-code-utilities/mods/* ~/.claude/mods/
echo 'export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/statusdeck:$HOME/.claude/mods/inkstyle"' >> ~/.zshrc
Open a new shell (or source ~/.zshrc) before starting claude.
prettier must be listed before inkstyle in CLAUDE_CODE_PLUGIN_DIRS (;-separated on Windows, : on Linux and macOS). The install commands above list it first.
Mods hook the same drawing in a chain: the first one loaded is the outermost, and it reaches the ones under it by handing the drawing on. prettier only wraps what is under it. inkstyle draws every reply and tool-call row itself (to colour them) and hands nothing on, so a mod loaded after inkstyle is never reached for those rows. Loaded first, prettier holds inkstyle's coloured replies and rows to 85% of the terminal; loaded after, they run the full width.
statusdeck's place in the list doesn't matter: it and prettier never draw the same thing.
In the wrong order nothing breaks; replies just go back to full width. Claude Code doesn't document the order mods load in; listing prettier first in this variable is what works. To check: ask Claude something with a long answer. If the reply wraps at about 85% of the terminal, the order is right.
The row under the prompt, with every colour in it:
⚡ and a countdown while the prompt cache is warm, ❄ once it has gone cold. The cache lifetime (1h on a Claude subscription, 5m with an API key) is a plugin option.statusdeck has no commands of its own. It doesn't draw the spinner; prettier does.
Colours only, inside Claude's replies and on tool-call rows:
| Command | What it does |
|---|---|
/inkstyle | on, off, or no argument to switch between them |
The layout of the chat. What it changes:
▸ 14 more lines. Beside the time, 14:32 ⧉ (same grey): a click on ⧉ copies that prompt's text and nothing else, and it reads a green ✓ for 3.5 seconds.python, or code when unlabeled) and ⧉ Copy at the right, which copies only the code and reads Copied! for 3.5 seconds. A panel is as wide as its longest line, never wider than the reply.+12 −3 (green and red) after an edit's row. While a reply is being written each diff shows whole at 75% of the terminal; once it is done, each edited or created file is one row, ▸ Edit hooks/app.ts +12 −3, that opens to its diff on a click.▸ Ran 2 commands · 1.2s. A click opens it: each call on a filled block with the whole command and its output (up to 40 lines). A click on any block, or on the row, closes it.✗ Bash failed, as wide as the error and no wider than the reply. Once the reply is done the call is one row, ▸ Edit app.ts · ✗ failed (✗ failed in red), that opens to its frame on a click. Declining a permission prompt is not a failure.⚙ Background task · completed · 2m 2s), not as your pill.39s · ▸ 2 tools, how long it took (dim) and how many tools it used (purple). A click on the count lists each call: ✓ Bash npm test · 1.2s.⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….Everything compact toggles on a click: ▸ is closed, ▾ is open. Clicks only reach mods in Claude Code's fullscreen (alternate-screen) layout. On the normal screen these rows stay compact, edit diffs and errors stay whole (there would be no way to reopen them), the Copy buttons are hidden, and a folded prompt or message says ctrl+o. ctrl+o is Claude Code's own transcript view: it shows tool calls, outputs and long prompts in full; press it again to go back.
| Command | Settings (defaults) |
|---|---|
/prettier | lists the commands; on, off, reset (every setting back to its default) |
/prettier-prompt | width 70 (% of the terminal), lines 6 (shown before folding), time on |
/prettier-response | width 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands) |
/prettier-code | width 75 (% of the terminal, for diffs) |
Each command on its own lists its settings and what they do. Change one or several at once: /prettier-prompt width 60 lines 4. Settings are kept across sessions. A wrong name or value says what's allowed.
│ lines may come out a little short.● <mod>: ... lines Claude Code prints when a mod loads, reloads or fails are Claude Code's own; no mod can restyle them.More detail is in mods/prettier/README.md.
Pull the latest changes and copy the mods again:
cd claude-code-utilities && git pull
cp -R mods/* ~/.claude/mods/
On Windows, run git pull and repeat the Copy-Item line from the install section. The environment variable only needs setting once.
types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.hooks/register.tsx 160 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Settings } from '../types'
5import { drawMarkdown, ORANGE, PURPLE, PURPLE_LIGHT } from './markdown'
6
7const settingsAtom = atom({ plugin: 'inkstyle', key: 'settings' } as const, {
8 isEnabled: true,
9} as Settings)
10
11const RED = '#EF4444'
12const GREY = '#71717A'
13const GUTTER = 2
14
15// ---------------------------------------------------------------- tool rows
16
17function normalize(path: string): string {
18 return path.replace(/\\/g, '/')
19}
20
21// Relative to the session's folder when inside it, else its last three parts.
22function shortPath(path: string, cwd: string): string {
23 const p = normalize(path)
24 const root = normalize(cwd).replace(/\/$/, '')
25 if (p.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return p.slice(root.length + 1)
26 const parts = p.split('/').filter(s => s !== '')
27 return parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : p
28}
29
30function str(input: Record<string, unknown>, key: string): string | undefined {
31 const v = input[key]
32 return typeof v === 'string' && v !== '' ? v : undefined
33}
34
35// `isCommand`: the summary is a shell command, drawn whole with its own lines.
36type Row = { name: string; scope?: string; summary: string; isCommand?: true }
37
38function describe(tool: string, raw: unknown, cwd: string): Row {
39 const input = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
40 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
41 if (mcp !== null) {
42 const scope = (mcp[1] ?? '').replace(/^claude_ai_/, '').replace(/_/g, ' ')
43 const first = Object.values(input).find(v => typeof v === 'string')
44 return { name: (mcp[2] ?? tool).replace(/_/g, ' '), scope, summary: typeof first === 'string' ? first : '' }
45 }
46 const path = str(input, 'file_path') ?? str(input, 'notebook_path') ?? str(input, 'path')
47 switch (tool) {
48 case 'Bash':
49 case 'PowerShell':
50 return { name: tool, summary: (str(input, 'command') ?? '').trim(), isCommand: true }
51 case 'Read':
52 case 'NotebookEdit':
53 return { name: tool, summary: path === undefined ? '' : shortPath(path, cwd) }
54 case 'Edit':
55 case 'Write':
56 return { name: tool, summary: path === undefined ? '' : shortPath(path, cwd) }
57 case 'Grep': {
58 const where = path === undefined ? '' : ` in ${shortPath(path, cwd)}`
59 return { name: tool, summary: `${str(input, 'pattern') ?? ''}${where}` }
60 }
61 case 'Glob':
62 return { name: tool, summary: str(input, 'pattern') ?? '' }
63 case 'WebFetch':
64 return { name: tool, summary: (str(input, 'url') ?? '').replace(/^https?:\/\//, '') }
65 case 'WebSearch':
66 return { name: tool, summary: str(input, 'query') ?? '' }
67 case 'Agent':
68 return { name: tool, summary: str(input, 'description') ?? '' }
69 case 'Skill':
70 return { name: tool, summary: str(input, 'skill') ?? '' }
71 default: {
72 const first = Object.values(input).find(v => typeof v === 'string')
73 return { name: tool, summary: typeof first === 'string' ? first : '' }
74 }
75 }
76}
77
78// Whitespace runs as one space, so a summary reads as one line of words.
79function flatten(text: string): string {
80 return text.replace(/\s+/g, ' ').trim()
81}
82
83// ---------------------------------------------------------------- toggle
84
85async function setEnabled($: EngineInterface, isEnabled: boolean): Promise<void> {
86 await update($, settingsAtom, () => ({ isEnabled }))
87 await $.store.set('isEnabled', isEnabled)
88}
89
90export const register: Register = on => {
91 on('session.start', async ($, e, next) => {
92 const stored = await $.store.get('isEnabled')
93 if (typeof stored === 'boolean') await update($, settingsAtom, () => ({ isEnabled: stored }))
94 await $.command.register({
95 name: 'inkstyle',
96 description: 'Turn the inkstyle reply colours on or off',
97 argumentHint: '[on|off]',
98 })
99 return next(e)
100 })
101
102 on('command.run', { command: 'inkstyle' }, async ($, e) => {
103 const arg = e.args.trim().toLowerCase()
104 const current = (await read($, settingsAtom)).isEnabled
105 const isEnabled = arg === 'on' ? true : arg === 'off' ? false : !current
106 await setEnabled($, isEnabled)
107 return { text: isEnabled ? 'inkstyle on' : "inkstyle off: replies use Claude Code's own look" }
108 })
109
110 // A reply's text, drawn from its markdown in the inkstyle palette.
111 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
112 if (e.surface !== 'terminal' || !(await read($, settingsAtom)).isEnabled) return next(e)
113 const el = $.ui.resolve({ ...e, surface: 'terminal' as const })
114 const { Box, Text } = el
115 const room = Math.max(20, (e.viewport?.columns ?? 80) - GUTTER - 1)
116 return (
117 <Box flexDirection="row">
118 <Box width={GUTTER} flexShrink={0}>
119 <Text color={ORANGE}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
120 </Box>
121 {drawMarkdown(e.props.text, room, el)}
122 </Box>
123 )
124 })
125
126 // A tool call's row: a status dot, the tool in purple, what it works on.
127 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
128 if (e.surface !== 'terminal' || !(await read($, settingsAtom)).isEnabled) return next(e)
129 const { Box, Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
130 const cwd = await $.session.cwd()
131 const row = describe(e.props.tool, e.props.input, cwd)
132 const dot = e.props.isInterrupted
133 ? GREY
134 : e.props.isErrored
135 ? RED
136 : e.props.isRunning
137 ? PURPLE_LIGHT
138 : PURPLE
139 const scope = row.scope === undefined ? '' : ` ${row.scope}`
140 const note = e.props.isInterrupted ? ' · interrupted' : ''
141 // Drawn whole, never cut: the row wraps in whatever width it is given.
142 const summary = row.isCommand === true ? row.summary : flatten(row.summary)
143 return (
144 <Box flexDirection="row">
145 <Box width={GUTTER} flexShrink={0}>
146 <Text color={dot}>{e.props.isRunning ? '○' : '●'}</Text>
147 </Box>
148 <Text>
149 <Text color={PURPLE} bold>
150 {row.name}
151 </Text>
152 {scope !== '' && <Text dimColor>{scope}</Text>}
153 {summary !== '' && <Text dimColor>{` ${summary}`}</Text>}
154 {note !== '' && <Text dimColor>{note}</Text>}
155 </Text>
156 </Box>
157 )
158 })
159}
160hooks/markdown.tsx 475 lines1import type { Elements, RenderNode } from 'claude-code'
2
3type El = Elements['terminal']
4type Inline = (RenderNode | string)[]
5
6// Palette shared with statusdeck and panes: orange for emphasis, purple for
7// structure. Body text keeps the terminal's own colour.
8export const ORANGE = '#F97316'
9export const ORANGE_BOLD = '#FB923C'
10export const ORANGE_LIGHT = '#FED7AA'
11export const PURPLE = '#A855F7'
12export const PURPLE_DEEP = '#7C3AED'
13export const PURPLE_LIGHT = '#C4B5FD'
14export const LAVENDER = '#D8B4FE'
15export const WHITE = '#FFFFFF'
16export const TABLE_HEAD = '#EA580C'
17export const ZEBRA = '#262626'
18export const RULE = '#3F3F46'
19export const BLACK = '#000000'
20
21type Ctx = { bold: string; color?: string }
22
23// ---------------------------------------------------------------- inline
24
25const INLINE: [RegExp, (m: RegExpExecArray, ctx: Ctx, el: El) => RenderNode][] = [
26 [/^(`+)([\s\S]+?)\1(?!`)/, (m, _ctx, { Text }) => <Text color={PURPLE_LIGHT}>{m[2] ?? ''}</Text>],
27 [
28 /^\*\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*\*/,
29 (m, ctx, el) => (
30 <el.Text bold italic color={ctx.bold}>
31 {inline(m[1] ?? '', ctx, el)}
32 </el.Text>
33 ),
34 ],
35 [
36 /^(\*\*|__)(?=\S)([\s\S]+?)(?<=\S)\1/,
37 (m, ctx, el) => (
38 <el.Text bold color={ctx.color ?? ctx.bold}>
39 {inline(m[2] ?? '', ctx, el)}
40 </el.Text>
41 ),
42 ],
43 [
44 /^~~(?=\S)([\s\S]+?)(?<=\S)~~/,
45 (m, ctx, el) => <el.Text strikethrough>{inline(m[1] ?? '', ctx, el)}</el.Text>,
46 ],
47 [
48 /^\*(?=[^\s*])([\s\S]+?)(?<=[^\s*])\*(?!\*)/,
49 (m, ctx, el) => <el.Text italic>{inline(m[1] ?? '', ctx, el)}</el.Text>,
50 ],
51 [
52 /^_(?=\S)([\s\S]+?)(?<=\S)_(?!\w)/,
53 (m, ctx, el) => <el.Text italic>{inline(m[1] ?? '', ctx, el)}</el.Text>,
54 ],
55 [
56 /^\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/,
57 (m, ctx, el) => (
58 <el.Link href={m[2] ?? ''}>
59 <el.Text color={LAVENDER} underline>
60 {inline(m[1] ?? '', ctx, el)}
61 </el.Text>
62 </el.Link>
63 ),
64 ],
65 [
66 /^<?(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"`*])>?/,
67 (m, _ctx, el) => (
68 <el.Link href={m[1] ?? ''}>
69 <el.Text color={LAVENDER} underline>
70 {m[1] ?? ''}
71 </el.Text>
72 </el.Link>
73 ),
74 ],
75]
76
77export function inline(text: string, ctx: Ctx, el: El): Inline {
78 const out: Inline = []
79 let plain = ''
80 let i = 0
81 while (i < text.length) {
82 const rest = text.slice(i)
83 const escaped = /^\\([\\`*_{}[\]()#+\-.!~|>])/.exec(rest)
84 if (escaped !== null) {
85 plain += escaped[1]
86 i += 2
87 continue
88 }
89 // Underscores and asterisks inside a word are text, not emphasis.
90 const prev = i > 0 ? text[i - 1] ?? '' : ''
91 const isWordEdge = !/\w/.test(prev)
92 let matched = false
93 for (const [re, make] of INLINE) {
94 if ((rest.startsWith('_') || rest.startsWith('*')) && !isWordEdge && !rest.startsWith('**')) break
95 if (rest.startsWith('http') && !isWordEdge) break
96 const m = re.exec(rest)
97 if (m === null) continue
98 if (plain !== '') out.push(plain)
99 plain = ''
100 out.push(make(m, ctx, el))
101 i += m[0].length
102 matched = true
103 break
104 }
105 if (!matched) {
106 plain += text[i]
107 i += 1
108 }
109 }
110 if (plain !== '') out.push(plain)
111 return out
112}
113
114// The text a cell shows, for measuring its width.
115export function plainText(text: string): string {
116 return text
117 .replace(/\[([^\]\n]+)\]\([^)\s]+(?:\s+"[^"]*")?\)/g, '$1')
118 .replace(/(\*\*\*|\*\*|__|~~)(?=\S)([\s\S]+?)(?<=\S)\1/g, '$2')
119 .replace(/(`+)([\s\S]+?)\1/g, '$2')
120 .replace(/(^|\W)[*_](?=\S)([\s\S]+?)(?<=\S)[*_](?!\w)/g, '$1$2')
121 .replace(/\\([\\`*_{}[\]()#+\-.!~|>])/g, '$1')
122}
123
124function width(text: string): number {
125 let n = 0
126 for (const ch of plainText(text)) {
127 const code = ch.codePointAt(0) ?? 0
128 // Wide East Asian characters and most emoji take two cells.
129 n += (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf) ||
130 (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) ||
131 (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) ||
132 (code >= 0x1f300 && code <= 0x1faff)
133 ? 2
134 : 1
135 }
136 return n
137}
138
139// How many rows a cell's text takes when word-wrapped at `room` columns.
140export function wrappedRows(text: string, room: number): number {
141 let rows = 1
142 let used = 0
143 for (const word of plainText(text).split(/\s+/).filter(w => w !== '')) {
144 const w = width(word)
145 if (used === 0) {
146 rows += Math.max(0, Math.ceil(w / room) - 1)
147 used = w % room === 0 ? room : w % room
148 } else if (used + 1 + w <= room) {
149 used += 1 + w
150 } else {
151 rows += Math.ceil(w / room)
152 used = w % room === 0 ? room : w % room
153 }
154 }
155 return rows
156}
157
158// ---------------------------------------------------------------- blocks
159
160type Item = { level: number; marker: string; isOrdered: boolean; text: string }
161type Align = 'left' | 'center' | 'right'
162type Block =
163 | { kind: 'heading'; level: number; text: string }
164 | { kind: 'para'; text: string }
165 | { kind: 'code'; lang: string; source: string }
166 | { kind: 'list'; items: Item[] }
167 | { kind: 'quote'; text: string }
168 | { kind: 'table'; head: string[]; rows: string[][]; align: Align[] }
169 | { kind: 'rule' }
170
171const FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`]*)[^`]*$/
172const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
173const RULE_LINE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
174const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
175const QUOTE = /^\s{0,3}>\s?(.*)$/
176const TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
177
178function cells(line: string): string[] {
179 let s = line.trim()
180 if (s.startsWith('|')) s = s.slice(1)
181 if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1)
182 const out: string[] = []
183 let cur = ''
184 let inCode = false
185 for (let i = 0; i < s.length; i++) {
186 const ch = s[i] ?? ''
187 if (ch === '\\' && s[i + 1] === '|') {
188 cur += '|'
189 i += 1
190 } else if (ch === '`') {
191 inCode = !inCode
192 cur += ch
193 } else if (ch === '|' && !inCode) {
194 out.push(cur.trim())
195 cur = ''
196 } else cur += ch
197 }
198 out.push(cur.trim())
199 return out
200}
201
202function isBlockStart(line: string, next: string | undefined): boolean {
203 return (
204 FENCE.test(line) || HEADING.test(line) || RULE_LINE.test(line) || ITEM.test(line) ||
205 QUOTE.test(line) || (line.includes('|') && next !== undefined && TABLE_SEP.test(next) && next.includes('-'))
206 )
207}
208
209export function parse(src: string): Block[] {
210 const lines = src.replace(/\r\n?/g, '\n').split('\n')
211 const out: Block[] = []
212 let i = 0
213 while (i < lines.length) {
214 const line = lines[i] ?? ''
215 const next = lines[i + 1]
216 if (line.trim() === '') {
217 i += 1
218 continue
219 }
220
221 const fence = FENCE.exec(line)
222 if (fence !== null) {
223 const mark = fence[2] ?? '```'
224 const body: string[] = []
225 i += 1
226 while (i < lines.length && !(lines[i] ?? '').trim().startsWith(mark)) {
227 body.push(lines[i] ?? '')
228 i += 1
229 }
230 i += 1
231 out.push({ kind: 'code', lang: fence[3] ?? '', source: body.join('\n') })
232 continue
233 }
234
235 const heading = HEADING.exec(line)
236 if (heading !== null) {
237 out.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' })
238 i += 1
239 continue
240 }
241
242 if (RULE_LINE.test(line)) {
243 out.push({ kind: 'rule' })
244 i += 1
245 continue
246 }
247
248 if (line.includes('|') && next !== undefined && TABLE_SEP.test(next) && next.includes('-')) {
249 const head = cells(line)
250 const align = cells(next).map(c =>
251 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
252 ) as Align[]
253 const rows: string[][] = []
254 i += 2
255 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
256 rows.push(cells(lines[i] ?? ''))
257 i += 1
258 }
259 out.push({ kind: 'table', head, rows, align })
260 continue
261 }
262
263 if (QUOTE.test(line)) {
264 const body: string[] = []
265 while (i < lines.length && QUOTE.test(lines[i] ?? '')) {
266 body.push(QUOTE.exec(lines[i] ?? '')?.[1] ?? '')
267 i += 1
268 }
269 out.push({ kind: 'quote', text: body.join('\n') })
270 continue
271 }
272
273 if (ITEM.test(line)) {
274 const items: Item[] = []
275 const indents: number[] = []
276 while (i < lines.length) {
277 const cur = lines[i] ?? ''
278 const m = ITEM.exec(cur)
279 if (m !== null) {
280 const indent = (m[1] ?? '').replace(/\t/g, ' ').length
281 // A top-level item of the other kind (bullets, numbers) starts a new list.
282 const first = items[0]
283 if (first !== undefined && indent <= (indents[0] ?? 0) && /\d/.test(m[2] ?? '') !== first.isOrdered) break
284 while (indents.length > 0 && (indents[indents.length - 1] ?? 0) > indent) indents.pop()
285 if (indents.length === 0 || (indents[indents.length - 1] ?? 0) < indent) indents.push(indent)
286 const marker = m[2] ?? '-'
287 items.push({ level: indents.length - 1, marker, isOrdered: /\d/.test(marker), text: m[3] ?? '' })
288 i += 1
289 continue
290 }
291 if (cur.trim() === '') {
292 // A blank line ends the list unless another item follows it.
293 let j = i + 1
294 while (j < lines.length && (lines[j] ?? '').trim() === '') j += 1
295 if (j < lines.length && ITEM.test(lines[j] ?? '')) {
296 i = j
297 continue
298 }
299 break
300 }
301 // An indented line continues the item above it.
302 if (/^\s+\S/.test(cur) && items.length > 0 && !isBlockStart(cur.trim(), lines[i + 1])) {
303 const last = items[items.length - 1]
304 if (last !== undefined) last.text += `\n${cur.trim()}`
305 i += 1
306 continue
307 }
308 break
309 }
310 out.push({ kind: 'list', items })
311 continue
312 }
313
314 const body: string[] = [line]
315 i += 1
316 while (i < lines.length && (lines[i] ?? '').trim() !== '' && !isBlockStart(lines[i] ?? '', lines[i + 1])) {
317 body.push(lines[i] ?? '')
318 i += 1
319 }
320 out.push({ kind: 'para', text: body.join('\n') })
321 }
322 return out
323}
324
325// ---------------------------------------------------------------- drawing
326
327// Headings are bold bright orange (the table-header orange), no background.
328const HEADINGS: Record<number, { color: string; underline?: boolean }> = {
329 1: { color: TABLE_HEAD, underline: true },
330 2: { color: TABLE_HEAD },
331 3: { color: TABLE_HEAD },
332}
333
334function drawTable(b: Extract<Block, { kind: 'table' }>, room: number, el: El): RenderNode {
335 const { Box, Text } = el
336 const count = Math.max(b.head.length, ...b.rows.map(r => r.length))
337 const widths: number[] = []
338 for (let c = 0; c < count; c++) {
339 widths.push(Math.max(3, width(b.head[c] ?? ''), ...b.rows.map(r => width(r[c] ?? ''))))
340 }
341 // Each cell has one cell of padding a side and one column rule between
342 // cells; shrink the widest column until the table fits.
343 while (widths.reduce((a, w) => a + w + 2, 0) + count - 1 > room) {
344 let at = 0
345 for (let c = 1; c < count; c++) if ((widths[c] ?? 0) > (widths[at] ?? 0)) at = c
346 if ((widths[at] ?? 0) <= 6) break
347 widths[at] = (widths[at] ?? 0) - 1
348 }
349 const justify = (a: Align | undefined) =>
350 a === 'right' ? 'flex-end' : a === 'center' ? 'center' : 'flex-start'
351
352 // The rule between columns spans the row's full height, so a row whose
353 // cells wrap keeps an unbroken line.
354 const row = (values: string[], isHead: boolean, bg: string | undefined) => {
355 const height = Math.max(1, ...widths.map((w, c) => wrappedRows(values[c] ?? '', w)))
356 const rule = Array.from({ length: height }, () => '│').join('\n')
357 const cellsOut: RenderNode[] = []
358 widths.forEach((w, c) => {
359 if (c > 0) {
360 cellsOut.push(
361 <Box width={1} flexShrink={0}>
362 <Text color={isHead ? BLACK : ORANGE}>{rule}</Text>
363 </Box>,
364 )
365 }
366 cellsOut.push(
367 <Box width={w + 2} paddingX={1} justifyContent={justify(b.align[c])}>
368 <Text bold={isHead} color={isHead ? WHITE : undefined} backgroundColor={bg} wrap="wrap">
369 {inline(values[c] ?? '', isHead ? { bold: WHITE, color: WHITE } : { bold: ORANGE_LIGHT }, el)}
370 </Text>
371 </Box>,
372 )
373 })
374 return (
375 <Box flexDirection="row" backgroundColor={bg}>
376 {cellsOut}
377 </Box>
378 )
379 }
380
381 return (
382 <Box flexDirection="column">
383 {row(b.head, true, TABLE_HEAD)}
384 {b.rows.map((r, n) => row(r, false, n % 2 === 1 ? ZEBRA : undefined))}
385 </Box>
386 )
387}
388
389function drawList(items: Item[], el: El): RenderNode {
390 const { Box, Text } = el
391 const counters: number[] = []
392 return (
393 <Box flexDirection="column">
394 {items.map(item => {
395 counters.length = item.level + 1
396 counters[item.level] = (counters[item.level] ?? 0) + 1
397 const start = /^\d+/.exec(item.marker)
398 if (item.isOrdered && counters[item.level] === 1 && start !== null) counters[item.level] = Number(start[0])
399 const task = /^\[([ xX])\]\s+/.exec(item.text)
400 const marker = item.isOrdered ? `${counters[item.level]}.` : '-'
401 const text = task !== null ? item.text.slice(task[0].length) : item.text
402 return (
403 <Box flexDirection="row" marginLeft={item.level * 2}>
404 <Text color={PURPLE} bold={item.isOrdered}>
405 {`${marker} `}
406 </Text>
407 {task !== null && <Text color={PURPLE}>{task[1] === ' ' ? '☐ ' : '☑ '}</Text>}
408 <Box flexShrink={1}>
409 <Text>{inline(text, { bold: ORANGE_LIGHT }, el)}</Text>
410 </Box>
411 </Box>
412 )
413 })}
414 </Box>
415 )
416}
417
418function drawBlock(b: Block, room: number, el: El): RenderNode {
419 const { Box, Code, Text } = el
420 switch (b.kind) {
421 case 'heading': {
422 const style = HEADINGS[b.level] ?? { color: TABLE_HEAD }
423 return (
424 <Text bold color={style.color} underline={style.underline}>
425 {inline(b.text, { bold: style.color }, el)}
426 </Text>
427 )
428 }
429 case 'para':
430 return <Text>{inline(b.text, { bold: ORANGE_LIGHT }, el)}</Text>
431 case 'code':
432 return (
433 <Box flexDirection="column">
434 <Text>
435 <Text color={PURPLE_DEEP}>▎</Text>
436 <Text dimColor>{` ${b.lang === '' ? 'code' : b.lang}`}</Text>
437 </Text>
438 <Box marginLeft={2}>
439 <Code source={b.source} language={b.lang === '' ? undefined : b.lang} />
440 </Box>
441 </Box>
442 )
443 case 'list':
444 return drawList(b.items, el)
445 case 'quote':
446 return (
447 <Box flexDirection="row">
448 <Text color={PURPLE_DEEP}>▎ </Text>
449 <Box flexShrink={1}>
450 <Text italic dimColor>
451 {inline(b.text, { bold: ORANGE_LIGHT }, el)}
452 </Text>
453 </Box>
454 </Box>
455 )
456 case 'table':
457 return drawTable(b, room, el)
458 case 'rule':
459 return <Text color={RULE}>{'─'.repeat(Math.max(3, Math.min(room, 60)))}</Text>
460 }
461}
462
463// A reply block: one blank row between blocks, as the engine spaces them.
464export function drawMarkdown(text: string, room: number, el: El): RenderNode {
465 const { Box } = el
466 const blocks = parse(text)
467 return (
468 <Box flexDirection="column" width={room}>
469 {blocks.map((b, n) => (
470 <Box marginTop={n === 0 ? 0 : 1}>{drawBlock(b, room, el)}</Box>
471 ))}
472 </Box>
473 )
474}
475types/index.d.ts 8 lines1export type Settings = { isEnabled: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 inkstyle: { settings: Settings }
6 }
7}
8