SLOPSHOPPER

shell-highlight

Draws Bash and PowerShell tool calls with syntax-highlighted commands, clickable paths and unfoldable reads

newrowstoastprocess
v0.3.1MITupdated 2026-10-07Turbo-Thorschten/shell-highlight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-highlight
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ● Update(/work/app/src/auth\.ts file:///work/app/src/auth.ts) ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
● Update(/work/app/src/auth\.ts file:///work/app/src/auth.ts)
README

shell-highlight

A Claude Code mod that draws Bash and PowerShell tool calls with syntax-highlighted commands, makes paths and URLs clickable wherever they appear, and lets you read along with what Read read.

Four shell calls drawn by the mod: a pipeline, a heredoc, an inline Python script and a folded output

Claude Code prints a shell command in one colour and folds a run of them into a count line (Ran 4 shell commands). This mod draws each call as its command, coloured by Claude Code's own highlighter, with the first lines of its output underneath.

Features

  • Highlighted commands. Bash calls are coloured as bash, PowerShell calls as PowerShell.
  • Embedded languages. A script handed to an interpreter is coloured in its own language, whether it sits in a heredoc, a PowerShell here-string or a quoted argument:
Written asColoured as
node -e '…', node <<'EOF', bun, deno, tsxJavaScript / TypeScript
python -c '…', python3 <<'EOF', pyPython
ruby -e, perl -e, php -rRuby, Perl, PHP
psql, sqlite3, mysql with a heredocSQL
bash -c '…', sh -c, zsh -ic '…'bash
pwsh -Command '…', powershell -cPowerShell
cmd /c "…"batch
cat > script.py <<'EOF', `@'…'@ \Set-Content data.json`by the file's extension
  • Folded by default, one click to unfold. A call shows up to 10 lines of its command and 5 lines of its output, then … +N lines (click to expand). A click anywhere on the run of calls unfolds every command and every line of output; a second click folds them again.
  • Hover per call. The pointer lights up the description and output of the call under it, not the whole run.
  • Wrapping that keeps the colours. An inline script wider than the terminal is wrapped at a space outside string literals, so every wrapped line is still highlighted. Wide characters (CJK, emoji) count as two cells.
  • Status at a glance. The dot is dim while a call runs, green when it succeeded, red when it failed or was interrupted. A running call says Running…, an interrupted one Interrupted, a silent one (no output).
  • Clean output. Colour codes and control characters are removed from the output; in a command they are shown as visible symbols (␛).
  • Clickable paths and URLs. Absolute paths (C:\…, \\server\…, /a/b…, ~/…) and http(s):// URLs are links in shell commands, shell output and Claude's replies, as are the targets in the Read, Update, Write and Fetch rows. In fullscreen one click opens them with the default program (explorer.exe on Windows, xdg-open or open elsewhere); a folder's Explorer window is brought to the front. A path wrapped over two lines is clickable on both.
  • Read along. Read 12 lines (click to show) unfolds the lines that were read, highlighted by the file's extension, numbered from where the read started; a second click folds them. .output, .log, .txt and extension-less files are coloured by their content when it is JSON, XML or a shell session. In an unfolded run the lines show at once.
  • Everything else stays native. Other tools keep Claude Code's rows. A run that mixes shell calls with other tools keeps its count line (Read 1 file, ran 2 shell commands) above the shell calls and unfolds into the engine's own rows.

Where the output comes from

ViewCommandOutput
Fullscreen transcript, foldedthis modthis mod, 5 lines
Fullscreen transcript, unfolded by a clickthis modthis mod, all lines
Detailed transcript (ctrl+o)this modClaude Code, all lines (this mod for a run unfolded before)
Classic (non-fullscreen) transcriptthis modClaude Code, folded, ctrl+o to expand

The fold belongs to Claude Code: a click unfolds the run of consecutive calls it sits in, and the state lasts as long as Claude Code keeps it.

Links and your terminal

Claude Code writes links as OSC 8 hyperlinks only in terminals it recognises; elsewhere it prints the URL after the text, which doubles every path and breaks at a line wrap. If that happens, set "FORCE_HYPERLINK": "1" in the env block of ~/.claude/settings.json and restart Claude Code.

A single click reaches the mod only in the fullscreen transcript. In the classic transcript the terminal handles the click itself, as with any hyperlink.

Known limits: a path with spaces is linked up to the first space, relative paths are not linked, a click on a link inside an unfolded run also folds the run, and WezTerm underlines only the line under the pointer of a wrapped link.

Requirements

Claude Code 2.1.287 or newer, the first version with mods (function hooks). The mods API is early access and may change between releases.

Install

git clone https://github.com/Turbo-Thorschten/shell-highlight.git
claude --plugin-dir ./shell-highlight

To load it in every session, name the folder in CLAUDE_CODE_PLUGIN_DIRS, either in your environment or in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/shell-highlight"
  }
}

The variable is a list. To keep other mods, append the folder with your platform's path-list separator: ; on Windows, : on Linux and macOS.

Open /plugin to check: the header reads N mods active · shell-highlight, ….

How it works

