SLOPSHOPPER

preview

Render a markdown file in a side pane, with mermaid diagrams as text

newpanecommandprocesstimer
★ 3v0.1.0no licenseupdated 2026-10-09puffin/dotfiles/config/claude-mods/preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · preview
│ ┃ README.md ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ README.md /work/app r: ↻ reloa │ ┃ ⏺ Read(src/auth.ts) │ ┃ app ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ An example service. Run bun test to test. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ctrl+x tab to focus · r reload · c or esc │ ┃ close ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /preview │ ⎿ preview: Previewing README.md. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · README.md
◆ README.md /work/app r: ↻ reload c: close app An example service. Run bun test to test. ctrl+x tab to focus · r reload · c or esc close
README

Dotfiles

A collection of neovim, tmux, and zsh configurations for macOS. Built for DevOps workflows with Terraform, Python, YAML, and JSON.

light theme

Contents

Setup and Installation

Clone the dotfiles repository to your home directory as ~/.dotfiles.

git clone https://github.com/puffin/dotfiles.git ~/.dotfiles
cd ~/.dotfiles

Backup

Run install/backup.sh to back up any existing symlinked files to ~/dotfiles-backup. The installation scripts will not overwrite existing files.

Installation

Install XCode CLI tools and Homebrew first:

xcode-select --install

Follow instructions at https://brew.sh/ to install Homebrew, then:

./install.sh

This will:

  • Symlink all *.symlink files to your home directory (e.g. zshrc.symlink becomes ~/.zshrc)
  • Symlink the config directory contents to ~/.config/
  • Install Homebrew packages from Brewfile
  • Run macOS-specific configurations via install/osx.sh, including remapping Caps Lock to Control on every keyboard (via hidutil, persisted with a LaunchAgent)

Uninstallation

./uninstall.sh

Reverses the above: removes the symlinks (only if they still point at this repo), reverts the shell change, uninstalls exactly the packages/casks/taps listed in Brewfile, clears zinit/fzf/tf-helper/nvim/tmux-plugin caches, and deletes the specific macOS defaults keys install/osx.sh set. It prompts for confirmation before doing anything, since most of it is destructive, and it deliberately leaves ~/.ssh, ~/.gnupg (besides the generated gpg-agent.conf), and any nvim/tmux session history alone, since those can hold data of your own. Note claude-code is itself a Brewfile cask, so it gets uninstalled too.

Terminal Capabilities

To support italic fonts in tmux:

tic -x resources/xterm-256color-italic.terminfo
tic -x resources/tmux.terminfo

ZSH Setup

ZSH is configured in zshrc.symlink. Key features:

  • EDITOR set to nvim
  • Zinit plugin manager for zsh plugins
  • Sources ~/.localrc for machine-specific config (API keys, etc.)
  • Custom prompt with git status on RPROMPT

Git Prompt Symbols

  • + New files added
  • ! Existing files modified
  • ? Untracked files
  • >> Files renamed
  • ✘ Tracked file deleted
  • $ Stashed files
  • = Unmerged files
  • ⇡ Branch ahead of remote
  • ⇣ Branch behind remote
  • ⇕ Branches diverged
  • ✔ Working directory clean

Neovim Setup

Neovim is configured entirely in Lua with the following structure:

~/.config/nvim/
├── init.lua                  -- Entry point
└── lua/user/
    ├── options.lua           -- Editor settings
    ├── keymaps.lua           -- Key mappings
    ├── autocmds.lua          -- Autocommands
    ├── plugins.lua           -- Plugin declarations (lazy.nvim)
    └── lsp.lua               -- LSP server configuration

Plugins are managed by lazy.nvim and installed automatically on first launch. Run :Lazy inside neovim to manage plugins.

LSP and Autocompletion

Language servers are managed by Mason and configured via Neovim's native vim.lsp.config (0.11+). Autocompletion is powered by blink.cmp.

