Draw mermaid fences in assistant replies as ASCII diagrams

Dotfiles repository for managing shell and tool configurations across macOS and Linux systems using Nix + Home Manager with Flakes.
~/.config/# Install Nix (using Determinate Systems installer)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
# Clone the repository
git clone https://github.com/kokatsu/dotfiles.git ~/dotfiles
cd ~/dotfiles
# Build and activate Home Manager configuration
DOTFILES_DIR="$PWD" nix run home-manager -- switch --flake . --impure
# User environment (packages / dotfiles) on both Linux and macOS.
# Set this to the actual repository path on each PC.
DOTFILES_DIR="$HOME/dotfiles" home-manager switch --flake "$HOME/dotfiles" --impure
# macOS system settings + Homebrew (quit Chrome first: casks get upgraded in place)
sudo HOSTNAME=$(hostname -s) DOTFILES_DIR="$HOME/dotfiles" darwin-rebuild switch --flake "$HOME/dotfiles" --impure
nix flake update
deno run --allow-all scripts/update-deno-deps.ts --all
DOTFILES_DIR="$HOME/dotfiles" home-manager switch --flake "$HOME/dotfiles" --impure
nix develop # Enter development shell with every linter / formatter
just check # Format check, lint, typos, script tests, and flake evaluation
just fmt # Format everything (Nix, Lua, shell, TOML, YAML, JSON, TypeScript)
just # List all recipes
Lefthook runs a subset of these on commit and push (formatters, linters, gitleaks, commitlint); the script tests and flake evaluation only run through just check and CI.
CI builds the flake checks on Linux x86_64, Linux ARM64, and macOS ARM64. Linux checks also build a WSL Home Manager configuration and verify its generated watch services, timers, and command paths. They do not start WSL or activate the configuration on Windows. MoonBit is installed only on Linux x86_64 and macOS ARM64 because the pinned MoonBit overlay does not support Linux ARM64.
.
โโโ flake.nix # Flake outputs and Home Manager/nix-darwin builders
โโโ flake.lock # Locked dependencies for reproducibility
โโโ justfile # Task runner: checks, formatters, tests
โโโ nix/
โ โโโ home/ # Home Manager modules (packages, files, programs, services)
โ โโโ darwin/ # macOS system and Homebrew configuration
โ โโโ overlays/ # Custom packages and upstream workarounds
โโโ bin/ # User scripts, linked to ~/.local/bin/scripts and on PATH
โโโ scripts/ # Test and helper scripts used by just and CI
โโโ karabiner-config/ # Karabiner-Elements rules written with karabiner.ts
โโโ .config/ # Dotfile sources linked by Home Manager
โโโ zsh/ # Zsh shell configuration
โโโ nvim/ # Neovim configuration
โโโ wezterm/ # WezTerm terminal configuration
โโโ claude/ # Claude Code settings, hooks, and skills
โโโ ... # Other tool configurations
Static application settings live in nix/home/programs/. Home Manager generates Git, Ghostty, ripgrep, Starship, Yazi, Biome, markdown-oxide, ov, Taplo, psql, Vim, Claude Code keybindings, and the Codex base configuration from Nix. Edit these modules, then run home-manager switch --flake . --impure to apply them.
Yazi plugins come from nixpkgs yaziPlugins where available; the remaining plugins and flavors are vendored under .config/yazi/. All are installed through programs.yazi; update them via nix flake update or in Git instead of using ya pkg. Yazi's init.lua and git-changes.sh also need home-manager switch --flake . --impure after editing to apply the changes. Lua, shell scripts, and repository tooling configurations remain under .config/.
Neovim's lazy.nvim, peek.nvim, blink.cmp, Pkl plugin, and Tree-sitter parsers are built by Nix and linked under ~/.local/share/nvim/nix-plugins/. Other Lua plugins still use lazy-lock.json and :Lazy. zeno.zsh uses fixed Deno/npm dependencies from the Nix store. Ruby LSP and its Rails/RuboCop gems are fixed in nix/ruby/ruby-lsp/; Neovim uses RUBY_LSP_COMMAND to avoid a user-installed gem taking precedence. Karabiner's managed Default profile is built by Nix, then merged into the writable JSON while retaining other profiles and GUI settings.
Renovate PRs regenerate Deno locks/hashes and Ruby gemsets in pr.yml.
Claude Code's settings.json keeps its writable repository link. Codex receives a writable copy of its generated configuration and preserves the existing local state sections during activation.
MIT. Third-party notices are listed in THIRD_PARTY_LICENSES.md.
hooks/register.tsx 168 lines1import type { Register } from 'claude-code';
2
3const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
4
5type Diagram = { start: number; end: number; source: string };
6
7// Tracks every fence, not just mermaid ones: a mermaid fence quoted inside
8// an outer fence is an example to copy, not a diagram to draw.
9export function findDiagrams(text: string): Diagram[] {
10 const diagrams: Diagram[] = [];
11 let open:
12 | { marker: string; start: number; body: number; isMermaid: boolean }
13 | undefined;
14 let at = 0;
15 for (const line of text.split('\n')) {
16 const lineEnd = at + line.length;
17 const fence = FENCE.exec(line);
18 if (fence) {
19 const [, marker = '', info = ''] = fence;
20 if (!open) {
21 if (!(marker[0] === '`' && info.includes('`'))) {
22 const isMermaid = info.trim().split(/\s/)[0] === 'mermaid';
23 open = { marker, start: at, body: lineEnd + 1, isMermaid };
24 }
25 } else if (
26 marker[0] === open.marker[0] &&
27 marker.length >= open.marker.length &&
28 info.trim() === ''
29 ) {
30 if (open.isMermaid) {
31 const source = text.slice(open.body, at);
32 diagrams.push({ start: open.start, end: lineEnd, source });
33 }
34 open = undefined;
35 }
36 }
37 at = lineEnd + 1;
38 }
39 return diagrams;
40}
41
42// The transcript indents a reply past its bullet, so the full width would wrap.
43const INDENT = 4;
44
45export const register: Register = (on) => {
46 // A streaming reply redraws on every flush; each diagram renders once. A
47 // diagram the tool rejects stays cached as undefined, but a run that could
48 // not finish (binary not installed yet, timeout) is dropped so a redraw
49 // retries it.
50 const cache = new Map<string, Promise<string | undefined>>();
51 let bullet: Promise<string> | undefined;
52
53 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
54 const { text } = e.props;
55 const columns = e.viewport ? e.viewport.columns - INDENT : undefined;
56
57 const toAscii = (source: string) => {
58 const key = `${columns}\0${source}`;
59 let ascii = cache.get(key);
60 if (!ascii) {
61 const width = columns ? ['--max-width', String(columns)] : [];
62 ascii = $.process
63 .run(['mermaid-ascii', '-f', '-', ...width], { stdin: source })
64 .then((r) => (r.exitCode === 0 ? r.stdout.trimEnd() : undefined));
65 ascii.catch(() => cache.delete(key));
66 cache.set(key, ascii);
67 }
68 return ascii;
69 };
70
71 const diagrams = findDiagrams(text);
72 const rendered = await Promise.all(diagrams.map((d) => toAscii(d.source)));
73 if (rendered.every((r) => r === undefined)) return next(e);
74
75 const { Box, Text } = $.ui.resolve(e);
76 const parts: Array<{ markdown: string } | { ascii: string }> = [];
77 let markdown = '';
78 let at = 0;
79 diagrams.forEach((d, i) => {
80 const ascii = rendered[i];
81 if (ascii === undefined) return;
82 markdown += text.slice(at, d.start);
83 if (markdown.trim()) parts.push({ markdown });
84 parts.push({ ascii });
85 markdown = '';
86 at = d.end;
87 });
88 markdown += text.slice(at);
89 if (markdown.trim()) parts.push({ markdown });
90
91 // Prose goes back through the engine so it keeps the reply's bullet and
92 // spacing. Only the first block draws the bullet, and the engine indents no
93 // block after it, so those take the bullet's width as padding.
94 const drawn = await Promise.all(
95 parts.map(async (p, i) => {
96 if ('markdown' in p) {
97 const isFirst = e.props.isFirstOfReply && i === 0;
98 const prose = await next({
99 ...e,
100 props: {
101 ...e.props,
102 text: p.markdown.replace(/^\s*\n/, '').trimEnd(),
103 isFirstOfReply: isFirst,
104 },
105 });
106 return i === 0 ? prose : <Box paddingLeft={2}>{prose}</Box>;
107 }
108 const diagram = (
109 <Box flexDirection="column">
110 {p.ascii.split('\n').map((line) => (
111 <Text wrap="truncate-end">
112 {runs(line).map((r) => (
113 <Text {...PAINT[r.kind]}>{r.text}</Text>
114 ))}
115 </Text>
116 ))}
117 </Box>
118 );
119 if (!(e.props.isFirstOfReply && i === 0)) {
120 return (
121 <Box paddingLeft={2} marginTop={1}>
122 {diagram}
123 </Box>
124 );
125 }
126 // The engine draws no bullet for an empty block, so a reply that opens
127 // with a diagram draws its own, using the glyph the engine picks per OS.
128 bullet ??= $.process
129 .run(['uname'])
130 .then((r) => (r.stdout.trim() === 'Darwin' ? 'โบ' : 'โ'));
131 return (
132 <Box flexDirection="row" marginTop={1}>
133 <Box width={2}>
134 <Text color="text">{await bullet}</Text>
135 </Box>
136 {diagram}
137 </Box>
138 );
139 }),
140 );
141 return <Box flexDirection="column">{drawn}</Box>;
142 }).catch((_$, e, next) => next(e));
143};
144
145type Kind = 'border' | 'arrow' | 'label';
146
147const PAINT: Record<Kind, { color?: string; bold?: boolean }> = {
148 border: { color: 'subtle' },
149 arrow: { color: 'claude' },
150 label: { bold: true },
151};
152
153function kindOf(ch: string): Kind {
154 if (/[โบโโฒโผ]/.test(ch)) return 'arrow';
155 return /[โ-โฟ]/.test(ch) ? 'border' : 'label';
156}
157
158function runs(line: string): Array<{ kind: Kind; text: string }> {
159 const out: Array<{ kind: Kind; text: string }> = [];
160 for (const ch of line) {
161 const kind = ch === ' ' ? (out.at(-1)?.kind ?? 'label') : kindOf(ch);
162 const last = out.at(-1);
163 if (last?.kind === kind) last.text += ch;
164 else out.push({ kind, text: ch });
165 }
166 return out;
167}
168