Two files: hooks/text.ts splits a command into pieces, their languages and their links, and hooks/register.ts draws them from ui.render hooks.

  1. ToolGroup, a run of calls. Folded, the hook draws each shell call itself. Unfolded, it hands the run to Claude Code, which draws every call as a row.
  2. ToolUse, one row. For a shell call the hook draws the highlighted command; for Read, Edit, Write and WebFetch the name and a linked target. A row of an unfolded run also draws its output, because Claude Code draws none there.
  3. ToolResult, the result block under a row. Left to Claude Code, except where the row already drew the output and for Read, whose line count unfolds the lines read.
  4. AssistantMessage, a block of a reply. A block with paths is drawn by the mod, with the paths as links; any other block is left to Claude Code.

The mod changes how rows are drawn and nothing else: the command that runs, its permission prompt and its result are untouched. claude plugin validate lists its engine calls: $.ui.resolve and $.ui.toast for drawing, $.state for the unfolded reads, $.env.get (USERPROFILE, HOME) to expand ~, and $.fs.stat and $.process.run only when you click a link. It makes no network calls.

It runs next to other mods. For a shell row it returns its own drawing, so a mod loaded beneath it does not draw that row.

Tested

Every behaviour above was checked on a real screen, on Claude Code 2.1.287:

  • Windows 11 in WezTerm, Bash and PowerShell tools
  • Linux (Ubuntu under WSL 2) in tmux, fullscreen and classic mode, at 110 and 50 columns
  • pointer hover and clicks, the ctrl+o transcript, a running, a failed, an interrupted and a not yet approved call, 3,000 lines of output, a second mod loaded alongside

Links and Read unfolding were checked on Claude Code 2.1.291: clicks on paths in tool rows, shell commands and replies in WezTerm on Windows, and a folder's Explorer window coming to the front. They were not checked in the desktop app, VS Code or on mobile.

claude plugin test runs 87 tests of the splitting, wrapping, linking and drawing on the terminal and desktop surfaces.

Development

claude plugin validate .    # what the module hooks and calls, and anything the engine would refuse
claude plugin test .        # runs tests/*.test.ts
npx -p typescript tsc -p .  # type-check; needs .claude-plugin/types/, which Claude Code writes when it loads the mod

License

MIT