LanguageServerFeatures
TerraformterraformlsCompletions, diagnostics, prefill required fields
Pythonpyright + ruffPyright for completions/types, Ruff for linting/formatting
YAMLyamllsSchema-aware completions (K8s, Docker Compose, GitHub Actions, etc.)
JSONjsonlsSchema-aware completions (package.json, tsconfig, etc.)

Schemas are provided by SchemaStore.nvim (300+ schemas).

Note: For Terraform, run terraform init in each project directory for provider-aware completions.

LSP Keymaps

KeyAction
gdGo to definition
gyGo to type definition
giGo to implementation
grFind references
KShow documentation
<leader>rnRename symbol
<leader>caCode action

Completion Keymaps

KeyAction
TabNext completion
S-TabPrevious completion
CRConfirm selection
C-SpaceTrigger / toggle docs
C-eDismiss completion
C-b / C-fScroll documentation

Diagnostics

Diagnostics show inline virtual text, gutter signs, and underlines. Holding the cursor on an error line auto-opens a floating window with the full message.

Plugins

UI: vim-one (colorscheme), lualine (statusline), nvim-web-devicons, vim-smoothie (smooth scrolling)

Editor: vim-surround, vim-repeat, vim-unimpaired, vim-sleuth, vim-abolish, Comment.nvim, splitjoin.vim, nvim-autopairs, editorconfig

Git: fugitive, gitsigns, diffview.nvim, vim-flog, vim-twiggy

Navigation: FZF (files, buffers, ripgrep), nvim-tree (file explorer)

Session: vim-obsession + vim-prosession (auto-save/restore sessions)

Syntax: Treesitter with parsers for Terraform, HCL, Python, TypeScript, JSON, YAML, Lua, and more

Tmux Configuration

Tmux is configured in ~/.tmux.conf with prefix set to control+a. Sessions are automatically saved every minute via tmux-continuum and restored on tmux start via tmux-resurrect.

Tmux Commands

