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

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.
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.
Bash calls are coloured as bash, PowerShell calls as PowerShell.| Written as | Coloured as | |
|---|---|---|
node -e '…', node <<'EOF', bun, deno, tsx | JavaScript / TypeScript | |
python -c '…', python3 <<'EOF', py | Python | |
ruby -e, perl -e, php -r | Ruby, Perl, PHP | |
psql, sqlite3, mysql with a heredoc | SQL | |
bash -c '…', sh -c, zsh -ic '…' | bash | |
pwsh -Command '…', powershell -c | PowerShell | |
cmd /c "…" | batch | |
cat > script.py <<'EOF', `@'…'@ \ | Set-Content data.json` | by the file's extension |
… +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.Running…, an interrupted one Interrupted, a silent one (no output).␛).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 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.Read 1 file, ran 2 shell commands) above the shell calls and unfolds into the engine's own rows.| View | Command | Output |
|---|---|---|
| Fullscreen transcript, folded | this mod | this mod, 5 lines |
| Fullscreen transcript, unfolded by a click | this mod | this mod, all lines |
Detailed transcript (ctrl+o) | this mod | Claude Code, all lines (this mod for a run unfolded before) |
| Classic (non-fullscreen) transcript | this mod | Claude 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.
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.
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.
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, ….
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.
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.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.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.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.
Every behaviour above was checked on a real screen, on Claude Code 2.1.287:
Bash and PowerShell toolsctrl+o transcript, a running, a failed, an interrupted and a not yet approved call, 3,000 lines of output, a second mod loaded alongsideLinks 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.
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
hooks/register.ts 361 lines1import { 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}
361hooks/text.ts 401 lines1export 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}
401types/index.d.ts 8 lines1export type ShownReads = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'shell-highlight': { shown: ShownReads }
6 }
7}
8