Colors assistant markdown (headings, bold, inline code, paths, links, list markers) and Bash rows in the transcript.

##初期化 ####Mac OS X
cd mac ./initialize
####Windows
PowerShellで
cd windows ./initialize.ps1
##更新 ####Mac OS X
cd mac ./update
####Windows
PowerShellで
cd windows ./update.ps1
##エディタプラグインの更新 ####Neovim プラグインはlazy.nvimで自動管理されます。 手動更新が必要な場合はNeovimで
:Lazy update
hooks/register.tsx 129 lines1import type { Register } from 'claude-code'
2
3import { commandSpans } from './bash'
4import { classifyLine, inlineSpans, splitBlocks } from './markdown'
5import type { Span } from './markdown'
6
7// Mariana palette as hex: color names are drawn as the engine's own vivid colors.
8const COLOR = {
9 heading: '#d8b6d8',
10 code: '#c695c6',
11 path: '#5fb4b4',
12 link: '#85add6',
13 bold: '#f9ae58',
14 marker: '#8fa1b3',
15 ok: '#99c794',
16 error: '#ec5f66',
17} as const
18
19// Shell words of the Bash row, by role.
20const COMMAND_COLOR = {
21 program: '#85add6',
22 option: '#f9ae58',
23 string: '#99c794',
24 path: '#5fb4b4',
25 var: '#c695c6',
26} as const
27
28export const register: Register = on => {
29 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
30 if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
31
32 const { Box, Text, Markdown } = $.ui.resolve(e)
33
34 const spans = (items: Span[]) =>
35 items.map(s => {
36 switch (s.kind) {
37 case 'bold':
38 return (
39 <Text bold color={COLOR.bold}>
40 {s.text}
41 </Text>
42 )
43 case 'code':
44 return <Text color={COLOR.code}>{s.text}</Text>
45 case 'path':
46 return <Text color={COLOR.path}>{s.text}</Text>
47 case 'link':
48 return (
49 <Text color={COLOR.link} underline>
50 {s.text}
51 </Text>
52 )
53 default:
54 return s.text
55 }
56 })
57
58 const rows = splitBlocks(e.props.text).map(block => {
59 if (block.kind === 'markdown') return <Markdown text={block.text} />
60
61 const line = classifyLine(block.text)
62 if (line.kind === 'heading') {
63 return (
64 <Text bold color={COLOR.heading}>
65 {line.text}
66 </Text>
67 )
68 }
69 if (line.kind === 'list') {
70 return (
71 <Text>
72 {line.indent}
73 <Text color={COLOR.marker}>{line.marker} </Text>
74 {spans(inlineSpans(line.text))}
75 </Text>
76 )
77 }
78
79 return <Text>{spans(inlineSpans(line.text))}</Text>
80 })
81
82 return (
83 <Box flexDirection="row">
84 <Text>{e.props.isFirstOfReply ? '⏺ ' : ' '}</Text>
85 <Box flexDirection="column">{rows}</Box>
86 </Box>
87 )
88 })
89
90 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
91 const input = e.props.input as { command?: unknown } | null
92 const command = typeof input?.command === 'string' ? input.command : null
93
94 if (e.surface !== 'terminal' || e.props.isExpanded || command === null) {
95 return next(e)
96 }
97
98 const { Box, Text } = $.ui.resolve(e)
99 const dot = e.props.isErrored
100 ? COLOR.error
101 : e.props.isRunning
102 ? undefined
103 : COLOR.ok
104 const [first, ...rest] = command.split('\n')
105
106 return (
107 <Box flexDirection="row">
108 <Text color={dot} dimColor={e.props.isRunning}>
109 {'⏺ '}
110 </Text>
111 <Text bold>Bash</Text>
112 <Text> </Text>
113 <Text>
114 {commandSpans(first).map(s =>
115 s.kind === 'text' ? (
116 s.text
117 ) : s.kind === 'operator' ? (
118 <Text dimColor>{s.text}</Text>
119 ) : (
120 <Text color={COMMAND_COLOR[s.kind]}>{s.text}</Text>
121 ),
122 )}
123 </Text>
124 {rest.length > 0 ? <Text dimColor> (+{rest.length} lines)</Text> : null}
125 </Box>
126 )
127 })
128}
129hooks/bash.ts 80 lines1export type CommandSpan = {
2 kind: 'program' | 'option' | 'string' | 'path' | 'var' | 'operator' | 'text'
3 text: string
4}
5
6// Longest alternative first, so `&&` is not read as two `&` and `2>&1` stays whole.
7const OPERATOR = /^(?:&&|\|\||\d*>>?(?:&\d)?|[|;&<])/
8const SEPARATES_COMMANDS = /^(?:&&|\|\||[|;&])$/
9const ASSIGNMENT = /^[A-Za-z_]\w*=/
10
11// Where the word that starts at `from` ends: quoted parts belong to the word,
12// and an unclosed quote runs to the end of the command.
13function wordEnd(command: string, from: number): number {
14 let i = from
15
16 while (i < command.length && !/[\s|;&<>]/.test(command[i])) {
17 const c = command[i]
18 if (c === '\\') {
19 i += 2
20 } else if (c === "'" || c === '"') {
21 i++
22 while (i < command.length && command[i] !== c) {
23 i += c === '"' && command[i] === '\\' ? 2 : 1
24 }
25 i++
26 } else {
27 i++
28 }
29 }
30
31 return Math.min(i, command.length)
32}
33
34export function commandSpans(command: string): CommandSpan[] {
35 const spans: CommandSpan[] = []
36 let at = 0
37 let isCommandPosition = true
38
39 while (at < command.length) {
40 const rest = command.slice(at)
41 const space = /^\s+/.exec(rest)
42 if (space) {
43 spans.push({ kind: 'text', text: space[0] })
44 at += space[0].length
45 continue
46 }
47
48 const op = OPERATOR.exec(rest)
49 if (op) {
50 spans.push({ kind: 'operator', text: op[0] })
51 if (SEPARATES_COMMANDS.test(op[0])) isCommandPosition = true
52 at += op[0].length
53 continue
54 }
55
56 const end = wordEnd(command, at)
57 const word = command.slice(at, end)
58 at = end
59
60 if (/^['"]/.test(word)) {
61 spans.push({ kind: 'string', text: word })
62 } else if (isCommandPosition && ASSIGNMENT.test(word)) {
63 spans.push({ kind: 'var', text: word })
64 } else if (isCommandPosition) {
65 spans.push({ kind: 'program', text: word })
66 isCommandPosition = false
67 } else if (word.startsWith('-')) {
68 spans.push({ kind: 'option', text: word })
69 } else if (word.startsWith('$')) {
70 spans.push({ kind: 'var', text: word })
71 } else if (word.includes('/') || word.startsWith('~')) {
72 spans.push({ kind: 'path', text: word })
73 } else {
74 spans.push({ kind: 'text', text: word })
75 }
76 }
77
78 return spans
79}
80hooks/markdown.ts 96 lines1export type Block =
2 | { kind: 'markdown'; text: string }
3 | { kind: 'line'; text: string }
4
5export type Span =
6 | { kind: 'text'; text: string }
7 | { kind: 'bold'; text: string }
8 | { kind: 'code'; text: string }
9 | { kind: 'link'; text: string }
10 | { kind: 'path'; text: string }
11
12const FENCE = /^\s*(```|~~~)/
13// Tables and quotes keep the engine's own layout.
14const KEEP_AS_MARKDOWN = /^\s*(\||>)/
15
16// Fenced code, tables and quotes stay with the engine's Markdown so its
17// highlighting is kept; every other line is drawn here. An unclosed fence
18// (a reply still streaming) runs to the end of the text.
19export function splitBlocks(text: string): Block[] {
20 const blocks: Block[] = []
21 let isFenced = false
22
23 const pushMarkdown = (line: string) => {
24 const last = blocks[blocks.length - 1]
25 if (last?.kind === 'markdown') last.text += '\n' + line
26 else blocks.push({ kind: 'markdown', text: line })
27 }
28
29 for (const line of text.split('\n')) {
30 if (isFenced) {
31 pushMarkdown(line)
32 if (FENCE.test(line)) isFenced = false
33 } else if (FENCE.test(line)) {
34 isFenced = true
35 pushMarkdown(line)
36 } else if (KEEP_AS_MARKDOWN.test(line)) {
37 pushMarkdown(line)
38 } else {
39 blocks.push({ kind: 'line', text: line })
40 }
41 }
42
43 return blocks
44}
45
46// Alternatives are tried left to right at each position, so a code span or a
47// link wins over the bold or path pattern that sits inside it.
48const INLINE =
49 /`([^`\n]+)`|\*\*([^*\n]+)\*\*|\[[^\]\n]+\]\([^)\s]+\)|https?:\/\/[^\s)]+|(?:\.{0,2}\/)?(?:[\w.-]+\/)+[\w.-]+\.\w+(?::\d+)?|\b[\w-]+(?:\.[\w-]+)*\.(?:ts|tsx|js|json|py|sh|zsh|md|toml|ya?ml|lua|rs|go)(?::\d+)?\b/g
50
51// A backticked span that is only a file path is drawn as a path, not as code.
52const PATH_ONLY =
53 /^(?:(?:\.{0,2}\/)?(?:[\w.-]+\/)+[\w.-]+\.\w+|[\w-]+(?:\.[\w-]+)*\.(?:ts|tsx|js|json|py|sh|zsh|md|toml|ya?ml|lua|rs|go))(?::\d+)?$/
54
55export function inlineSpans(line: string): Span[] {
56 const spans: Span[] = []
57 let at = 0
58
59 for (const m of line.matchAll(INLINE)) {
60 const start = m.index ?? 0
61 if (start > at) spans.push({ kind: 'text', text: line.slice(at, start) })
62
63 if (m[1] !== undefined) {
64 spans.push({ kind: PATH_ONLY.test(m[1]) ? 'path' : 'code', text: m[1] })
65 } else if (m[2] !== undefined) spans.push({ kind: 'bold', text: m[2] })
66 else if (m[0].startsWith('[')) {
67 spans.push({ kind: 'link', text: m[0].slice(1, m[0].indexOf(']')) })
68 } else if (m[0].startsWith('http')) spans.push({ kind: 'link', text: m[0] })
69 else spans.push({ kind: 'path', text: m[0] })
70
71 at = start + m[0].length
72 }
73
74 if (at < line.length) spans.push({ kind: 'text', text: line.slice(at) })
75
76 return spans
77}
78
79export type LineKind =
80 | { kind: 'heading'; text: string }
81 | { kind: 'list'; indent: string; marker: string; text: string }
82 | { kind: 'plain'; text: string }
83
84export function classifyLine(line: string): LineKind {
85 const heading = /^#{1,6}\s+(.*)$/.exec(line)
86 if (heading) return { kind: 'heading', text: heading[1] }
87
88 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
89 if (item) {
90 const marker = /\d/.test(item[2]) ? item[2] : '•'
91 return { kind: 'list', indent: item[1], marker, text: item[3] }
92 }
93
94 return { kind: 'plain', text: line }
95}
96