KeyAction
prefix + IInstall plugins
prefix + UUpdate plugins
prefix + wWindow/pane selection
prefix + cNew window
prefix + ,Rename window
prefix + &Kill window
prefix + [1-9]Select window
prefix + -Split vertically
`prefix + \`Split horizontally
prefix + xKill pane
prefix + [h,j,k,l]Move to pane
prefix + zToggle pane fullscreen
prefix + shift + [h,j,k,l]Resize pane

Herdr Configuration

Herdr is an agent-aware terminal multiplexer - it covers the same sessions/windows/panes ground as tmux, plus status tracking for AI coding agents (Claude Code, Codex, etc.) running in its panes. It's configured in ~/.config/herdr/config.toml with the same control+a prefix as tmux, so the muscle memory carries over. Both tools are installed; use either as your daily driver, or reach for herdr specifically when running coding agents you want to keep tabs on. Sessions persist across restarts and reattaches natively, with no plugin manager needed.

Herdr Commands

KeyAction
prefix + wWorkspace/agent picker
prefix + cNew tab
prefix + shift + tRename tab
prefix + shift + xClose tab
prefix + [1-9]Select tab
alt + [1-9]Select tab (no prefix)
prefix + minusSplit stacked
`prefix + \`Split side-by-side
prefix + xClose pane
prefix + [h,j,k,l]Move to pane
prefix + zToggle pane fullscreen
prefix + shift + [h,j,k,l]Swap pane
prefix + rResize pane mode
prefix + [Copy mode (vim-style)
ctrl + shift + [left,right]Reorder current tab
prefix + qDetach

Herdr Plugins

Installed automatically by install.sh via herdr plugin install (source lives outside this repo under ~/.config/herdr/plugins/, gitignored - not vendored). The marketplace is a self-tagged, unreviewed GitHub index; these were picked and their READMEs checked by hand, not exhaustively vetted against the ~1000 plugins listed there.

PluginWhat it doesKey
herdr-auto-titleRenames tabs to match what's running in them(automatic)
vim-herdr-navigationctrl+h/j/k/l crosses seamlessly between herdr panes and Neovim splits (vim-tmux-navigator, ported to herdr)ctrl + [h,j,k,l]
herdr-reviewrDiff/review pane - comment on an agent's changes, send feedback back to itprefix + shift + c
herdr-sessionizerFuzzy-open projects/worktrees, bootstrap a workspace layout from TOMLprefix + shift + s

herdr-sessionizer needs bun to build (in the Brewfile via the oven-sh/bun tap).

Terminal Configuration

Terminal of choice is Alacritty. Configuration is in config/alacritty/alacritty.yml.

Fonts

SauceCodePro NF, installed via Homebrew.

Color Scheme

vim-one in light mode. Comments are displayed in light grey italic.

Toggle Light/Dark

Press Ctrl+x Ctrl+t to toggle between light and dark themes. This works in both neovim and the shell, and switches all three simultaneously:

  • Neovim colorscheme (vim-one light/dark)
  • Alacritty terminal colors
  • Tmux status bar

You can also run toggle-theme from the command line, optionally with light or dark as an argument.

Dark Theme

dark theme

Claude Code

Claude Code is integrated into Neovim via the claudecode.nvim plugin, providing an in-editor AI assistant panel.

Claude Code Keymaps

Leader key is Space.

KeyAction
<leader>acToggle Claude Code panel
<leader>asSend selection to Claude
<leader>aaAdd current file to Claude
<C-w>Navigate away from terminal (e.g. Claude panel)

Claude Code CLI Hooks

config/claude-hooks/ holds portable Claude Code hook scripts, symlinked into ~/.claude/hooks/ by install/link.sh. ~/.claude/settings.json itself is not tracked here (it's inherently per-machine — permissions, plugins, org-specific config), so after installing, register a hook manually in its hooks block, e.g.:

"hooks": {
    "PreCompact": [
        {
            "matcher": "auto",
            "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/precompact-nudge.sh" }]
        }
    ]
}
  • precompact-nudge.sh — fires only on automatic compaction and prints a visible reminder to /clear instead if you're switching to an unrelated task, rather than letting one session run indefinitely.

Claude Code Mods

config/claude-mods/ holds Claude Code mods (plugins of function hooks). zsh/config.zsh exports CLAUDE_CODE_PLUGIN_DIRS with every folder there that has a .claude-plugin/, so each claude launched from a shell loads them; no settings.json change is needed. Interactive sessions watch these folders, so editing a mod reloads it live. Check one with claude plugin validate config/claude-mods/<name>.

  • context-gauge — context fill as a bar at the end of the prompt hint line (ctx ▰▱▱▱▱▱▱▱▱▱ 6% · 62k), plus a band above the prompt from 50% of the window (red from 75%) suggesting /clear before switching tasks. Thresholds are WARN_PCT / HOT_PCT in hooks/register.tsx.
  • git — a status-line entry with the folder, git branch and the branch's PR checks and review (~/.dotfiles ⎇ my-branch · #31 ✓5 ✗1 ●2 approved). PR status comes from gh and is polled every minute.
  • preview — /preview [file.md] (default README.md) renders a markdown file in a side pane: markdown through glow with One Dark / One Light styles (styles/*.json) that follow bin/toggle-theme, and mermaid blocks drawn as text diagrams by termaid (both in the Brewfile). Falls back to Claude Code's own markdown renderer, or the mermaid source, when either tool is missing.
  • runwatch — watches Terrakube jobs, Jenkins builds and PR checks in a side pane and toasts when each finishes (Terrakube shows the plan summary, e.g. plan: +2 ~0 -1, and toasts when a job waits for approval). Runs started by terrakube.sh run … --confirm, jenkins.sh trigger, gh pr create or git push are picked up automatically; /watch tk|jenkins|pr … adds one by hand, and Claude can hand one off through the mod's watch tool, which wakes it with the result instead of it polling. Polls every 20s through the ge-cloudops skills' wrapper scripts, so credentials stay in them.
  • write-gate — holds every Jira/Confluence write (comment, transition, edit, create, link, worklog, page), Lucid comment/share/update, git push and gh pr create|merge until you pick Post it. The exact payload (and, for a push, the commits no remote has) shows in a pane; Reject, or anything typed under Other, blocks it and tells Claude why. Fails closed, and logs each decision to ~/.claude/write-gate.log.

Usage

Vim Quick Reference

Leader key is Space.

KeyAction
<leader>kToggle file explorer (see explorer keymaps)
<leader>stStart screen
<leader>bClose buffer (keep split)
<leader>tGit file finder
<leader>eAll files finder
<leader>rBuffer finder
<leader>sGit status files
:RgRipgrep search
<leader>gsGit status
<leader>gdGit 3-way diff
gdh / gdlTake left/right in diff
<leader>dvoOpen Diffview
<leader>dvcClose Diffview
<leader>dvhDiffview file history
]g / [gNext/previous git hunk
gsPreview git hunk
guReset git hunk
gc / gccComment toggle

File Explorer Keymaps

The file explorer (nvim-tree) uses coc-explorer-style keybindings. Confirmations (y/n) are single-keypress — no Enter needed.

KeyAction
yyCopy file/directory (toggle, visual mode supported)
ddCut file/directory (toggle, visual mode supported)
pPaste from clipboard
dfDelete file/directory (trash)
dFDelete permanently
ypCopy absolute path to system clipboard
ynCopy filename to system clipboard
ACreate new directory
aCreate new file
EOpen in vertical split
VVisual select (then yy/dd/df for multi-file operations)

Copied files are highlighted in green, cut files in red with strikethrough.

Zsh Shortcuts

KeyAction
Alt + Right/LeftMove one word forward/backward
Cmd + Right/LeftMove to end/beginning of line
Alt + DDelete word after cursor
Alt + BackspaceDelete word before cursor
Ctrl + UClear entire line
Ctrl + RCommand history
Ctrl + TFile history

Troubleshooting

If you encounter permission errors during installation:

sudo chown -R $(whoami):admin /usr/local/
sudo chmod -R 755 /usr/local

Questions

If you have questions or notice issues, please open an issue.

Source 2 files
hooks/register.tsx 334 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Doc, Theme } from '../types'
5
6const PANE = 'preview'
7// A Markdown element takes at most 10000 characters.
8const CHUNK_MAX = 9000
9
10const doc = atom({ plugin: 'preview', key: 'doc' } as const, null)
11const theme = atom({ plugin: 'preview', key: 'theme' } as const, 'dark')
12
13let home: string | null = null
14
15async function homeDir($: EngineInterface) {
16  if (home === null) home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
17  return home
18}
19
20async function readTheme($: EngineInterface): Promise<Theme> {
21  const text = await $.fs.read(THEME_FILE.replace('~', await homeDir($))).catch(() => '')
22  return text.includes('one-light') ? 'light' : 'dark'
23}
24
25// Named terminal colors, so the pane follows the terminal's light/dark theme.
26const ACCENT = 'cyan'
27const MUTED = 'gray'
28const BAR_BG = 'blackBright'
29// glow's theme follows bin/toggle-theme, which copies the live theme here.
30const THEME_FILE = '~/.dotfiles/config/alacritty/theme-current.toml'
31const THEME_POLL_MS = 3000
32// glow styles shipped with the mod, built from the One Dark / One Light palettes.
33const GLOW_STYLE = { dark: 'styles/one-dark.json', light: 'styles/one-light.json' } as const
34
35async function load(path: string, readFile: (path: string) => Promise<string>): Promise<Doc> {
36  return readFile(path)
37    .then(text => ({ path, text }))
38    .catch(() => ({ path, error: `Could not read ${path}` }))
39}
40
41const basename = (path: string) => path.split('/').pop() || path
42// The folder, kept short for the header: its last two parts when it is long.
43function dirname(path: string) {
44  const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.'
45  if (dir.length <= 32) return dir
46  return `…/${dir.split('/').slice(-2).join('/')}`
47}
48
49type Segment = { kind: 'markdown'; text: string } | { kind: 'mermaid'; source: string }
50
51// Mermaid fences become their own segments; the markdown between them is cut
52// at blank lines outside code fences so no block is split in half.
53function segment(text: string) {
54  const segments: Segment[] = []
55  let current = ''
56  let fence: 'mermaid' | 'code' | null = null
57  let mermaid = ''
58
59  const flush = () => {
60    if (current.trim()) segments.push({ kind: 'markdown', text: current.slice(0, CHUNK_MAX) })
61    current = ''
62  }
63
64  for (const line of text.split('\n')) {
65    const isFence = /^\s*(```|~~~)/.test(line)
66    if (fence === null && isFence && /^\s*(```|~~~)\s*mermaid\b/.test(line)) {
67      flush()
68      fence = 'mermaid'
69      mermaid = ''
70      continue
71    }
72    if (fence === 'mermaid') {
73      if (isFence) {
74        segments.push({ kind: 'mermaid', source: mermaid })
75        fence = null
76      } else {
77        mermaid += `${line}\n`
78      }
79      continue
80    }
81    if (isFence) fence = fence === null ? 'code' : null
82    if (fence === null && line.trim() === '' && current.length > CHUNK_MAX * 0.8) flush()
83    current += `${line}\n`
84  }
85  flush()
86  return segments
87}
88
89// Rendered diagrams by width and source: termaid is a process, run once each.
90const diagrams = new Map<string, string | null>()
91
92async function diagram($: EngineInterface, source: string, width: number) {
93  const key = `${width}\n${source}`
94  if (!diagrams.has(key)) {
95    const { exitCode, stdout } = await $.process
96      .run(['termaid', '--width', String(width), '--gap', '2', '--padding-y', '1'], {
97        stdin: source,
98        timeoutMs: 10_000,
99      })
100      .catch(() => ({ exitCode: 1, stdout: '' }))
101    diagrams.set(key, exitCode === 0 && stdout.trim() ? stdout.replace(/\n+$/, '') : null)
102  }
103  return diagrams.get(key) ?? null
104}
105
106// glow prints ANSI escapes, which Text refuses: parse its SGR codes into
107// styled spans and drop the rest (OSC 8 links and other sequences).
108type Span = { text: string; color?: string; backgroundColor?: string; bold?: boolean; italic?: boolean; underline?: boolean }
109
110const NAMED = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
111
112function color256(n: number) {
113  // The 16 base colors stay named, so they follow the terminal's theme.
114  if (n < 8) return NAMED[n]
115  if (n < 16) return `${NAMED[n - 8]}Bright`
116  const hex = (v: number) => v.toString(16).padStart(2, '0')
117  if (n >= 232) {
118    const v = 8 + (n - 232) * 10
119    return `#${hex(v)}${hex(v)}${hex(v)}`
120  }
121  const c = n - 16
122  const level = (v: number) => (v === 0 ? 0 : 55 + v * 40)
123  return `#${hex(level(Math.floor(c / 36)))}${hex(level(Math.floor(c / 6) % 6))}${hex(level(c % 6))}`
124}
125
126function applySgr(style: Omit<Span, 'text'>, params: string) {
127  const codes = params === '' ? [0] : params.split(';').map(Number)
128  let next = { ...style }
129  for (let i = 0; i < codes.length; i++) {
130    const code = codes[i]
131    if (code === 0) next = {}
132    else if (code === 1) next.bold = true
133    else if (code === 3) next.italic = true
134    else if (code === 4) next.underline = true
135    else if (code === 22) next.bold = false
136    else if (code === 23) next.italic = false
137    else if (code === 24) next.underline = false
138    else if (code === 39) next.color = undefined
139    else if (code === 49) next.backgroundColor = undefined
140    else if (code >= 30 && code <= 37) next.color = NAMED[code - 30]
141    else if (code >= 90 && code <= 97) next.color = `${NAMED[code - 90]}Bright`
142    else if (code >= 40 && code <= 47) next.backgroundColor = NAMED[code - 40]
143    else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
144      const value = color256(codes[i + 2])
145      if (code === 38) next.color = value
146      else next.backgroundColor = value
147      i += 2
148    } else if ((code === 38 || code === 48) && codes[i + 1] === 2) {
149      const hex = (v: number) => (v || 0).toString(16).padStart(2, '0')
150      const value = `#${hex(codes[i + 2])}${hex(codes[i + 3])}${hex(codes[i + 4])}`
151      if (code === 38) next.color = value
152      else next.backgroundColor = value
153      i += 4
154    }
155  }
156  return next
157}
158
159const sameStyle = (a: Omit<Span, 'text'>, b: Omit<Span, 'text'>) =>
160  a.color === b.color && a.backgroundColor === b.backgroundColor && !!a.bold === !!b.bold &&
161  !!a.italic === !!b.italic && !!a.underline === !!b.underline
162
163function parseAnsi(output: string) {
164  const clean = output
165    .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC: hyperlinks, titles
166    .replace(/\r/g, '')
167  return clean.split('\n').map(line => {
168    const spans: Span[] = []
169    let style: Omit<Span, 'text'> = {}
170    const parts = line.split(/(\x1b\[[0-9;]*[A-Za-z])/)
171    for (const part of parts) {
172      const sgr = /^\x1b\[([0-9;]*)m$/.exec(part)
173      if (sgr) {
174        style = applySgr(style, sgr[1])
175        continue
176      }
177      if (part.startsWith('\x1b') || part === '') continue
178      const text = part.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
179      const last = spans[spans.length - 1]
180      if (last && sameStyle(last, style)) last.text += text
181      else spans.push({ ...style, text })
182    }
183    return spans
184  })
185}
186
187// Rendered markdown by width and text: glow is a process, run once each.
188const rendered = new Map<string, Span[][] | null>()
189
190async function glow($: EngineInterface, text: string, width: number, style: Theme) {
191  const key = `${style}\n${width}\n${text}`
192  if (!rendered.has(key)) {
193    const { exitCode, stdout } = await $.process
194      .run(['glow', '-s', `${$.plugin.root}/${GLOW_STYLE[style]}`, '-w', String(width), '-'], {
195        stdin: text,
196        env: { CLICOLOR_FORCE: '1' },
197        timeoutMs: 10_000,
198      })
199      .catch(() => ({ exitCode: 1, stdout: '' }))
200    rendered.set(key, exitCode === 0 && stdout.trim() ? parseAnsi(stdout.replace(/\n+$/, '')) : null)
201  }
202  return rendered.get(key) ?? null
203}
204
205export const register: Register = on => {
206  on('session.start', async ($, e, next) => {
207    await $.command.register({
208      name: 'preview',
209      description: 'Render a markdown file in a side pane (default README.md)',
210    })
211    const sync = async () => {
212      const now = await readTheme($)
213      // Writing only on a change keeps the pane from redrawing every poll.
214      if (now !== (await read($, theme))) await update($, theme, () => now)
215    }
216    await sync()
217    $.clock.every(THEME_POLL_MS, () => void sync())
218    return next(e)
219  })
220
221  on('command.run', { command: 'preview' }, async ($, e) => {
222    // Resolved once against the session's folder, so reload reads the same file
223    // even if the folder changes, and an error names the full path.
224    // An @-mention completes to `@path`: drop the @. `~` is the home folder.
225    const arg = e.args.trim().replace(/^@/, '') || 'README.md'
226    const expanded = arg.startsWith('~/') ? `${await homeDir($)}${arg.slice(1)}` : arg
227    const path = expanded.startsWith('/') ? expanded : `${await $.session.cwd()}/${expanded}`
228    const loaded = await load(path, p => $.fs.read(p))
229    await update($, doc, () => loaded)
230    // The frame draws its own close button; Escape closes it too while it has the keys.
231    await $.ui.open({ id: PANE, title: basename(path), closeOnEscape: true })
232
233    return { text: `Previewing ${arg}.` }
234  })
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
238    const current = await read($, doc)
239
240    if (current === null) return <Text dimColor>Run /preview &lt;file.md&gt;</Text>
241
242    const columns = e.props.bodyColumns
243    const style = await read($, theme)
244    const reload = async () => {
245      const fresh = await load(current.path, p => $.fs.read(p))
246      await update($, doc, () => fresh)
247    }
248
249    const header = (
250      <Box key="header" backgroundColor={BAR_BG} paddingX={1} justifyContent="space-between" width={columns}>
251        <Box gap={1}>
252          <Text color={ACCENT} bold>
253            ◆ {basename(current.path)}
254          </Text>
255          <Text color={MUTED} wrap="truncate-start">
256            {dirname(current.path)}
257          </Text>
258        </Box>
259        <Box gap={2}>
260          <Button key="reload" label="↻ reload" hotkey="r" plain onPress={reload} />
261          <Button key="close" label="close" hotkey="c" plain onPress={() => $.ui.close({ id: PANE })} />
262        </Box>
263      </Box>
264    )
265
266    const footer = (
267      <Box key="footer" paddingX={1} marginTop={1}>
268        <Text color={MUTED}>ctrl+x tab to focus · r reload · c or esc close</Text>
269      </Box>
270    )
271
272    if ('error' in current) {
273      return (
274        <Box flexDirection="column">
275          {header}
276          <Box paddingX={2} marginTop={1}>
277            <Text color="red">{current.error}</Text>
278          </Box>
279          {footer}
280        </Box>
281      )
282    }
283
284    // Body sits inside 2 columns of padding each side; a card adds a border and 1 of padding.
285    const cardWidth = Math.max(20, columns - 8)
286    const blocks = await Promise.all(
287      segment(current.text).map(async (part, i) => {
288        if (part.kind === 'markdown') {
289          const lines = await glow($, part.text, columns - 2, style)
290          // glow missing or failed: Claude Code's own renderer.
291          if (lines === null) return <Markdown key={`m${i}`} text={part.text} />
292
293          return (
294            <Box key={`g${i}`} flexDirection="column">
295              {lines.map((spans, j) => (
296                <Text key={`g${i}-${j}`} wrap="truncate-end">
297                  {spans.length === 0 ? ' ' : spans.map(({ text, ...style }) => <Text {...style}>{text}</Text>)}
298                </Text>
299              ))}
300            </Box>
301          )
302        }
303
304        const art = await diagram($, part.source, cardWidth)
305        // termaid cannot draw it (type unsupported, or not installed): show the source.
306        if (art === null) return <Markdown key={`m${i}`} text={`\`\`\`mermaid\n${part.source}\`\`\``} />
307
308        return (
309          <Box key={`d${i}`} flexDirection="column" marginY={1}>
310            <Text color={MUTED}> mermaid</Text>
311            <Box flexDirection="column" borderStyle="round" borderColor={MUTED} paddingX={1}>
312              {art.split('\n').map((line, j) => (
313                <Text key={`d${i}-${j}`} wrap="truncate-end">
314                  {line}
315                </Text>
316              ))}
317            </Box>
318          </Box>
319        )
320      }),
321    )
322
323    return (
324      <Box flexDirection="column">
325        {header}
326        <Box flexDirection="column" paddingX={1} marginTop={1}>
327          {blocks}
328        </Box>
329        {footer}
330      </Box>
331    )
332  })
333}
334
types/index.d.ts 9 lines
1export type Theme = 'dark' | 'light'
2export type Doc = { path: string; text: string } | { path: string; error: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    preview: { doc: Doc | null; theme: Theme }
7  }
8}
9