Source 3 files
hooks/register.ts 361 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cells, chunks, escapeMarkdown, fromLinkTarget, isWindowsPath, layout, lineCount, linkify, linkRows, linkTarget, outputLines, sniffLanguage, takeLines, visible } from './text'
5import type { Links, Piece, Shell } from './text'
6
7const SHELLS: Record<string, Shell> = { Bash: 'bash', PowerShell: 'powershell' }
8const PATH_TOOLS: Record<string, { label: string; field: string }> = {
9  Read: { label: 'Read', field: 'file_path' },
10  Edit: { label: 'Update', field: 'file_path' },
11  Write: { label: 'Write', field: 'file_path' },
12  WebFetch: { label: 'Fetch', field: 'url' },
13}
14const FOLDED_COMMAND_LINES = 10
15const FOLDED_OUTPUT_LINES = 5
16const FOLD_SLACK = 1
17const DIM = 'inactive'
18const LIT = { color: 'text' }
19
20const groupRows = new Set<string>()
21const shown = atom({ plugin: 'shell-highlight', key: 'shown' } as const, [] as string[])
22let home: string | undefined
23
24function links(): Links {
25  return home === undefined ? {} : { home }
26}
27
28type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
29
30type Call = {
31  id: string
32  tool: string
33  input: unknown
34  isRunning: boolean
35  isErrored: boolean
36  isInterrupted: boolean
37  output?: unknown
38}
39
40type ShellCommand = { shell: Shell; command: string; description: string }
41
42function shellCommand(tool: string, input: unknown): ShellCommand | undefined {
43  const shell = SHELLS[tool]
44  const fields = input as { command?: unknown; description?: unknown } | null
45  if (shell === undefined || typeof fields?.command !== 'string') return undefined
46  const command = visible(fields.command).replace(/\n+$/, '')
47  if (command.trim() === '') return undefined
48  return { shell, command, description: typeof fields.description === 'string' ? fields.description : '' }
49}
50
51function draw(
52  $: EngineInterface,
53  e: RenderEvent,
54  call: Call,
55  shown: ShellCommand,
56  isFolded: boolean,
57  hasOutput: boolean,
58  viewport: { columns: number; isFullscreen?: boolean } | undefined,
59) {
60  const { Box, Text, Code, Markdown } = $.ui.resolve(e)
61  const columns = Math.max(20, (viewport?.columns ?? 80) - 6)
62  const code = (piece: Piece, source: string) =>
63    Code({ source, ...(piece.language !== undefined ? { language: piece.language } : {}), ...(piece.path !== undefined ? { path: piece.path } : {}) })
64  const dim = (text: string) => Text({ color: DIM, hover: LIT, children: [text] })
65  let linkCount = 0
66  const cell = (piece: Piece, drawn: (piece: Piece) => ReturnType<typeof Text>) => {
67    const target = piece.link
68    return Box({
69      flexShrink: 0,
70      flexGrow: 0,
71      width: cells(piece.source),
72      children: [
73        target === undefined
74          ? drawn(piece)
75          : Markdown({
76              key: `link-${call.id}-${linkCount++}`,
77              text: `[${escapeMarkdown(piece.source)}](${linkTarget(target)})`,
78              onLinkPress: () => void openTarget($, target),
79            }),
80      ],
81    })
82  }
83
84  const header = [
85    Box({
86      flexDirection: 'row',
87      gap: 1,
88      flexShrink: 0,
89      children: [
90        Text({
91          color: call.isRunning ? DIM : call.isErrored || call.isInterrupted ? 'error' : 'success',
92          children: ['●'],
93        }),
94        Text({ bold: true, children: [call.tool] }),
95      ],
96    }),
97  ]
98  if (shown.description !== '') header.push(dim(shown.description))
99  if (call.isInterrupted) header.push(dim('Interrupted'))
100
101  const blocks = layout(shown.command, shown.shell, columns, links())
102  const commandLines = lineCount(blocks)
103  const isCommandCut = isFolded && commandLines > FOLDED_COMMAND_LINES + FOLD_SLACK
104  const body = []
105  for (const block of isCommandCut ? takeLines(blocks, FOLDED_COMMAND_LINES) : blocks) {
106    if (block.kind === 'row') {
107      body.push(Box({ flexDirection: 'row', children: block.pieces.map(piece => cell(piece, one => code(one, one.source))) }))
108    } else {
109      for (const source of chunks(block.piece.source)) body.push(code(block.piece, source))
110    }
111  }
112  let hidden = isCommandCut ? commandLines - FOLDED_COMMAND_LINES : 0
113  if (hidden > 0) body.push(dim('…'))
114
115  const result = []
116  const lines = hasOutput ? outputLines(call.output) : []
117  const isOutputCut = isFolded && lines.length > FOLDED_OUTPUT_LINES + FOLD_SLACK
118  const visibleLines = isOutputCut ? lines.slice(0, FOLDED_OUTPUT_LINES) : lines
119  hidden += lines.length - visibleLines.length
120  if (call.isRunning) result.push(dim('Running…'))
121  else if (lines.length > 0) {
122    let plain: string[] = []
123    const flush = () => {
124      if (plain.length > 0) for (const text of chunks(plain.join('\n'))) result.push(dim(text))
125      plain = []
126    }
127    for (const line of visibleLines) {
128      const rows = linkRows(line, columns - 2, links())
129      if (rows === undefined) {
130        plain.push(line)
131        continue
132      }
133      flush()
134      for (const pieces of rows) result.push(Box({ flexDirection: 'row', children: pieces.map(piece => cell(piece, one => dim(one.source))) }))
135    }
136    flush()
137  } else if (hasOutput && call.output !== undefined) result.push(dim('(no output)'))
138  if (hidden > 0) result.push(dim(`… +${hidden} lines (${viewport?.isFullscreen === true ? 'click' : 'ctrl+o'} to expand)`))
139
140  if (result.length > 0) {
141    body.push(
142      Box({
143        flexDirection: 'row',
144        gap: 1,
145        children: [Box({ flexShrink: 0, children: [dim('⎿')] }), Box({ flexDirection: 'column', children: result })],
146      }),
147    )
148  }
149
150  return Box({
151    key: `call-${call.id}`,
152    flexDirection: 'column',
153    marginTop: 1,
154    children: [
155      Box({ flexDirection: 'row', gap: 1, children: header }),
156      Box({ flexDirection: 'column', paddingLeft: 2, children: body }),
157    ],
158  })
159}
160
161type PathTarget = { label: string; target: string; detail: string }
162
163type ReadFile = { filePath: string; content: string; numLines: number; startLine: number }
164
165function pathTarget(tool: string, input: unknown): PathTarget | undefined {
166  const known = PATH_TOOLS[tool]
167  const fields = input as Record<string, unknown> | null
168  const target = fields?.[known?.field ?? '']
169  if (known === undefined || typeof target !== 'string' || target.trim() === '') return undefined
170  let detail = ''
171  if (tool === 'Read') {
172    const offset = typeof fields?.offset === 'number' ? fields.offset : undefined
173    const limit = typeof fields?.limit === 'number' ? fields.limit : undefined
174    if (limit !== undefined) detail = ` · lines ${offset ?? 1}-${(offset ?? 1) + limit - 1}`
175    else if (offset !== undefined) detail = ` · from line ${offset}`
176    if (typeof fields?.pages === 'string') detail += ` · pages ${fields.pages}`
177  }
178  return { label: known.label, target, detail }
179}
180
181function readFile(output: unknown): ReadFile | undefined {
182  const result = output as { type?: unknown; file?: Partial<ReadFile> } | null
183  const file = result?.file
184  if (result?.type !== 'text' || typeof file?.filePath !== 'string' || typeof file.content !== 'string') return undefined
185  return {
186    filePath: file.filePath,
187    content: file.content,
188    numLines: typeof file.numLines === 'number' ? file.numLines : file.content.split('\n').length,
189    startLine: typeof file.startLine === 'number' ? file.startLine : 1,
190  }
191}
192
193function foregroundFolder(path: string): string {
194  return `$p = '${path.replace(/'/g, "''")}'
195Add-Type -Namespace W -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, int f, System.UIntPtr e); [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr h, int c);'
196$url = ([System.Uri]$p).AbsoluteUri
197$sh = New-Object -ComObject Shell.Application
198$known = @($sh.Windows() | ForEach-Object { $_.HWND })
199$sh.Open($p)
200foreach ($i in 1..50) {
201  Start-Sleep -Milliseconds 100
202  $w = $sh.Windows() | Where-Object { $_.LocationURL -eq $url -and $known -notcontains $_.HWND } | Select-Object -First 1
203  if ($w) {
204    [W.U]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero); [W.U]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
205    [void][W.U]::ShowWindow([IntPtr]$w.HWND, 9); [void][W.U]::SetForegroundWindow([IntPtr]$w.HWND)
206    break
207  }
208}`
209}
210
211async function openTarget($: EngineInterface, target: string) {
212  const isUrl = /^https?:\/\//i.test(target)
213  const local = isWindowsPath(target) ? target.replace(/\//g, '\\') : target
214  if (isWindowsPath(local) && (await $.fs.stat(local).catch(() => undefined))?.kind === 'dir') {
215    try {
216      await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', foregroundFolder(local)])
217      $.ui.toast(`Opening ${local}`)
218      return
219    } catch {}
220  }
221  const openers = isUrl || isWindowsPath(target) ? ['explorer.exe', 'xdg-open', 'open'] : ['xdg-open', 'open', 'explorer.exe']
222  for (const opener of openers) {
223    try {
224      await $.process.run([opener, local])
225      $.ui.toast(`Opening ${local}`)
226      return
227    } catch {}
228  }
229  $.ui.toast(`Could not open ${target}`)
230}
231
232function drawPathHeader($: EngineInterface, e: RenderEvent, call: Call, path: PathTarget) {
233  const { Box, Text, Markdown } = $.ui.resolve(e)
234  const href = linkTarget(path.target)
235  const children = [
236    Box({
237      flexShrink: 0,
238      children: [
239        Text({
240          color: call.isRunning ? DIM : call.isErrored || call.isInterrupted ? 'error' : 'success',
241          children: ['●'],
242        }),
243      ],
244    }),
245    Markdown({
246      key: `path-${call.id}`,
247      text: `**${path.label}**([${escapeMarkdown(path.target)}](${href})${escapeMarkdown(path.detail)})`,
248      onLinkPress: () => void openTarget($, path.target),
249    }),
250  ]
251  if (call.isInterrupted) children.push(Text({ color: DIM, children: ['Interrupted'] }))
252  return Box({ key: `head-${call.id}`, flexDirection: 'row', gap: 1, marginTop: 1, children })
253}
254
255function drawReadResult($: EngineInterface, e: RenderEvent, id: string, file: ReadFile, isOpen: boolean, isClickable: boolean) {
256  const { Box, Text, Markdown, Code } = $.ui.resolve(e)
257  const href = linkTarget(file.filePath)
258  const count = `Read **${file.numLines}** ${file.numLines === 1 ? 'line' : 'lines'}`
259  const summary = [
260    Markdown({
261      key: `read-${id}`,
262      text: isClickable ? `[${count}](${href})` : count,
263      ...(isClickable
264        ? {
265            onLinkPress: () =>
266              void update($, shown, list => (list.includes(id) ? list.filter(one => one !== id) : [...list, id])),
267          }
268        : {}),
269    }),
270  ]
271  if (isClickable) summary.push(Text({ color: DIM, hover: LIT, children: [isOpen ? '(click to fold)' : '(click to show)'] }))
272
273  const lines = [Box({ flexDirection: 'row', gap: 1, children: summary })]
274  if (isOpen) {
275    const language = sniffLanguage(file.filePath, file.content)
276    let startLine = file.startLine
277    for (const source of chunks(visible(file.content).replace(/\n$/, ''))) {
278      lines.push(Code({ source, ...(language !== undefined ? { language } : { path: file.filePath }), startLine }))
279      startLine += source.split('\n').length
280    }
281  }
282  return Box({
283    key: `result-${id}`,
284    flexDirection: 'row',
285    gap: 1,
286    children: [Box({ flexShrink: 0, children: [Text({ color: DIM, children: ['⎿'] })] }), Box({ flexDirection: 'column', children: lines })],
287  })
288}
289
290export const register: Register = on => {
291  on('session.start', async ($, e, next) => {
292    home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
293    return next(e)
294  })
295
296  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
297    const text = linkify(e.props.text, links())
298    if (text === e.props.text) return next(e)
299    if ((e.props as { isSummary?: true }).isSummary === true) return next({ ...e, props: { ...e.props, text } })
300    const { Box, Text, Markdown } = $.ui.resolve(e)
301    return Box({
302      flexDirection: 'row',
303      marginTop: 1,
304      children: [
305        Box({ flexShrink: 0, width: 2, children: [Text({ children: [e.props.isFirstOfReply ? '●' : ' '] })] }),
306        Markdown({
307          key: `reply-${String(e.requestId)}`,
308          text,
309          onLinkPress: link => void openTarget($, fromLinkTarget(link.href)),
310        }),
311      ],
312    })
313  })
314
315  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
316    if (e.props.isExpanded) {
317      for (const call of e.props.calls) if (call.tool_use_id !== undefined) groupRows.add(call.tool_use_id)
318      return next(e)
319    }
320    const calls = e.props.calls.map((call, index) => ({
321      call: { ...call, id: call.tool_use_id ?? `${String(e.requestId)}-${index}` },
322      shown: shellCommand(call.tool, call.input),
323    }))
324    const shellCalls = calls.filter(one => one.shown !== undefined)
325    if (shellCalls.length === 0) return next(e)
326
327    const { Box } = $.ui.resolve(e)
328    const rows = shellCalls.length === calls.length ? [] : [await next(e)]
329    for (const one of shellCalls) {
330      if (one.shown !== undefined) rows.push(draw($, e, one.call, one.shown, true, true, e.viewport))
331    }
332    return Box({ flexDirection: 'column', children: rows })
333  })
334
335  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
336    const call = { ...e.props, id: e.props.tool_use_id }
337    const command = shellCommand(e.props.tool, e.props.input)
338    if (command !== undefined) return draw($, e, call, command, false, groupRows.has(call.id), e.viewport)
339
340    const path = pathTarget(e.props.tool, e.props.input)
341    const isGroupRow = groupRows.has(call.id)
342    if (path === undefined || (isGroupRow && e.props.tool !== 'Read')) return next(e)
343    const header = drawPathHeader($, e, call, path)
344    const file = isGroupRow ? readFile(e.props.output) : undefined
345    if (file === undefined) return header
346    const { Box } = $.ui.resolve(e)
347    return Box({ flexDirection: 'column', children: [header, drawReadResult($, e, call.id, file, true, false)] })
348  })
349
350  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
351    if (groupRows.has(e.props.tool_use_id)) {
352      const { Box } = $.ui.resolve(e)
353      return Box({ children: [] })
354    }
355    const file = e.props.tool === 'Read' && !e.props.isErrored ? readFile(e.props.output) : undefined
356    if (file === undefined) return next(e)
357    const isOpen = (await read($, shown)).includes(e.props.tool_use_id)
358    return drawReadResult($, e, e.props.tool_use_id, file, isOpen, e.viewport?.isFullscreen === true)
359  })
360}
361
hooks/text.ts 401 lines
1export type Shell = 'bash' | 'powershell'
2
3export type Piece = { source: string; language?: string; path?: string; link?: string }
4
5export type Links = { home?: string }
6
7type Found = { start: number; end: number; target: string }
8
9export type Block = { kind: 'code'; piece: Piece } | { kind: 'row'; pieces: Piece[] }
10
11type Span = { start: number; end: number; language?: string; path?: string; closer?: number }
12
13const MAX_ELEMENT_CHARS = 10000
14
15const INTERPRETERS: Record<string, string> = {
16  node: 'javascript',
17  nodejs: 'javascript',
18  bun: 'typescript',
19  deno: 'typescript',
20  tsx: 'typescript',
21  python: 'python',
22  python3: 'python',
23  py: 'python',
24  ruby: 'ruby',
25  perl: 'perl',
26  php: 'php',
27  psql: 'sql',
28  sqlite3: 'sql',
29  mysql: 'sql',
30  bash: 'bash',
31  sh: 'bash',
32  zsh: 'bash',
33  pwsh: 'powershell',
34  powershell: 'powershell',
35  cmd: 'bat',
36}
37
38const EVAL_FLAGS: Record<string, string[]> = {
39  javascript: ['-e', '--eval', '-p', '--print'],
40  typescript: ['-e', '--eval', '-p', '--print', 'eval'],
41  python: ['-c'],
42  ruby: ['-e'],
43  perl: ['-e', '-E'],
44  php: ['-r'],
45  bash: ['-c', '-ic', '-lc', '-ilc', '-lic'],
46  powershell: ['-c', '-Command', '-command'],
47  bat: ['/c', '/C', '/k', '/K'],
48}
49
50const INTERPRETER_NAMES = Object.keys(INTERPRETERS).join('|')
51const EVAL = new RegExp(
52  `(?<![\\w./-])(${INTERPRETER_NAMES})(?:\\.exe)?[ \\t]+(?:-[\\w=:.-]+[ \\t]+)*?(\\/?[\\w-]+)[ \\t]+(['"])`,
53  'g',
54)
55const INTERPRETER_WORD = new RegExp(`(?<![\\w./-])(${INTERPRETER_NAMES})(?:\\.exe)?(?![\\w.-])`)
56const HEREDOC = /<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n/g
57const HERE_STRING = /@(['"])[ \t]*\n/g
58const REDIRECT = /(?:>>?|\btee(?:[ \t]+-a)?|\b(?:Set-Content|Add-Content|Out-File)(?:[ \t]+-(?:Path|FilePath|LiteralPath))?)[ \t]*(['"]?)([^\s'"|;&<>]+)\1/
59const ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g
60const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
61const WIDE = /[\u{1100}-\u{115f}\u{2e80}-\u{a4cf}\u{ac00}-\u{d7a3}\u{f900}-\u{faff}\u{fe30}-\u{fe4f}\u{ff00}-\u{ff60}\u{ffe0}-\u{ffe6}\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
62const URL_LINK = /\bhttps?:\/\/[^\s"'`<>]+/g
63const PATH_LINK = /(?<![\w.~/\\:$-])(?:[A-Za-z]:[\\/]|\\\\[\w.$-]+\\|~[\\/]|\/(?=[\w.@+-]+\/))[^\s"'`<>|*?()[\]{}]*/g
64const UNSURE_WIDTH =/[\u{300}-\u{36f}\u{483}-\u{489}\u{591}-\u{5c7}\u{610}-\u{61a}\u{64b}-\u{65f}\u{900}-\u{dff}\u{e31}-\u{e4e}\u{200b}-\u{200f}\u{2028}-\u{202e}\u{2060}-\u{206f}\u{2600}-\u{27bf}\u{fe00}-\u{fe0f}\u{1f1e6}-\u{1f1ff}\u{e0000}-\u{e0fff}]/u
65
66export function cells(text: string): number {
67  let width = 0
68  for (const ch of text) width += WIDE.test(ch) ? 2 : 1
69  return width
70}
71
72function breakAt(source: string, room: number): number {
73  let used = 0
74  let at = 0
75  let quote = ''
76  let isEscaped = false
77  let outsideQuotes = 0
78  let anywhere = 0
79  for (const ch of source) {
80    used += WIDE.test(ch) ? 2 : 1
81    if (used > room) break
82    at += ch.length
83    if (isEscaped) isEscaped = false
84    else if (ch === '\\') isEscaped = true
85    else if (quote !== '') quote = ch === quote ? '' : quote
86    else if (ch === "'" || ch === '"' || ch === '`') quote = ch
87    if (ch === ' ') {
88      anywhere = at
89      if (quote === '') outsideQuotes = at
90    }
91  }
92  return outsideQuotes > 0 ? outsideQuotes : anywhere > 0 ? anywhere : -at
93}
94
95function wrap(parts: Piece[], columns: number): Piece[][] {
96  const rows: Piece[][] = [[]]
97  const queue = [...parts]
98  let room = columns
99  for (let piece = queue.shift(); piece !== undefined; piece = queue.shift()) {
100    const row = rows[rows.length - 1] ?? []
101    const width = cells(piece.source)
102    if (width <= room) {
103      row.push(piece)
104      room -= width
105      continue
106    }
107    const found = breakAt(piece.source, room)
108    const cut = found > 0 ? found : row.length > 0 ? 0 : Math.max(-found, [...piece.source][0]?.length ?? 1)
109    if (cut > 0) row.push({ ...piece, source: piece.source.slice(0, cut) })
110    queue.unshift({ ...piece, source: piece.source.slice(cut) })
111    rows.push([])
112    room = columns
113  }
114  return rows.filter(row => row.length > 0)
115}
116
117export function visible(text: string): string {
118  return text
119    .replace(/\r\n?/g, '\n')
120    .replace(CONTROL, ch => (ch === '\u007f' ? '␡' : String.fromCharCode(0x2400 + ch.charCodeAt(0))))
121}
122
123function closingQuote(text: string, from: number, quote: string, shell: Shell): number {
124  for (let i = from; i < text.length; i++) {
125    const ch = text[i]
126    if (quote === '"' && ch === (shell === 'bash' ? '\\' : '`')) {
127      i++
128    } else if (ch === quote) {
129      if (shell === 'bash' && quote === "'" && text.startsWith("'\\''", i)) i += 3
130      else if (shell === 'powershell' && text[i + 1] === quote) i++
131      else return i
132    }
133  }
134  return -1
135}
136
137function lineAround(text: string, index: number): string {
138  const start = text.lastIndexOf('\n', index - 1) + 1
139  const end = text.indexOf('\n', index)
140  return text.slice(start, end === -1 ? text.length : end)
141}
142
143function languageOfLine(line: string): Pick<Span, 'language' | 'path'> {
144  const interpreter = INTERPRETER_WORD.exec(line)?.[1]
145  if (interpreter !== undefined) return { language: INTERPRETERS[interpreter] }
146  const target = REDIRECT.exec(line.replace(/<<-?[ \t]*['"]?\w+['"]?/, ''))?.[2]
147  return target === undefined ? {} : { path: target }
148}
149
150function spans(command: string, shell: Shell): Span[] {
151  const found: Span[] = []
152
153  for (const match of command.matchAll(EVAL)) {
154    const language = INTERPRETERS[match[1] ?? '']
155    if (language === undefined || !(EVAL_FLAGS[language] ?? []).includes(match[2] ?? '')) continue
156    const start = match.index + match[0].length
157    const end = closingQuote(command, start, match[3] ?? '', shell)
158    if (end > start) found.push({ start, end, language, closer: 1 })
159  }
160
161  for (const match of command.matchAll(HEREDOC)) {
162    const start = match.index + match[0].length
163    const terminator = new RegExp(`^[ \\t]*${match[2]}[ \\t]*$`, 'm')
164    const rest = command.slice(start)
165    const at = terminator.exec(rest)
166    if (at === null || at.index === 0) continue
167    found.push({ start, end: start + at.index - 1, ...languageOfLine(lineAround(command, match.index)) })
168  }
169
170  if (shell === 'powershell') {
171    for (const match of command.matchAll(HERE_STRING)) {
172      const start = match.index + match[0].length
173      const close = command.indexOf(`\n${match[1]}@`, start - 1)
174      if (close < start) continue
175      const opening = lineAround(command, match.index)
176      const closing = lineAround(command, close + 1)
177      const hint = languageOfLine(opening)
178      found.push({ start, end: close, closer: 3, ...(hint.language ?? hint.path ? hint : languageOfLine(closing)) })
179    }
180  }
181
182  found.sort((a, b) => a.start - b.start)
183  const kept: Span[] = []
184  for (const span of found) {
185    const last = kept[kept.length - 1]
186    if (last === undefined || span.start >= last.end) kept.push(span)
187  }
188  return kept
189}
190
191function isSameLanguage(a: Piece, b: Piece): boolean {
192  return a.language === b.language && a.path === b.path
193}
194
195export function layout(command: string, shell: Shell, columns: number, links?: Links): Block[] {
196  const pieces: Piece[] = []
197  let at = 0
198  for (const span of spans(command, shell)) {
199    pieces.push({ source: command.slice(at, span.start), language: shell })
200    pieces.push({ source: command.slice(span.start, span.end), language: span.language, path: span.path })
201    at = span.end
202    if (span.closer !== undefined) {
203      pieces.push({ source: command.slice(at, at + span.closer), language: shell })
204      at += span.closer
205    }
206  }
207  pieces.push({ source: command.slice(at), language: shell })
208
209  const lines: Piece[][] = [[]]
210  for (const piece of pieces) {
211    piece.source.split('\n').forEach((part, index) => {
212      if (index > 0) lines.push([])
213      lines[lines.length - 1]?.push({ ...piece, source: part })
214    })
215  }
216
217  const blocks: Block[] = []
218  for (const line of lines) {
219    const filled = line.filter(piece => piece.source !== '')
220    const found = links === undefined ? filled : splitLinks(filled, links)
221    const parts = found.length > 0 ? found : line.slice(0, 1)
222    const single: Piece =
223      parts.length === 1 && parts[0] !== undefined && parts[0].link === undefined
224        ? parts[0]
225        : { source: parts.map(piece => piece.source).join(''), language: shell }
226    if ((parts.length > 1 || parts[0]?.link !== undefined) && !UNSURE_WIDTH.test(single.source)) {
227      for (const pieces of wrap(parts, columns)) blocks.push({ kind: 'row', pieces })
228      continue
229    }
230    const last = blocks[blocks.length - 1]
231    if (last?.kind === 'code' && isSameLanguage(last.piece, single)) {
232      last.piece = { ...last.piece, source: `${last.piece.source}\n${single.source}` }
233    } else {
234      blocks.push({ kind: 'code', piece: single })
235    }
236  }
237  return blocks
238}
239
240export function lineCount(blocks: Block[]): number {
241  return blocks.reduce((sum, block) => sum + (block.kind === 'row' ? 1 : block.piece.source.split('\n').length), 0)
242}
243
244export function takeLines(blocks: Block[], count: number): Block[] {
245  const taken: Block[] = []
246  let room = count
247  for (const block of blocks) {
248    if (room <= 0) break
249    if (block.kind === 'row') {
250      taken.push(block)
251      room -= 1
252      continue
253    }
254    const lines = block.piece.source.split('\n')
255    taken.push({ kind: 'code', piece: { ...block.piece, source: lines.slice(0, room).join('\n') } })
256    room -= lines.length
257  }
258  return taken
259}
260
261export function chunks(text: string): string[] {
262  const out: string[] = []
263  let current: string | null = null
264  for (const line of text.split('\n')) {
265    for (let from = 0; from === 0 || from < line.length; from += MAX_ELEMENT_CHARS) {
266      const part = line.slice(from, from + MAX_ELEMENT_CHARS)
267      if (current === null) {
268        current = part
269      } else if (current.length + 1 + part.length > MAX_ELEMENT_CHARS) {
270        out.push(current)
271        current = part
272      } else {
273        current = `${current}\n${part}`
274      }
275    }
276  }
277  out.push(current ?? '')
278  return out.map(chunk => (chunk === '' ? ' ' : chunk))
279}
280
281function trimLink(raw: string): string {
282  let text = raw.replace(/[.,;:!?'"]+$/, '')
283  while (text.endsWith(')') && text.split('(').length < text.split(')').length) text = text.slice(0, -1).replace(/[.,;:!?'"]+$/, '')
284  return text
285}
286
287export function findLinks(text: string, links: Links): Found[] {
288  const found: Found[] = []
289  for (const pattern of [URL_LINK, PATH_LINK]) {
290    for (const match of text.matchAll(pattern)) {
291      const raw = trimLink(match[0])
292      const start = match.index
293      const end = start + raw.length
294      if (raw.length < 3 || found.some(one => start < one.end && end > one.start)) continue
295      if (/^~[\\/]/.test(raw) && links.home === undefined) continue
296      found.push({ start, end, target: /^~[\\/]/.test(raw) ? `${links.home}${raw.slice(1)}` : raw })
297    }
298  }
299  return found.sort((a, b) => a.start - b.start)
300}
301
302function splitLinks(pieces: Piece[], links: Links): Piece[] {
303  const out: Piece[] = []
304  for (const piece of pieces) {
305    let at = 0
306    for (const one of findLinks(piece.source, links)) {
307      if (one.start > at) out.push({ ...piece, source: piece.source.slice(at, one.start) })
308      out.push({ ...piece, source: piece.source.slice(one.start, one.end), link: one.target })
309      at = one.end
310    }
311    if (at < piece.source.length || at === 0) out.push({ ...piece, source: piece.source.slice(at) })
312  }
313  return out
314}
315
316export function linkRows(line: string, columns: number, links: Links): Piece[][] | undefined {
317  if (UNSURE_WIDTH.test(line)) return undefined
318  const pieces = splitLinks([{ source: line }], links)
319  return pieces.some(piece => piece.link !== undefined) ? wrap(pieces, columns) : undefined
320}
321
322export function linkify(markdown: string, links: Links): string {
323  const link = (label: string, target: string) => `[${label}](${linkTarget(target)})`
324  const plain = (text: string) => {
325    let out = ''
326    let at = 0
327    for (const one of findLinks(text, links)) {
328      if (/^https?:/i.test(one.target)) continue
329      out += `${text.slice(at, one.start)}${link(escapeMarkdown(text.slice(one.start, one.end)), one.target)}`
330      at = one.end
331    }
332    return out + text.slice(at)
333  }
334  let out = ''
335  let at = 0
336  for (const match of markdown.matchAll(/```[\s\S]*?(?:```|$)|`[^`\n]+`|\[[^\]\n]*\]\([^)\n]*\)|<https?:[^>\s]+>/g)) {
337    out += plain(markdown.slice(at, match.index))
338    const span = /^`([^`]+)`$/.exec(match[0])?.[1]
339    const found = span === undefined ? [] : findLinks(span, links)
340    const whole = found.length === 1 && found[0]?.start === 0 && found[0].end === span?.length ? found[0] : undefined
341    out += whole === undefined || span === undefined ? match[0] : link(escapeMarkdown(span), whole.target)
342    at = match.index + match[0].length
343  }
344  return out + plain(markdown.slice(at))
345}
346
347export function escapeMarkdown(text: string): string {
348  return text.replace(/[\\`*_{}[\]()<>#+\-.!|~]/g, ch => `\\${ch}`)
349}
350
351export function isWindowsPath(path: string): boolean {
352  return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(path)
353}
354
355export function linkTarget(target: string): string {
356  const parens = (url: string) => url.replace(/\(/g, '%28').replace(/\)/g, '%29')
357  if (/^https?:\/\//i.test(target)) return parens(target)
358  const slashed = target.replace(/\\/g, '/')
359  const rooted = slashed.startsWith('//') ? slashed.slice(2) : slashed.startsWith('/') ? slashed.slice(1) : slashed
360  return `file://${slashed.startsWith('//') ? '' : '/'}${parens(encodeURI(rooted).replace(/[?#]/g, encodeURIComponent))}`
361}
362
363export function sniffLanguage(path: string, content: string): string | undefined {
364  if (!/(?:\.(?:output|log|txt|out)|(?:^|[\\/])[^.\\/]+)$/i.test(path)) return undefined
365  const text = content.trimStart()
366  if (/^[{[]/.test(text)) {
367    try {
368      JSON.parse(content)
369      return 'json'
370    } catch {}
371  }
372  if (/^<[!?A-Za-z]/.test(text)) return 'xml'
373  if (/^(?:\$|PS [^>\n]*>|❯) /m.test(content)) return 'shell'
374  return undefined
375}
376
377export function fromLinkTarget(href: string): string {
378  if (!/^file:/i.test(href)) return href
379  const url = new URL(href)
380  const path = decodeURIComponent(url.pathname)
381  if (url.host !== '') return `\\\\${url.host}${path.replace(/\//g, '\\')}`
382  return /^\/[A-Za-z]:/.test(path) ? path.slice(1).replace(/\//g, '\\') : path
383}
384
385export function outputLines(output: unknown): string[] {
386  const parts =
387    typeof output === 'string'
388      ? [output]
389      : output !== null && typeof output === 'object'
390        ? [(output as { stdout?: unknown }).stdout, (output as { stderr?: unknown }).stderr]
391        : []
392  const text = parts
393    .filter((part): part is string => typeof part === 'string' && part.trim() !== '')
394    .map(part => part.replace(/[\r\n]+$/, ''))
395    .join('\n')
396    .replace(ANSI, '')
397    .replace(/\r\n?/g, '\n')
398    .replace(CONTROL, '')
399  return text.trim() === '' ? [] : text.split('\n')
400}
401
types/index.d.ts 8 lines
1export type ShownReads = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'shell-highlight': { shown: ShownReads }
6  }
7}
8