SLOPSHOPPER

inkstyle

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

newrowscommand
v0.1.0no licenseupdated 2026-10-16MoizAsif616/claude-code-utilities/mods/inkstyle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inkstyle
› fix the failing auth test and add an audit log call ⏺ 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. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /inkstyle ⎿ inkstyle: inkstyle off: replies use Claude Code's own look ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-utilities

Mods for Claude Code. Each mod has one job and works on its own: none imports, reads or calls another.

ModIts job
mods/statusdeckThe status row under the prompt: live stats and their colours
mods/inkstyleColours inside Claude's replies and on tool-call rows
mods/prettierThe 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.

Install

Windows (PowerShell)

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')

Linux (Ubuntu)

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.

macOS

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.

Load order: prettier goes first

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.

statusdeck

The row under the prompt, with every colour in it:

  • Mode, model and effort at the left. A click on the model or the effort opens Claude Code's picker for it.
  • Usage pills at the right: the 5-hour and 7-day rate-limit windows and the context fill, each coloured from blue (little used) through green and amber to red (nearly full), with the time until a window resets.
  • Cache warmth: ⚡ 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.
  • While Claude works: the turn's running time and live output tokens after the effort.
  • Pane chips above the prompt when the panes mod is installed.

statusdeck has no commands of its own. It doesn't draw the spinner; prettier does.

inkstyle

Colours only, inside Claude's replies and on tool-call rows:

  • Replies: orange headings and bold, lavender links, purple list and quote markers, and tables with orange headers and striped rows.
  • Tool-call rows: a status dot (purple when done, light purple while running, red when it failed, grey when interrupted), the tool's name in purple and what it worked on in grey. The command or path is shown whole, never cut.
CommandWhat it does
/inkstyleon, off, or no argument to switch between them

prettier

The layout of the chat. What it changes:

  • Your prompts: a pill with a rounded purple border on the right, one space of padding on the left, at most 70% of the terminal wide, with the time you sent it under it. A long prompt shows its first 6 lines, then ▸ 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.
  • Replies and tool-call rows: held to the left 85% of the terminal (needs the load order).
  • Code blocks in replies: each fenced block is a charcoal panel with a thin violet rounded border. Its top row holds the language on a lighter chip at the left (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.
  • Edits: +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.
  • Reads, searches and commands: a run of them folds to one row, ▸ 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.
  • Failures: a failed tool's error in a red frame, ✗ 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.
  • Rows you didn't type: background tasks finishing and messages from other agents, sessions or channels show as a quiet card on the left (⚙ Background task · completed · 2m 2s), not as your pill.
  • End of a reply: 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.
  • Spinner: drawn whole by prettier, a purple animated ⠋ and Claude Code's word, or the tool running now: ⠋ ▸ running Bash….

Opening folded rows

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.

Commands

CommandSettings (defaults)
/prettierlists the commands; on, off, reset (every setting back to its default)
/prettier-promptwidth 70 (% of the terminal), lines 6 (shown before folding), time on
/prettier-responsewidth 85 (% of the terminal; also the most a code panel or red frame takes), fold on (fold runs of reads, searches and commands)
/prettier-codewidth 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.

Known limits

  • Tool output and progress text that Claude Code draws itself run the full terminal width: Claude Code refuses a mod that puts its own drawing inside a fixed width.
  • inkstyle sizes tables for the full terminal width; inside the 85% box their columns are squeezed, so a wide table's │ lines may come out a little short.
  • Times and tool lists only appear for prompts and replies made after the mod loaded, not for resumed sessions.
  • The dim ● <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.

Update

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.

Notes

  • The types/ and tests/ folders inside each mod are for development and aren't needed to run the mods.
  • Tested on Windows. The code has nothing Windows-specific, so it should work on Linux and macOS, but I haven't verified that.
Source 3 files
hooks/register.tsx 160 lines
1import { 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}
160
hooks/markdown.tsx 475 lines
1import 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}
475
types/index.d.ts 8 lines
1export type Settings = { isEnabled: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    inkstyle: { settings: Settings }
6  }
7}
8