Answer sections and a collapsible work log

Two mods for Claude Code by FLOWVIA Labs.
| Mod | What it does |
|---|---|
| neon-sections | Splits Claude's answers into Conclusion / Rationale / Needs your input / Next steps and draws each section in its own neon box. Tool calls from a finished turn fold into one work-log line. |
| clutch | A sports minigame under the spinner while Claude works: penalty shootout, batting, and a 3-point contest, all played with the Space key. |



Mods use Claude Code's mod API, which is in early access. A Claude Code update can change that API, so a mod may stop working until it is updated here.
In a Claude Code session:
/plugin marketplace add FLOWVIA-Labs/flowvia-mods
/plugin install neon-sections@flowvia-mods
/plugin install clutch@flowvia-mods
Or from your shell:
claude plugin marketplace add FLOWVIA-Labs/flowvia-mods
claude plugin install neon-sections@flowvia-mods
claude plugin install clutch@flowvia-mods
Install one or both.
Both mods speak English and Korean. Each has a language option:
| Value | Behavior |
|---|---|
auto (default) | Korean when your Claude Code language setting is Korean (korean, ko, ko-KR, 한국어), English otherwise |
ko | Always Korean |
en | Always English |
Change it in /plugin → Installed → the mod → Configure options, or in /config.
neon-sections asks Claude to structure answers with these headings and then draws them as boxes:
### Conclusion
### Rationale
### Needs your input
### Next steps
### Conclusion · Done, · Waiting, or · Blocked. It shows as a badge on the Conclusion box.### 결론 and so on) are recognized too, whatever the language setting.Run /ns-setting to open the settings panel. You can set:
The same settings are available as commands:
| Command | Effect | |||||
|---|---|---|---|---|---|---|
/ns-setting on / off | Turn the boxes on or off | |||||
/ns-setting status | Show the current settings | |||||
| `/ns-setting color <section> #RRGGBB [dark\ | light]` | Set a section color | ||||
| `/ns-setting effect <glow\ | latest\ | hover\ | blink\ | breathe> <section> <on\ | off>` | Toggle an effect |
| `/ns-setting fold <on\ | off>` | Fold tool calls into the work log |
<section> is conclusion, attention, or next (or the heading text). The Rationale box always uses a fixed gray.
When Claude starts working, a small field appears under the spinner. Each new prompt picks a sport at random, and a long task switches sports every minute.
| Key | Action |
|---|---|
| Space | Shoot or swing |
| 1 / 2 / 3 | Switch to penalty shootout / baseball / 3-point contest |
| 9 | Pause the game for this turn |
/clutch turns the game on or off.language value and the light/dark theme, and never send them anywhere.See AGENTS.md for the layout, setup, and test commands.
MIT © FLOWVIA Labs
Not affiliated with, endorsed by, or sponsored by Anthropic. "Claude" and "Claude Code" are used only to describe what these mods work with.
hooks/register.tsx 378 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, RenderChildren, RenderSurface, RenderElement, Timer } from 'claude-code'
3import type { NeonSectionsEntry, NeonSectionsTurn, Prismantis } from '../types/index'
4import { languageOption, messages, resolveLanguage, sectionKey, statusBadge } from './i18n'
5import { openPanel, registerPanel, resetPanel } from './panel'
6import { editable, effects } from './effects'
7import { glowColor, titleColor } from './colors'
8import { codeBoxes, registerCopy } from './code-box'
9import type { Section } from '../types/index'
10import { brighter, config, defaults, fenceAfter, names, parse, record, validColor } from './sections'
11
12import { empty } from './state'
13const session = atom({ plugin: 'neon-sections', key: 'session' } as const, empty)
14// Only animated boxes subscribe; storing this in session would redraw every site on each tick.
15const tick = atom({ plugin: 'neon-sections', key: 'tick' } as const, 0)
16const appearance = atom({ plugin: 'neon-sections', key: 'appearance' } as const,
17 { light: false, verbose: false })
18
19
20
21function lastText(turn?: NeonSectionsTurn) {
22 return turn?.entries.findLast(e => e.kind === 'text')
23}
24
25function summary(input: unknown): string {
26 if (!input || typeof input !== 'object') return ''
27 const values = input as Record<string, unknown>
28 const value = values.command ?? values.file_path ?? values.pattern ?? values.query ?? values.url
29 return typeof value === 'string' ? value.replace(/\s+/g, ' ').slice(0, 120) : ''
30}
31
32type Animation = { sites: Set<string>; timer?: Timer }
33function stop(animation: Animation) {
34 animation.timer?.cancel()
35 animation.timer = undefined
36 animation.sites.clear()
37}
38function animate($: EngineInterface, animation: Animation, id: string, active: boolean) {
39 if (active) animation.sites.add(id)
40 else animation.sites.delete(id)
41 if (!animation.sites.size) { stop(animation); return }
42 if (!animation.timer) animation.timer = $.clock.every(250, async () => {
43 const now = await $.clock.now()
44 await update($, tick, () => now)
45 })
46}
47
48async function body($: EngineInterface, e: RenderInput, text: string, columns: number, numberColor?: string,
49 code?: { draw: ReturnType<typeof codeBoxes>; title: string }): Promise<RenderChildren> {
50 const { Box, Text, Markdown } = $.ui.resolve(e)
51 const markdown = async (text: string, width = columns) => {
52 try {
53 const tree = await ($ as EngineInterface & { prismantis: Prismantis<RenderSurface, RenderElement> }).prismantis.markdown({ surface: e.surface, text, columns: width })
54 if (tree !== undefined) return tree
55 } catch { /* Optional renderer: keep the native Markdown fallback. */ }
56 return <Markdown text={text} />
57 }
58 const content = async (text: string, width = columns): Promise<RenderChildren> => {
59 if (!code) return markdown(text, width)
60 const parts: RenderChildren[] = [], lines = text.split('\n')
61 let prose: string[] = []
62 for (let i = 0; i < lines.length; i++) {
63 const fence = fenceAfter(lines[i]!, '')
64 if (!fence) { prose.push(lines[i]!); continue }
65 const start = i
66 if (prose.length) parts.push(await markdown(prose.join('\n'), width))
67 prose = []
68 const language = /^\s*(?:`{3,}|~{3,})\s*(\S+)?/.exec(lines[i]!)?.[1]
69 const source: string[] = []
70 while (i + 1 < lines.length && fenceAfter(lines[i + 1]!, fence)) source.push(lines[++i]!)
71 const closed = i + 1 < lines.length
72 if (closed) i++
73 parts.push(await code.draw(source.join('\n'), language, closed, width, code.title) ??
74 await markdown(lines.slice(start, i + 1).join('\n'), width))
75 }
76 if (prose.length) parts.push(await markdown(prose.join('\n'), width))
77 return parts
78 }
79 const nodes: RenderChildren[] = []
80 let pending: string[] = [], fence = ''
81 let previousNumbered = false
82 const flush = async (numbered = false) => {
83 const gap = previousNumbered && numbered && !pending.join('\n').trim()
84 if (pending.length && !gap) nodes.push(await content(pending.join('\n')))
85 pending = []
86 previousNumbered = numbered
87 return gap
88 }
89 const lines = text.split('\n')
90 const marker = /^(\d+(?:-\d+)?[.)])\s+(.*)$/
91 const continues = (at: number, open = '') => at < lines.length && (Boolean(open) ||
92 /^ {2,}\S/.test(lines[at]!) || (!lines[at]!.trim() &&
93 /^ {2,}\S/.test(lines.slice(at + 1).find(line => line.trim()) ?? '')))
94 let scanFence = '', numberWidth = 0
95 if (numberColor) for (const line of lines) {
96 const next = fenceAfter(line, scanFence)
97 if (!scanFence && !next) {
98 const match = marker.exec(line)
99 if (match) numberWidth = Math.max(numberWidth, match[1]!.length + 1)
100 }
101 scanFence = next
102 }
103 for (let i = 0; i < lines.length; i++) {
104 const line = lines[i]!
105 const nextFence = fenceAfter(line, fence)
106 if (fence || nextFence) { pending.push(line); fence = nextFence; continue }
107 const underlined = (numberColor ? /^(\s*(?:[-*+] |\d+(?:-\d+)?[.)]\s+)?)<u>(.*)/ :
108 /^(\s*(?:[-*+] |\d+[.)] )?)<u>(.*)/).exec(line)
109 if (underlined) {
110 let content = underlined[2]!, end = i
111 while (!content.includes('</u>') && end + 1 < lines.length) content += '\n' + lines[++end]
112 if (content.endsWith('</u>') && content.indexOf('</u>') === content.length - 4 &&
113 !(numberColor && marker.test(line) && continues(end + 1))) {
114 const numbered = numberColor && marker.exec(line)
115 const prefix = numbered ? numbered[1]!.padEnd(numberWidth) : underlined[1]!
116 const gap = await flush(Boolean(numbered))
117 const pieces = content.slice(0, -4).split(/(\*\*[^*]+\*\*)/g)
118 nodes.push(<Box {...(numbered ? { alignItems: 'flex-start' } : {})} {...(gap ? { marginTop: 1 } : {})}><Text color={numberColor ?? 'inactive'} {...(numberColor ? { bold: true } : {})}>{prefix}</Text><Text underline color="text">
119 {pieces.map(p => p.startsWith('**') && p.endsWith('**')
120 ? <Text bold>{p.slice(2, -2)}</Text> : p)}
121 </Text></Box>)
122 i = end
123 continue
124 }
125 }
126 const numbered = numberColor && marker.exec(line)
127 if (numbered) {
128 const gap = await flush(true)
129 const item = [numbered[2]!]
130 const indent = new RegExp(`^ {1,${numbered[1]!.length + 1}}`)
131 let itemFence = fenceAfter(item[0]!, '')
132 while (continues(i + 1, itemFence)) {
133 const line = lines[++i]!.replace(indent, '')
134 item.push(line)
135 itemFence = fenceAfter(line, itemFence)
136 }
137 let stripFence = ''
138 const itemText = item.map(line => {
139 const next = fenceAfter(line, stripFence)
140 const value = stripFence || next ? line : line.replace(/<\/?u>/g, '')
141 stripFence = next
142 return value
143 }).join('\n')
144 const width = Math.max(0, columns - numberWidth)
145 nodes.push(<Box alignItems="flex-start" {...(gap ? { marginTop: 1 } : {})}><Text color={numberColor} bold>{numbered[1]}{' '.repeat(numberWidth - numbered[1]!.length)}</Text>
146 <Box flexDirection="column" flexGrow={1}>{numbered[2]!.startsWith('<u>')
147 ? await body($, e, item.join('\n'), width, undefined, code) : await content(itemText, width)}</Box></Box>)
148 } else pending.push(line.replace(/<\/?u>/g, ''))
149 }
150 await flush()
151 return nodes
152}
153
154export const register: Register = (on, options) => {
155 const preference = atom({ plugin: 'neon-sections', key: 'language' } as const, languageOption(options.language))
156 const settings = atom({ plugin: 'neon-sections', key: 'config' } as const, config(options))
157 // Only cancellation handles live here; all values read by drawings are host state.
158 const animation: Animation = { sites: new Set() }
159 registerPanel(on, options, () => stop(animation))
160 const copySources = new Map<string, Map<string, string>>()
161 registerCopy(on, copySources)
162
163 on('session.start', async ($, e, next) => {
164 const result = await next(e)
165 await $.command.register({ name: 'ns-setting', description: messages[await resolveLanguage($, await read($, preference))].command })
166 const rows = await $.config.list()
167 await update($, appearance, () => ({
168 light: String(rows.find(r => r.key === 'theme')?.value ?? '').startsWith('light'),
169 verbose: rows.find(r => r.key === 'verbose')?.value === true,
170 }))
171 return result
172 })
173
174 on('prompt.compose', async ($, e, next) => {
175 const result = await next(e)
176 return { sections: [...result.sections.filter(s => s.id !== 'neon-sections:format'),
177 { id: 'neon-sections:format', text: messages[await resolveLanguage($, await read($, preference))].prompt, scope: 'session' as const }] }
178 })
179
180 on('turn.start', async ($, e, next) => {
181 await update($, session, s => ({ ...s, active: e.turnId,
182 turns: s.turns.some(t => t.id === e.turnId) ? s.turns : [...s.turns,
183 { id: e.turnId, entries: [], completedAt: null, attention: false }] }))
184 return next(e)
185 })
186
187 on('session.append', { door: 'response' }, async ($, e, next) => {
188 const result = await next(e)
189 if (e.agentId || !result.message) return result
190 const entries: NeonSectionsEntry[] = []
191 for (const block of result.message.content) {
192 if (block.type === 'text' && typeof block.text === 'string') {
193 entries.push({ kind: 'text', id: result.uuid, text: block.text })
194 } else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
195 entries.push({ kind: 'tool', id: block.id, name: block.name, summary: summary(block.input), failed: false })
196 }
197 }
198 await update($, session, s => ({ ...s, turns: s.turns.map(t => t.id !== s.active ? t : {
199 ...t, entries: [...t.entries.filter(old => !entries.some(entry => entry.id === old.id)), ...entries],
200 }) }))
201 return result
202 })
203
204 on('tool.call', async ($, e, next) => {
205 if (e.agentId) return next(e)
206 await update($, session, s => ({ ...s, turns: s.turns.map(t => t.id !== s.active ? t : {
207 ...t, entries: t.entries.some(entry => entry.id === e.tool_use_id) ? t.entries : [...t.entries,
208 { kind: 'tool' as const, id: e.tool_use_id, name: String(e.tool), summary: summary(e), failed: false }],
209 }) }))
210 const result = await next(e)
211 const errorResult = result.result as { isError?: boolean; isErrored?: boolean } | null
212 const failed = result.isError === true || typeof result.deny === 'string' ||
213 errorResult?.isError === true || errorResult?.isErrored === true
214 await update($, session, s => ({ ...s, turns: s.turns.map(t => ({ ...t,
215 entries: t.entries.map(entry => entry.kind === 'tool' && entry.id === e.tool_use_id
216 ? { ...entry, failed } : entry),
217 })) }))
218 return result
219 })
220
221 on('turn.complete', async ($, e, next) => {
222 if (!e.agentId) {
223 const now = await $.clock.now()
224 await update($, session, s => ({ ...s, latest: e.turnId,
225 turns: s.turns.map(t => t.id === e.turnId ? { ...t, completedAt: now, attention: true } : t) }))
226 }
227 return next(e)
228 })
229
230 on('prompt.submit', async ($, e, next) => {
231 stop(animation)
232 await update($, session, s => ({ ...s, turns: s.turns.map(t => ({ ...t, attention: false })) }))
233 return next(e)
234 })
235 on('session.end', async ($, e, next) => {
236 stop(animation)
237 copySources.clear()
238 await update($, session, () => empty)
239 await resetPanel($)
240 return next(e)
241 })
242
243 on('config.set', async ($, e, next) => {
244 const result = await next(e)
245 if (result.deny !== undefined) return result
246 if (e.key === 'neon-sections.language') {
247 await update($, preference, () => languageOption(result.value))
248 } else if (e.key.startsWith('neon-sections.')) {
249 const key = e.key.slice('neon-sections.'.length)
250 await update($, settings, c => config({ ...c, [key]: result.value }))
251 } else if (e.key === 'theme' || e.key === 'verbose') {
252 await update($, appearance, a => e.key === 'theme'
253 ? { ...a, light: String(result.value).startsWith('light') }
254 : { ...a, verbose: result.value === true })
255 }
256 return result
257 })
258
259 on('command.run', { command: 'ns-setting' }, async ($, e) => {
260 const t = messages[await resolveLanguage($, await read($, preference))]
261 const args = e.args.trim()
262 if (args === 'off' || args === 'on') {
263 if (args === 'off') stop(animation)
264 await update($, session, s => ({ ...s, enabled: args === 'on' }))
265 return { text: `${t.answerSections} ${args}` }
266 }
267 const c = await read($, settings)
268 if (!args) { await openPanel($, (await read($, appearance)).light, t.title); return {} }
269 if (args === 'status') return { text: `${t.answerSections} ${(await read($, session)).enabled ? 'on' : 'off'}\n` +
270 Object.entries(c).map(([key, value]) => `${key}: ${value}`).join('\n') }
271 const color = /^color (.+) (#[\da-fA-F]{6})(?: (dark|light))?$/.exec(args)
272 const effect = /^effect (glow|latest|hover|blink|breathe) (.+) (on|off)$/.exec(args)
273 const section = sectionKey(color?.[1] ?? effect?.[2] ?? '')
274 const fold = /^fold (on|off)$/.exec(args)
275 let key: string, value: string | boolean
276 if (fold) {
277 key = 'foldTools'
278 value = fold[1] === 'on'
279 } else if (color && section && section !== 'rationale') {
280 key = section +
281 'Color' + (color[3] === 'light' ? 'Light' : 'Dark')
282 value = color[2]!
283 } else if (effect && section && section !== 'rationale') {
284 key = section + effects.find(e => e.command === effect[1])!.key
285 value = effect[3] === 'on'
286 } else return { text: t.usage }
287 const result = await $.config.set({ key: `neon-sections.${key}`, value })
288 if (result.deny !== undefined) return { text: t.rejected(result.deny) }
289 await update($, settings, old => config({ ...old, [key]: result.value }))
290 return { text: `${key}: ${result.value}` }
291 })
292
293 on('ui.render', { component: ['AssistantMessage', 'ToolUse', 'ToolGroup', 'ToolResult'] }, async ($, e, next) => {
294 const s = await read($, session), c = await read($, settings), a = await read($, appearance)
295 const language = await resolveLanguage($, await read($, preference)), t = messages[language]
296 const site = `${e.surface}:${e.requestId}`
297 if (!s.enabled || a.verbose || ('isExpanded' in e.props && e.props.isExpanded === true)) {
298 animate($, animation, site, false)
299 return next(e)
300 }
301 const { Box, Text, Button } = $.ui.resolve(e)
302 // Regular terminals lack clicks and ToolUse's isExpanded, so hidden rows cannot be restored.
303 const foldable = e.surface !== 'terminal' || e.viewport?.isFullscreen === true
304 if (e.component !== 'AssistantMessage') {
305 if (!c.foldTools) return next(e)
306 const ids = e.component === 'ToolGroup' ? e.props.calls.map(call => call.tool_use_id) : [e.props.tool_use_id]
307 const canFold = ids.length > 0 && ids.every(id => s.turns.some(t => lastText(t) && t.entries.some(x => x.kind === 'tool' && x.id === id)))
308 return canFold && foldable ? <Box /> : next(e)
309 }
310 const turn = s.turns.find(t => t.entries.some(x => x.kind === 'text' && x.id === e.requestId))
311 const owner = lastText(turn)
312 const isOwner = owner?.id === e.requestId
313 if (c.foldTools && turn && !isOwner && turn.entries.some((x, i) => x.kind === 'tool' &&
314 i > turn.entries.findIndex(entry => entry.id === e.requestId))) return foldable ? <Box /> : next(e)
315 const streaming = s.turns.some(t => t.id === s.active && t.completedAt === null) &&
316 (!turn || turn.id === s.active)
317 const parsed = parse(e.props.text, streaming)
318 const elapsed = turn?.completedAt == null ? 1000 : Math.max(0, (await $.clock.now()) - turn.completedAt)
319 const canAnimate = parsed.boxed && isOwner && turn?.attention === true && e.props.onScreen !== null && foldable
320 const pulse = canAnimate && elapsed < 1000 && editable.some(s => parsed.sections[s.key] && c[`${s.key}Blink`])
321 const breathe = canAnimate && editable.some(s => parsed.sections[s.key] && c[`${s.key}Breathe`])
322 animate($, animation, site, !!(pulse || breathe))
323 if (pulse || breathe) await read($, tick)
324 const sections: RenderChildren[] = []
325 const drawCode = codeBoxes($, e, a.light, copySources, language)
326 // Match prismantis response width (columnsOf): subtract 4 columns of padding, defaulting to 100 columns before measurement.
327 const columns = Math.max(20, Math.floor(e.viewport?.columns ?? 100) - 4)
328 if (parsed.recognized) {
329 if (parsed.preamble) sections.push(await body($, e, parsed.preamble, columns))
330 for (const name of names) {
331 const text = parsed.sections[name]
332 if (!text) continue
333 if (!parsed.boxed) { sections.push(await body($, e, text, columns)); continue }
334 const prefix = name === 'rationale' ? '' : name
335 const key = `${prefix}Color${a.light ? 'Light' : 'Dark'}` as 'conclusionColorDark'
336 const base = name === 'rationale' ? (a.light ? '#949494' : '#626262') : (validColor(c[key]) ? c[key] : defaults[key])
337 const title = name === 'rationale' ? (a.light ? '#585858' : '#BCBCBC') : titleColor(base, a.light)
338 const effect = (suffix: 'Glow' | 'LatestOnly' | 'Hover' | 'Blink' | 'Breathe') =>
339 name !== 'rationale' && c[`${prefix as Section}${suffix}`]
340 const color = pulse && effect('Blink') ? brighter(base, 0.6 * Math.sin(Math.PI * elapsed / 1000)) :
341 breathe && effect('Breathe') ? brighter(base, 0.35 * (1 - Math.cos(Math.PI * elapsed / 1000)) / 2) : base
342 const dim = foldable && effect('LatestOnly') && !!s.latest && turn?.id !== s.latest
343 const hover = foldable && effect('Hover') ? { borderStyle: 'bold', borderColor: brighter(base), borderDimColor: false } : undefined
344 const headingLanguage = parsed.languages[name]!
345 const status = name === 'conclusion' ? statusBadge(parsed.status, headingLanguage) : undefined
346 const box = <Box key={`section-${name}`} flexDirection="column" borderStyle="round" borderColor={color}
347 {...(dim ? { borderDimColor: true } : {})} {...(hover ? { hover } : {})} paddingX={1} marginBottom={1}>
348 <Box justifyContent="space-between"><Text bold color={title}>{messages[headingLanguage].sections[name]}</Text>
349 {status ? <Text bold color={status[1]}>{status[0]}</Text> : null}</Box>
350 {await body($, e, text, Math.max(0, columns - 4 - (effect('Glow') ? 2 : 0)), name === 'next' ? base : undefined, { draw: drawCode, title })}
351 </Box>
352 sections.push(effect('Glow') ? <Box flexDirection="column" borderStyle="round" borderColor={glowColor(color, a.light)} {...(dim ? { borderDimColor: true } : {})}>{box}</Box> : box)
353 }
354 } else sections.push(await next(parsed.visible === e.props.text ? e : { ...e, props: { ...e.props, text: parsed.visible } }))
355 const tools = turn?.entries.filter(x => x.kind === 'tool') ?? []
356 if (c.foldTools && isOwner && turn && tools.length) {
357 const progress = turn.entries.filter((x, i) => x.kind === 'text' && turn.entries.slice(i + 1).some(after => after.kind === 'tool'))
358 const expanded = foldable && s.expanded[turn.id] === true
359 const counts = ` · ${t.tools(tools.length)}${progress.length ? ` · ${t.progress(progress.length)}` : ''}`
360 const label = `${expanded ? '▾' : '▸'} ${t.workLog}${counts}`
361 if (foldable) sections.push(<Button key={`process-${turn.id}`} plain label={label} onPress={() => update($, session,
362 old => ({ ...old, expanded: { ...old.expanded, [turn.id]: !old.expanded[turn.id] } }))}>
363 <Text color={a.light ? '#585858' : '#BCBCBC'}>{expanded ? '▾' : '▸'}{' '}{t.workLog}</Text>
364 <Text color="inactive">{counts}</Text>
365 </Button>)
366 if (expanded) for (const entry of turn.entries) {
367 if (entry.kind === 'text') {
368 if (progress.includes(entry)) sections.push(<Text color={a.light ? '#585858' : '#BCBCBC'}>{entry.text}</Text>)
369 } else sections.push(<Text><Text color={entry.failed ? 'error' : 'success'}>{entry.failed ? '✗' : '✓'} </Text>
370 <Text bold>{entry.name}</Text><Text color="inactive">{' '}{entry.summary}</Text></Text>)
371 }
372 sections.push(<Text color="inactive">{record(turn.entries, language)}</Text>)
373 }
374 if (!parsed.recognized && (!c.foldTools || !tools.length)) return sections[0] as Awaited<ReturnType<typeof next>>
375 return <Box flexDirection="column">{sections}</Box>
376 }).catch(($, e, next) => next(e))
377}
378hooks/i18n.ts 98 lines1import type { EngineInterface } from 'claude-code'
2
3export type Language = 'ko' | 'en'
4export type LanguageOption = 'auto' | Language
5export const sectionKeys = ['conclusion', 'rationale', 'attention', 'next'] as const
6export type SectionKey = typeof sectionKeys[number]
7export const languageOption = (value: unknown): LanguageOption => value === 'ko' || value === 'en' ? value : 'auto'
8export async function resolveLanguage($: Pick<EngineInterface, 'settings'>, value: unknown): Promise<Language> {
9 const option = languageOption(value)
10 if (option !== 'auto') return option
11 try {
12 const language = (await $.settings.read()).language
13 return typeof language === 'string' && ['korean', 'ko', 'ko-kr', '한국어'].includes(language.trim().toLowerCase()) ? 'ko' : 'en'
14 } catch { return 'en' }
15}
16
17export const messages = {
18 ko: {
19 sections: { conclusion: '결론', rationale: '근거', attention: '확인 필요', next: '다음 단계' },
20 gridSections: { conclusion: '결론', attention: '확인 필요', next: '다음 단계' },
21 statuses: { done: '완료', waiting: '대기', blocked: '막힘' },
22 title: '답변 섹션 설정', answerSections: '답변 섹션', show: '답변 섹션 표시', fold: '도구 호출 접기',
23 dark: '다크 테마용', light: '라이트 테마용', language: '언어', apply: '적용', reset: '기본값',
24 workLog: '작업 과정', tools: (n: number) => `도구 ${n}회`, progress: (n: number) => `진행 문장 ${n}개`,
25 failed: (n: number, details: string) => `실패 ${n}: ${details}`,
26 rejected: (reason: string) => `거부: ${reason}`,
27 command: '답변 섹션·색·테두리 효과 설정',
28 usage: '거부: 색은 결론|확인 필요|다음 단계와 #RRGGBB [dark|light], 효과는 /ns-setting effect <glow|latest|hover|blink|breathe> <결론|확인 필요|다음 단계> <on|off>, 접기는 /ns-setting fold <on|off>를 사용합니다. 근거 색은 고정입니다.',
29 colorError: '색 오류: #RRGGBB 형식으로 입력하세요',
30 approximation: (color: string, index: number) => `256색 화면에서는 ${color}(${index})로 보임`,
31 fixedRationale: '근거 · 바꿀 수 없습니다 · 효과 없음',
32 copyFailed: '복사 실패 · 문장을 직접 선택해 복사하세요',
33 effect: '효과', desktopOnly: '데스크톱·전체화면에서만',
34 unsupported: (name: string) => `${name} — 이 화면에서 안 됨 · 데스크톱·전체화면에서 바꾸세요`,
35 enabledFor: (section: string, enabled: boolean) => `${section}에 ${enabled ? '켜 둠' : '꺼 둠'}`,
36 gridKeys: '↑↓ 효과 ←→ 섹션 Space 체크/해제 Esc 나가기',
37 panelKeys: 'Tab 다음 칸 Enter 고르기 o 표시 f 접기 t 테마 Esc 닫기',
38 effects: {
39 Glow: ['이중 테두리 글로우', '상자 바깥에 흐린 테두리를 한 겹 더합니다'],
40 LatestOnly: ['최신 답변만 네온', '지난 답변의 상자를 흐리게 합니다'],
41 Hover: ['마우스 올리면 강조', '마우스를 올린 상자를 굵고 밝게 합니다'],
42 Blink: ['깜빡임', '답변이 끝나면 한 번 밝아졌다 돌아옵니다'],
43 Breathe: ['숨쉬기', '다음 질문까지 천천히 밝기가 오갑니다'],
44 },
45 palettes: {
46 dark: ['빨강', '주황', '금색', '라임', '네온 그린', '시안', '파랑', '보라', '마젠타', '살구', '연노랑', '민트', '청록', '연하늘', '라벤더', '연분홍', '장미', '흰색'],
47 light: ['빨강', '주황', '금색', '올리브', '초록', '시안', '파랑', '보라', '마젠타', '다홍', '갈색', '청록', '하늘', '남색', '라벤더', '연분홍', '장미', '먹색'],
48 },
49 prompt: "[답변 섹션 표기 — neon-sections mod]\n답변이 두 부분 이상이면 아래 소제목을 이 순서로, 줄 맨 앞에 정확히 쓴다. 내용이 없는 섹션은 쓰지 않는다.\n### 결론\n### 근거\n### 확인 필요\n### 다음 단계\n- 작업 결과를 보고하는 답이면 결론 소제목 뒤에 상태를 붙인다: \"### 결론 · 완료\", \"### 결론 · 대기\", \"### 결론 · 막힘\". 질문에 답하는 경우처럼 상태가 없는 답에는 붙이지 않는다.\n- 결론은 한두 문장으로 쓴다.\n- 근거의 핵심 문장은 **굵게** 쓴다. 문장 하나를 통째로 강조할 때만 그 목록 항목이나 문단 전체를 <u>와 </u>로 감싼다.\n- 확인 필요에는 사용자가 답하거나 정해야 하는 것만 쓴다.\n- 다음 단계는 할 일을 줄 맨 앞 번호(1. 2.)로 쓴다. 한 번호 안에 할 일이 둘 이상이면 1-1. 1-2.로 나눈다. 항목에 딸린 문장과 코드 블록은 번호 뒤 글자 시작 위치만큼 들여 써서 그 항목 안에 둔다.\n- 한두 문장으로 끝나는 짧은 답과 도구 호출 사이의 진행 상황 문장에는 소제목을 쓰지 않는다.\n- 다른 규칙이나 스킬이 출력 형식을 정했으면 이 소제목을 쓰지 않고 그 형식을 따른다.",
50 },
51 en: {
52 sections: { conclusion: 'Conclusion', rationale: 'Rationale', attention: 'Needs your input', next: 'Next steps' },
53 gridSections: { conclusion: 'Concl.', attention: 'Input', next: 'Next' },
54 statuses: { done: 'Done', waiting: 'Waiting', blocked: 'Blocked' },
55 title: 'Answer sections', answerSections: 'Answer sections', show: 'Show answer sections', fold: 'Fold tool calls',
56 dark: 'Dark theme', light: 'Light theme', language: 'Language', apply: 'Apply', reset: 'Default',
57 workLog: 'Work log', tools: (n: number) => `${n} tool ${n === 1 ? 'call' : 'calls'}`,
58 progress: (n: number) => `${n} progress ${n === 1 ? 'note' : 'notes'}`,
59 failed: (n: number, details: string) => `${n} failed: ${details}`,
60 rejected: (reason: string) => `Rejected: ${reason}`,
61 command: 'Configure answer sections, colors and border effects',
62 usage: 'Rejected: for colors use Conclusion|Needs your input|Next steps and #RRGGBB [dark|light]; for effects use /ns-setting effect <glow|latest|hover|blink|breathe> <Conclusion|Needs your input|Next steps> <on|off>; for folding use /ns-setting fold <on|off>. The rationale color is fixed.',
63 colorError: 'Invalid color: enter #RRGGBB',
64 approximation: (color: string, index: number) => `Shown as ${color}(${index}) on 256-color displays`,
65 fixedRationale: 'Rationale · fixed color · no effects',
66 copyFailed: 'Copy failed · select the text and copy it yourself',
67 effect: 'Effect', desktopOnly: 'Desktop and fullscreen only',
68 unsupported: (name: string) => `${name} — unavailable here · change it on desktop or in fullscreen`,
69 enabledFor: (section: string, enabled: boolean) => `${enabled ? 'On' : 'Off'} for ${section}`,
70 gridKeys: '↑↓ effect ←→ section Space toggle Esc exit',
71 panelKeys: 'Tab next field Enter select o show f fold t theme Esc close',
72 effects: {
73 Glow: ['Double-border glow', 'Add a dim outer border to the box'],
74 LatestOnly: ['Neon on latest only', 'Dim boxes in earlier answers'],
75 Hover: ['Highlight on hover', 'Make the hovered box bold and bright'],
76 Blink: ['Blink', 'Brighten once when the answer finishes'],
77 Breathe: ['Breathe', 'Slowly vary brightness until the next question'],
78 },
79 palettes: {
80 dark: ['Red', 'Orange', 'Gold', 'Lime', 'Neon green', 'Cyan', 'Blue', 'Purple', 'Magenta', 'Apricot', 'Pale yellow', 'Mint', 'Teal', 'Pale sky blue', 'Lavender', 'Pale pink', 'Rose', 'White'],
81 light: ['Red', 'Orange', 'Gold', 'Olive', 'Green', 'Cyan', 'Blue', 'Purple', 'Magenta', 'Vermilion', 'Brown', 'Teal', 'Sky blue', 'Navy', 'Lavender', 'Pale pink', 'Rose', 'Charcoal'],
82 },
83 prompt: "[Answer sections — neon-sections mod]\nWhen an answer has two or more parts, write the headings below in this order, exactly as shown, at the start of a line. Leave out any section that would be empty.\n### Conclusion\n### Rationale\n### Needs your input\n### Next steps\n- Write these headings exactly as shown, even when you answer in another language.\n- When the answer reports the result of a task, add a status after the Conclusion heading: \"### Conclusion · Done\", \"### Conclusion · Waiting\", \"### Conclusion · Blocked\". Add no status when there is none, such as when answering a question.\n- Keep the conclusion to one or two sentences.\n- Put the key sentences of the rationale in **bold**. Wrap a whole list item or paragraph in <u> and </u> only when the entire sentence needs emphasis.\n- Under Needs your input, list only what the user has to answer or decide.\n- Under Next steps, write each task as a number at the start of a line (1. 2.). When one number holds two or more tasks, split them as 1-1. 1-2. Indent sentences and code blocks that belong to an item to where the text after its number starts, so they stay inside that item.\n- Do not use these headings for short answers of one or two sentences, or for progress sentences between tool calls.\n- When another rule or skill defines the output format, follow that format instead of these headings.",
84 },
85} as const
86
87export function sectionKey(name: string): SectionKey | undefined {
88 return sectionKeys.find(key => key === name.toLowerCase() ||
89 Object.values(messages).some(pack => pack.sections[key].toLowerCase() === name.toLowerCase()))
90}
91export function statusBadge(status: string, language: Language) {
92 const key = (['done', 'waiting', 'blocked'] as const).find(key =>
93 Object.values(messages).some(pack => pack.statuses[key] === status))
94 if (!key) return undefined
95 const [symbol, color] = { done: ['✓', 'success'], waiting: ['…', 'warning'], blocked: ['✗', 'error'] }[key]!
96 return [`${symbol} ${messages[language].statuses[key]}`, color] as const
97}
98hooks/panel.tsx 146 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { NeonSectionsConfig, PanelState, ColorKey, Section } from '../types/index'
4import { empty } from './state'
5import { languageOption, messages, resolveLanguage } from './i18n'
6import { editable, effects } from './effects'
7import { approximation, background, glowColor, palettes, titleColor } from './colors'
8import { config, defaults, validColor } from './sections'
9import { drawGrid } from './effects-grid'
10
11const session = atom({ plugin: 'neon-sections', key: 'session' } as const, empty)
12const appearance = atom({ plugin: 'neon-sections', key: 'appearance' } as const, { light: false, verbose: false })
13export const panelId = 'neon-sections-settings'
14const initial: PanelState = { light: null, messages: { conclusion: null, attention: null, next: null }, row: 0, column: 0, notice: null }
15const panel = atom({ plugin: 'neon-sections', key: 'panel' } as const, initial)
16export async function resetPanel($: EngineInterface) { await update($, panel, () => initial) }
17export async function openPanel($: EngineInterface, light: boolean, title: string) {
18 await update($, panel, (p): PanelState => p.light === null ? { ...p, light } : p)
19 await $.ui.open({ id: panelId, title, focus: true, closeOnEscape: true })
20}
21async function save($: EngineInterface, c: NeonSectionsConfig, key: keyof NeonSectionsConfig, value: string | boolean) {
22 const result = await $.config.set({ key: `neon-sections.${key}`, value })
23 if (result.deny !== undefined) {
24 const section = editable.find(s => key.startsWith(s.key))?.key
25 await update($, panel, (p): PanelState => key.includes('Color')
26 ? { ...p, messages: { ...p.messages, [section!]: { kind: 'rejected', reason: result.deny } } }
27 : { ...p, notice: { kind: 'rejected', reason: result.deny } })
28 return false
29 }
30 const settings = atom({ plugin: 'neon-sections', key: 'config' } as const, c)
31 await update($, settings, old => config({ ...old, [key]: result.value }))
32 return true
33}
34async function select($: EngineInterface, c: NeonSectionsConfig, row: number, column: number, toggle: boolean, interactive: boolean) {
35 const effect = effects[row]!, section = editable[column]!
36 await update($, panel, (p): PanelState => ({ ...p, row, column, notice: !interactive && row > 0 && toggle
37 ? { kind: 'unsupported', row } : null }))
38 if (toggle && (interactive || row === 0)) {
39 const key = `${section.key}${effect.key}` as const
40 await save($, c, key, !c[key])
41 }
42}
43export function registerPanel(on: On, options: PluginOptions, stop: () => void) {
44 const preference = atom({ plugin: 'neon-sections', key: 'language' } as const, languageOption(options.language))
45 const settings = atom({ plugin: 'neon-sections', key: 'config' } as const, config(options))
46 on('ui.message', { component: 'Pane', requestId: panelId, element: 'effects-grid' }, async ($, e) => {
47 if (!e.data || typeof e.data !== 'object') return {}
48 const { row, column, toggle } = e.data as Record<string, unknown>
49 if (typeof row !== 'number' || !Number.isInteger(row) || row < 0 || row >= effects.length ||
50 typeof column !== 'number' || !Number.isInteger(column) || column < 0 || column >= editable.length || typeof toggle !== 'boolean') return {}
51 await select($, await read($, settings), row, column, toggle, true)
52 return {}
53 })
54 on('ui.focus', { component: 'Pane', requestId: panelId }, async ($, e, next) => {
55 const result = await next(e)
56 if (result.deny !== undefined) return result
57 const cell = /^effect-([0-4])-([0-2])$/.exec(e.element ?? '')
58 if (cell) await update($, panel, (p): PanelState => ({ ...p, row: Number(cell[1]), column: Number(cell[2]), notice: null }))
59 const color = /^palette-(conclusion|attention|next)-(\d+)$/.exec(e.element ?? '')
60 if (color) {
61 const p = await read($, panel), a = await read($, appearance)
62 const palette = palettes[(p.light ?? a.light) ? 'light' : 'dark']
63 const choice = palette[Number(color[2])]
64 if (choice) await update($, panel, (p): PanelState => ({ ...p, messages: { ...p.messages, [color[1]!]: { kind: 'palette', index: Number(color[2]), light: p.light ?? a.light } } }))
65 }
66 return result
67 })
68 on('ui.render', { component: 'Pane', requestId: panelId }, async ($, e, next) => {
69 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
70 const p = await read($, panel), c = await read($, settings), s = await read($, session), a = await read($, appearance)
71 const language = await resolveLanguage($, await read($, preference)), t = messages[language]
72 const option = await read($, preference)
73 const light = p.light ?? a.light, wide = e.props.bodyColumns >= 110
74 const interactive = e.surface !== 'terminal' || e.viewport?.isFullscreen === true
75 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
76 const palette = palettes[light ? 'light' : 'dark']
77 async function color(section: Section, value: string) {
78 if (!validColor(value)) { await update($, panel, (p): PanelState => ({ ...p, messages: { ...p.messages, [section]: { kind: 'colorError' } } })); return }
79 const key: ColorKey = `${section}Color${light ? 'Light' : 'Dark'}`
80 if (await save($, c, key, value)) await update($, panel, (p): PanelState => ({ ...p, messages: { ...p.messages, [section]: { kind: 'approximation', value } } }))
81 }
82 function colorMessage(section: Section) {
83 const message = p.messages[section]
84 if (!message) return ' '
85 if (message.kind === 'rejected') return t.rejected(message.reason)
86 if (message.kind === 'colorError') return t.colorError
87 if (message.kind === 'approximation') return approximation(message.value, language) || ' '
88 const theme = message.light ? 'light' : 'dark'
89 return `${t.sections[section]} · ${t.palettes[theme][message.index]} ${palettes[theme][message.index]![1]}`
90 }
91 const grid = { values: c, row: p.row, column: p.column, notice: !p.notice ? '' : p.notice.kind === 'rejected' ? t.rejected(p.notice.reason) : t.unsupported(t.effects[effects[p.notice.row]!.key][0]), wide, interactive, language }
92 return <Box flexDirection="column">
93 <Text bold>{t.title}</Text>
94 <Button key="enabled" plain hotkey="o" autoFocus label={`${s.enabled ? '[✓]' : '[ ]'} ${t.show}`}
95 onPress={async () => { stop(); await update($, session, s => ({ ...s, enabled: !s.enabled })) }} />
96 <Button key="foldTools" plain hotkey="f" label={`${c.foldTools ? '[✓]' : '[ ]'} ${t.fold}`}
97 onPress={() => save($, c, 'foldTools', !c.foldTools)} />
98 <Button key="theme" plain hotkey="t" label={`${light ? '○' : '●'} ${t.dark} ${light ? '●' : '○'} ${t.light}`}
99 onPress={() => update($, panel, (p): PanelState => ({ ...p, light: !light, messages: initial.messages }))} />
100 <Button key="language" plain label={`${t.language} ${(['auto', 'ko', 'en'] as const).map(value => `${option === value ? '●' : '○'} ${value}`).join(' ')}`}
101 onPress={async () => {
102 const value = option === 'auto' ? 'ko' : option === 'ko' ? 'en' : 'auto'
103 const result = await $.config.set({ key: 'neon-sections.language', value })
104 if (result.deny !== undefined) {
105 await update($, panel, (p): PanelState => ({ ...p, notice: { kind: 'rejected', reason: result.deny! } }))
106 } else {
107 await update($, preference, () => languageOption(result.value))
108 await $.ui.open({ id: panelId, title: messages[await resolveLanguage($, result.value)].title, focus: true, closeOnEscape: true })
109 }
110 }} />
111 {editable.map(section => {
112 const key: ColorKey = `${section.key}Color${light ? 'Light' : 'Dark'}`
113 const rows = wide ? [palette] : [palette.slice(0, 9), palette.slice(9)]
114 return <Box key={`colors-${section.key}`} flexDirection="column" {...(light !== a.light ? { backgroundColor: background(light) } : {})}>
115 <Box justifyContent="space-between">
116 <Text bold color={titleColor(c[key], light)}><Text color={c[key]}>██ </Text>{t.sections[section.key]}</Text>
117 <Input key={`hex-${section.key}`} label="hex" value={c[key]} submitLabel={t.apply} onSubmit={value => color(section.key, value)} />
118 <Button key={`reset-${section.key}`} plain label={t.reset} onPress={() => color(section.key, defaults[key])} />
119 </Box>
120 {rows.map((row, rowIndex) => <Box key={`palette-row-${section.key}-${rowIndex}`}>
121 {row.map((choice, index) => <Button key={`palette-${section.key}-${rowIndex * 9 + index}`} plain
122 label={`${t.sections[section.key]} · ${t.palettes[light ? 'light' : 'dark'][rowIndex * 9 + index]} ${choice[1]}`} onPress={() => color(section.key, choice[1])}>
123 <Text color={choice[1]}>{c[key].toUpperCase() === choice[1] ? '[██]' : ' ██ '}</Text>
124 </Button>)}
125 </Box>)}
126 <Text color={light ? '#585858' : '#BCBCBC'}>{colorMessage(section.key)}</Text>
127 </Box>
128 })}
129 <Text><Text color={light ? '#949494' : '#626262'}>██ </Text><Text color={light ? '#585858' : '#BCBCBC'}>{t.fixedRationale}</Text></Text>
130 <Box key="previews" {...(light !== a.light ? { backgroundColor: background(light) } : {})}>
131 {editable.map(section => {
132 const key: ColorKey = `${section.key}Color${light ? 'Light' : 'Dark'}`
133 const box = <Box key={`preview-${section.key}`} flexDirection="column" borderStyle="round" borderColor={c[key]} paddingX={1}>
134 <Text bold color={titleColor(c[key], light)}>{t.sections[section.key]}</Text>
135 </Box>
136 return c[`${section.key}Glow`] ? <Box key={`preview-glow-${section.key}`} borderStyle="round" borderColor={glowColor(c[key], light)}>{box}</Box> : box
137 })}
138 </Box>
139 {interactive ? <Client key="effects-grid" module="./effects-grid.tsx" props={grid} />
140 : drawGrid({ Box, Text, Button }, grid, (row, column, toggle) => select($, c, row, column, toggle, false))}
141 {interactive ? <Text dimColor>{t.gridKeys}</Text> : null}
142 <Text dimColor>{t.panelKeys}</Text>
143 </Box>
144 }).catch(($, e, next) => next(e))
145}
146hooks/effects.ts 15 lines1import { messages } from './i18n'
2
3export const editable = [
4 { key: 'conclusion', name: messages.ko.sections.conclusion },
5 { key: 'attention', name: messages.ko.sections.attention },
6 { key: 'next', name: messages.ko.sections.next },
7] as const
8export const effects = [
9 { key: 'Glow', command: 'glow', name: messages.ko.effects.Glow[0], description: messages.ko.effects.Glow[1] },
10 { key: 'LatestOnly', command: 'latest', name: messages.ko.effects.LatestOnly[0], description: messages.ko.effects.LatestOnly[1] },
11 { key: 'Hover', command: 'hover', name: messages.ko.effects.Hover[0], description: messages.ko.effects.Hover[1] },
12 { key: 'Blink', command: 'blink', name: messages.ko.effects.Blink[0], description: messages.ko.effects.Blink[1] },
13 { key: 'Breathe', command: 'breathe', name: messages.ko.effects.Breathe[0], description: messages.ko.effects.Breathe[1] },
14] as const
15hooks/colors.ts 57 lines1import { messages } from './i18n'
2import type { Language } from './i18n'
3
4const dark = [
5 [messages.ko.palettes.dark[0], '#FF5F5F'], [messages.ko.palettes.dark[1], '#FF8700'], [messages.ko.palettes.dark[2], '#FFD700'], [messages.ko.palettes.dark[3], '#AFFF00'],
6 [messages.ko.palettes.dark[4], '#00FF87'], [messages.ko.palettes.dark[5], '#00D7FF'], [messages.ko.palettes.dark[6], '#5F87FF'], [messages.ko.palettes.dark[7], '#AF5FFF'],
7 [messages.ko.palettes.dark[8], '#FF5FFF'], [messages.ko.palettes.dark[9], '#FFAF87'], [messages.ko.palettes.dark[10], '#FFFF87'], [messages.ko.palettes.dark[11], '#87FFD7'],
8 [messages.ko.palettes.dark[12], '#00AFAF'], [messages.ko.palettes.dark[13], '#AFD7FF'], [messages.ko.palettes.dark[14], '#D7AFFF'], [messages.ko.palettes.dark[15], '#FFAFD7'],
9 [messages.ko.palettes.dark[16], '#FF0087'], [messages.ko.palettes.dark[17], '#EEEEEE'],
10] as const
11const light = [
12 [messages.ko.palettes.light[0], '#D70000'], [messages.ko.palettes.light[1], '#D75F00', '#AF5F00'], [messages.ko.palettes.light[2], '#AF8700', '#875F00'],
13 [messages.ko.palettes.light[3], '#5F8700', '#5F5F00'], [messages.ko.palettes.light[4], '#00875F'], [messages.ko.palettes.light[5], '#0087AF', '#005F87'],
14 [messages.ko.palettes.light[6], '#005FD7'], [messages.ko.palettes.light[7], '#8700D7'], [messages.ko.palettes.light[8], '#D700D7', '#AF00AF'],
15 [messages.ko.palettes.light[9], '#D75F5F', '#AF5F5F'], [messages.ko.palettes.light[10], '#AF5F00'], [messages.ko.palettes.light[11], '#008787', '#005F5F'],
16 [messages.ko.palettes.light[12], '#5F87D7', '#5F5FAF'], [messages.ko.palettes.light[13], '#0000AF'], [messages.ko.palettes.light[14], '#875FD7'],
17 [messages.ko.palettes.light[15], '#D75FAF', '#AF0087'], [messages.ko.palettes.light[16], '#D7005F'], [messages.ko.palettes.light[17], '#3A3A3A'],
18] as const
19export const palettes = { dark, light }
20export const background = (isLight: boolean) => isLight ? '#FFFFFF' : '#0D0D0D'
21const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
22const hex = (channels: number[]) => '#' + channels.map(c => Math.round(c).toString(16).padStart(2, '0')).join('').toUpperCase()
23const levels = [0, 95, 135, 175, 215, 255]
24export function contrastOnWhite(color: string) {
25 const linear = rgb(color).map(c => c / 255).map(c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
26 return 1.05 / (linear[0]! * 0.2126 + linear[1]! * 0.7152 + linear[2]! * 0.0722 + 0.05)
27}
28export function contrastOnDark(color: string) {
29 return (1.05 / contrastOnWhite(color)) / (1.05 / contrastOnWhite('#0D0D0D'))
30}
31export function titleColor(color: string, isLight: boolean): string {
32 if (!isLight) return color
33 const preset = light.find(p => p[1] === color.toUpperCase())
34 if (preset) return preset.length === 3 ? preset[2] : color
35 const channels = rgb(color)
36 while (contrastOnWhite(hex(channels)) < 4.5) {
37 for (let i = 0; i < channels.length; i++) channels[i] = Math.max(0, channels[i]! - 1)
38 }
39 return hex(channels)
40}
41export function glowColor(color: string, isLight: boolean) {
42 const bg = isLight ? 255 : 13
43 return hex(rgb(color).map(c => c + (bg - c) * 0.55).map(c =>
44 levels.reduce((best, n) => Math.abs(n - c) < Math.abs(best - c) ? n : best)))
45}
46export function approximation(color: string, language: Language = 'ko') {
47 const channels = rgb(color)
48 let distance = Infinity, closest = '', index = 16
49 for (let n = 16; n <= 255; n++) {
50 const k = n - 16
51 const candidate = n < 232 ? [levels[Math.floor(k / 36)]!, levels[Math.floor(k / 6) % 6]!, levels[k % 6]!] : Array<number>(3).fill(8 + (n - 232) * 10)
52 const d = candidate.reduce((sum, c, i) => sum + (c - channels[i]!) ** 2, 0)
53 if (d < distance) { distance = d; closest = hex(candidate); index = n }
54 }
55 return distance === 0 ? '' : messages[language].approximation(closest, index)
56}
57hooks/code-box.tsx 128 lines1import { messages } from './i18n'
2import type { Language } from './i18n'
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, RenderInput, RenderSurface } from 'claude-code'
5import { contrastOnDark } from './colors'
6
7const copy = atom({ plugin: 'neon-sections', key: 'copy' } as const, {})
8
9function cells(char: string): number {
10 if (/\p{Mark}/u.test(char) || char === '\u200d') return 0
11 const n = char.codePointAt(0)!
12 return n >= 0x1100 && (n <= 0x115f || n === 0x2329 || n === 0x232a ||
13 (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
14 (n >= 0xac00 && n <= 0xd7a3) || (n >= 0xf900 && n <= 0xfaff) ||
15 (n >= 0xfe10 && n <= 0xfe19) || (n >= 0xfe30 && n <= 0xfe6f) ||
16 (n >= 0xff01 && n <= 0xff60) || (n >= 0xffe0 && n <= 0xffe6) ||
17 (n >= 0x1f300 && n <= 0x1faff) || (n >= 0x20000 && n <= 0x3fffd)) ? 2 : 1
18}
19
20export function wrapCode(source: string, columns: number, firstWidth = columns, pad = true): string[] {
21 let first = true
22 return source.split('\n').flatMap(line => {
23 const chars = Array.from(line.replace(/\t/g, ' '))
24 const rows: string[] = []
25 for (let start = 0; start < chars.length;) {
26 const width = first ? firstWidth : columns
27 first = false
28 let end = start, used = 0, space = -1
29 while (end < chars.length && used + cells(chars[end]!) <= width) {
30 used += cells(chars[end]!)
31 if (chars[end] === ' ') space = end
32 end++
33 }
34 if (end === start) { rows.push(' '.repeat(width)); start++; continue }
35 let next = end
36 if (end < chars.length) {
37 if (chars[end] === ' ') next++
38 else if (space >= start) next = end = space + 1
39 }
40 const row = chars.slice(start, end)
41 rows.push(row.join('') + (pad ? ' '.repeat(width - row.reduce((n, c) => n + cells(c), 0)) : ''))
42 start = next
43 }
44 const width = first ? firstWidth : columns
45 first = false
46 return rows.length ? rows : [pad ? ' '.repeat(width) : ' ']
47 })
48}
49
50async function copySource($: EngineInterface, id: string, source: string, surface: RenderSurface) {
51 const started = await update($, copy, old => ({ ...old,
52 [id]: { revision: (old[id]?.revision ?? 0) + 1, label: null, failed: false } }))
53 const revision = started[id]!.revision
54 let success = false
55 try { success = (await $.ui.copy({ text: source, surface })).isCopied } catch { /* Show the same failure as a refused copy. */ }
56 await update($, copy, old => old[id]?.revision !== revision ? old : { ...old,
57 [id]: { revision, label: success ? 'COPIED' as const : 'FAILED' as const, failed: !success } })
58 const timer = $.clock.every(success ? 1500 : 2000, async () => {
59 timer.cancel()
60 await update($, copy, old => old[id]?.revision !== revision ? old : {
61 ...old, [id]: { ...old[id]!, label: null },
62 })
63 })
64}
65
66export function registerCopy(on: On, sources: Map<string, Map<string, string>>) {
67 on('ui.message', { component: 'AssistantMessage' }, async ($, e) => {
68 if (e.surface === 'terminal' || e.module !== 'hooks/copy.tsx' ||
69 !/^copy-(0|[1-9]\d*)$/.test(e.element) || e.data !== 'copy') return {}
70 const source = sources.get(`${e.surface}:${e.requestId}`)?.get(e.element)
71 if (source === undefined) return {}
72 await copySource($, `${e.requestId}:${e.element.slice(5)}`, source, e.surface)
73 return {}
74 })
75}
76
77export function codeBoxes($: EngineInterface, e: RenderInput, light: boolean, sources: Map<string, Map<string, string>>, locale: Language = 'ko') {
78 const codes = new Map<string, string>()
79 if (e.surface !== 'terminal') sources.set(`${e.surface}:${e.requestId}`, codes)
80 let index = 0
81 return async (source: string, language: string | undefined, closed: boolean, width: number, title: string) => {
82 const n = index++, id = `${e.requestId}:${n}`, key = `copy-${n}`
83 if (width < 5) return undefined
84 const { Box, Text, Button, Code } = $.ui.resolve(e)
85 const desktop = e.surface !== 'terminal'
86 const clickable = closed && (desktop || e.viewport?.isFullscreen === true) && width >= (desktop ? 15 : 13)
87 const state = clickable ? (await read($, copy))[id] : undefined
88 const color = !light && contrastOnDark(title) < 4.5 ? '#EEEEEE' : title
89 const statusColor = state?.label === 'COPIED'
90 ? (desktop ? '#4EBA65' : light ? '#008700' : '#5FAF5F')
91 : (desktop ? '#FF6B80' : light ? '#AF005F' : '#FF5F87')
92 const button = clickable && !desktop ? <Button key={key} plain label={state?.label ?? '[COPY]'} onPress={press => copySource($, id, source, press.surface)}>
93 {state?.label ? <Text color={statusColor}>{state.label}</Text> : [
94 <Text color={color}>[</Text>, <Text color={color} bold>COPY</Text>, <Text color={color}>]</Text>,
95 ]}
96 </Button> : null
97 const notice = state?.failed ? <Text dimColor color="inactive">{messages[locale].copyFailed}</Text> : null
98 if (desktop) {
99 const { Client } = $.ui.resolve({ surface: 'desktop', component: e.component })
100 if (clickable) codes.set(key, source)
101 const lines = wrapCode(source, width - 4, width - 4 - (clickable ? 10 : 0), false)
102 return <Box flexDirection="column">
103 <Box flexDirection="column" borderStyle="round" borderColor="#4E4E4E" backgroundColor="#262625" paddingX={1}>
104 {clickable ? <Box justifyContent="space-between" columnGap={2}>
105 <Box flexShrink={1} minWidth={0}><Text mono>{lines[0]}</Text></Box>
106 <Box width={6} flexShrink={0}>
107 <Client key={key} module="./copy.tsx" width={6} height={1} props={{ color, label: state?.label ?? null }} />
108 </Box>
109 </Box> : <Text mono>{lines[0]}</Text>}
110 {lines.slice(1).map(line => <Text mono>{line}</Text>)}
111 </Box>{notice}
112 </Box>
113 }
114 const border = light ? '#B2B2B2' : '#4E4E4E'
115 return <Box flexDirection="column">
116 <Box>{button ? [
117 <Text color={border}>{'╭' + '─'.repeat(width - 11) + ' '}</Text>,
118 <Box width={6} flexShrink={0}>{button}</Box>, <Text color={border}>{' ─╮'}</Text>,
119 ] : <Text color={border}>{'╭' + '─'.repeat(width - 2) + '╮'}</Text>}</Box>
120 {wrapCode(source, width - 4).map(line => <Box>
121 <Text color={border}>{'│ '}</Text><Box width={width - 4} flexShrink={0}><Code source={line} language={language} wrap="wrap" /></Box><Text color={border}>{' │'}</Text>
122 </Box>)}
123 <Text color={border}>{'╰' + '─'.repeat(width - 2) + '╯'}</Text>
124 {notice}
125 </Box>
126 }
127}
128hooks/sections.ts 94 lines1import type { NeonSectionsConfig, NeonSectionsEntry, EffectKey } from '../types/index'
2
3import { messages, sectionKeys as names } from './i18n'
4import type { Language } from './i18n'
5export { names }
6export type SectionName = typeof names[number]
7export const defaults: NeonSectionsConfig = {
8 foldTools: true,
9 conclusionColorDark: '#00D7FF', conclusionColorLight: '#0087AF',
10 attentionColorDark: '#FFD700', attentionColorLight: '#AF8700',
11 nextColorDark: '#FF5FFF', nextColorLight: '#D700D7',
12 conclusionGlow: false, conclusionLatestOnly: false, conclusionHover: false, conclusionBlink: false, conclusionBreathe: false,
13 attentionGlow: false, attentionLatestOnly: false, attentionHover: false, attentionBlink: false, attentionBreathe: false,
14 nextGlow: false, nextLatestOnly: false, nextHover: false, nextBlink: false, nextBreathe: false,
15}
16export const validColor = (value: unknown): value is string =>
17 typeof value === 'string' && /^#[\da-f]{6}$/i.test(value)
18
19export function config(values: Record<string, unknown>): NeonSectionsConfig {
20 const result = { ...defaults }
21 for (const key of Object.keys(defaults) as (keyof NeonSectionsConfig)[]) {
22 if (typeof defaults[key] === 'boolean') {
23 const k = key as EffectKey | 'foldTools'
24 result[k] = typeof values[key] === 'boolean' ? values[key] : defaults[k]
25 } else {
26 const k = key as 'conclusionColorDark'
27 result[k] = validColor(values[key]) ? values[key] : defaults[k]
28 }
29 }
30 return result
31}
32
33// Track the fence's character and length so headings in code remain literal.
34export function fenceAfter(line: string, fence: string): string {
35 const mark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
36 if (!mark) return fence
37 if (!fence) return mark
38 return mark[0] === fence[0] && mark.length >= fence.length &&
39 line.trim() === mark ? '' : fence
40}
41
42export function parse(text: string, streaming: boolean) {
43 const sections: Partial<Record<SectionName, string>> = {}
44 const languages: Partial<Record<SectionName, Language>> = {}
45 const headings = (['ko', 'en'] as const).flatMap(language => names.map(key => ({ key, language, heading: `### ${messages[language].sections[key]}` })))
46 let preamble = '', current: SectionName | undefined, status = '', fence = ''
47 let visible = text
48 const lines = text.split('\n')
49 for (let i = 0; i < lines.length; i++) {
50 const line = lines[i]!, trimmed = line.trim()
51 if (!fence && streaming && i === lines.length - 1 && trimmed &&
52 headings.flatMap(h => h.key === 'conclusion' ? [h.heading, `${h.heading} · `] : [h.heading]).some(h => h.startsWith(trimmed) && h !== trimmed)) {
53 visible = lines.slice(0, -1).join('\n')
54 break
55 }
56 const nextFence = fenceAfter(line, fence)
57 const match = !fence && nextFence === '' && headings.find(h => trimmed === h.heading ||
58 (h.key === 'conclusion' && trimmed.startsWith(`${h.heading} · `)))
59 if (match) {
60 current = match.key
61 languages[current] = match.language
62 if (sections[current] !== undefined) sections[current] += '\n'
63 else sections[current] = ''
64 if (current === 'conclusion') status = trimmed.slice(match.heading.length + 3) || status
65 } else if (current) sections[current] += `${line}\n`
66 else preamble += `${line}\n`
67 fence = nextFence
68 }
69 for (const name of names) if (sections[name] !== undefined) sections[name] = sections[name]!.trim()
70 return { sections, languages, status, preamble: preamble.trim(), visible,
71 recognized: sections.conclusion !== undefined,
72 boxed: sections.conclusion !== undefined && Object.keys(sections).length > 1 }
73}
74
75export function record(entries: NeonSectionsEntry[], language: Language = 'ko'): string {
76 const tools = entries.filter(e => e.kind === 'tool')
77 if (!tools.length) return ''
78 const counts = new Map<string, number>()
79 for (const tool of tools) counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1)
80 const failures = tools.flatMap((tool, i) => tool.failed
81 ? [`${tool.name} ✗${tools[i + 1] ? ` → ${tools[i + 1]!.name}` : ''}`] : [])
82 const t = messages[language]
83 return `${t.tools(tools.length)} · ` + [...counts].sort((a, b) => b[1] - a[1])
84 .map(([name, count]) => `${name} ${count}`).join(' · ') +
85 (failures.length ? ` · ${t.failed(failures.length, failures.join(', '))}` : '')
86}
87
88export function brighter(hex: string, amount = 0.35): string {
89 return '#' + [1, 3, 5].map(i => {
90 const n = parseInt(hex.slice(i, i + 2), 16)
91 return Math.round(n + (255 - n) * amount).toString(16).padStart(2, '0')
92 }).join('')
93}
94hooks/state.ts 6 lines1import type { NeonSectionsState } from '../types/index'
2
3export const empty: NeonSectionsState = {
4 enabled: true, active: '', latest: '', turns: [], expanded: {},
5}
6hooks/effects-grid.tsx 55 lines1import { messages } from './i18n'
2import type { Language } from './i18n'
3import type { ClientElements, ClientModule } from 'claude-code'
4import { editable, effects } from './effects'
5import type { EffectKey } from '../types/index'
6
7export type GridProps = {
8 values: Record<EffectKey, boolean>; row: number; column: number;
9 wide: boolean; interactive: boolean; notice: string; language: Language;
10}
11export function drawGrid(elements: Pick<ClientElements, 'Box' | 'Text' | 'Button'>, p: GridProps,
12 select: (row: number, column: number, toggle: boolean) => void) {
13 const { Box, Text, Button } = elements
14 const t = messages[p.language]
15 const description = (row: number) => p.notice || `${t.effects[effects[row]!.key][1]} · ${t.enabledFor(t.sections[editable[p.column]!.key], p.values[`${editable[p.column]!.key}${effects[row]!.key}`])}`
16 return <Box flexDirection="column">
17 <Box><Box width={22}><Text>{t.effect}</Text></Box>{editable.map(s => <Box width={10}><Text>{t.gridSections[s.key]}</Text></Box>)}</Box>
18 {effects.map((effect, row) => <Box flexDirection="column">
19 {row === 1 ? <Text dimColor>{t.desktopOnly}</Text> : null}
20 <Box>
21 <Box width={22}><Text>{t.effects[effect.key][0]}</Text></Box>
22 {editable.map((section, column) => {
23 const supported = p.interactive || row === 0
24 const current = row === p.row && column === p.column
25 return <Box width={10}><Button key={`effect-${row}-${column}`} plain
26 label={`${t.effects[effect.key][0]} × ${t.sections[section.key]}`} dimColor={!supported}
27 onPress={() => select(row, column, true)}>
28 <Text inverse={current} dimColor={!supported}>{supported ? p.values[`${section.key}${effect.key}`] ? '[✓]' : '[ ]' : ' ╌ '}</Text>
29 </Button></Box>
30 })}
31 {p.wide ? <Text dimColor>{p.row === row ? description(row) : t.effects[effect.key][1]}</Text> : null}
32 </Box>
33 </Box>)}
34 {!p.wide ? <Text dimColor>{description(p.row)}</Text> : null}
35 </Box>
36}
37const EffectsGrid: ClientModule<GridProps, { row: number; column: number }> = (props, surface) => {
38 const current = surface.state ?? { row: props.row, column: props.column }
39 const select = (row: number, column: number, toggle: boolean) => {
40 surface.setState({ row, column })
41 surface.post({ row, column, toggle })
42 }
43 surface.onKey(e => {
44 if (e.ctrl || e.meta) return
45 const { row, column } = surface.state ?? current
46 if (e.key === 'up') select(Math.max(0, row - 1), column, false)
47 else if (e.key === 'down') select(Math.min(4, row + 1), column, false)
48 else if (e.key === 'left') select(row, Math.max(0, column - 1), false)
49 else if (e.key === 'right') select(row, Math.min(2, column + 1), false)
50 else if (e.key === ' ' || e.key === 'return') select(row, column, true)
51 })
52 return drawGrid(surface.elements, { ...props, ...current }, select)
53}
54export default EffectsGrid
55hooks/copy.tsx 14 lines1import type { ClientModule } from 'claude-code'
2
3const Copy: ClientModule<{ color: string; label: 'COPIED' | 'FAILED' | null }> = (props, surface) => {
4 const { Text } = surface.elements
5 surface.onPointer(e => {
6 if (e.type === 'down' && e.button === 'left') surface.post('copy')
7 })
8 return props.label
9 ? <Text mono color={props.label === 'COPIED' ? '#4EBA65' : '#FF6B80'}>{props.label}</Text>
10 : <Text mono color={props.color}>[<Text color={props.color} bold>COPY</Text>]</Text>
11}
12
13export default Copy
14types/index.d.ts 43 lines1export type PanelMessage = { kind: 'rejected'; reason: string } | { kind: 'colorError' }
2 | { kind: 'approximation'; value: string } | { kind: 'palette'; index: number; light: boolean } | null;
3export type PanelNotice = { kind: 'rejected'; reason: string } | { kind: 'unsupported'; row: number } | null;
4export type Section = 'conclusion' | 'attention' | 'next';
5export type Effect = 'Glow' | 'LatestOnly' | 'Hover' | 'Blink' | 'Breathe';
6export type EffectKey = `${Section}${Effect}`;
7export type ColorKey = `${Section}Color${'Dark' | 'Light'}`;
8export type NeonSectionsConfig = {
9 foldTools: boolean;
10 conclusionColorDark: string; conclusionColorLight: string;
11 attentionColorDark: string; attentionColorLight: string;
12 nextColorDark: string; nextColorLight: string;
13} & Record<EffectKey, boolean>;
14export type Prismantis<Surface, Drawing> = {
15 markdown: (args: { surface: Surface; text: string; columns: number }) => Promise<Drawing | undefined>;
16};
17export type PanelState = {
18 light: boolean | null; messages: Record<Section, PanelMessage>; row: number; column: number; notice: PanelNotice;
19};
20export type NeonSectionsEntry =
21 | { kind: 'text'; id: string; text: string }
22 | { kind: 'tool'; id: string; name: string; summary: string; failed: boolean };
23export type NeonSectionsTurn = {
24 id: string; entries: NeonSectionsEntry[]; completedAt: number | null; attention: boolean;
25};
26export type NeonSectionsState = {
27 enabled: boolean; active: string; latest: string; turns: NeonSectionsTurn[];
28 expanded: Record<string, boolean>;
29};
30declare module 'claude-code' {
31 interface PluginState {
32 'neon-sections': {
33 language: 'auto' | 'ko' | 'en';
34 session: NeonSectionsState;
35 config: NeonSectionsConfig;
36 appearance: { light: boolean; verbose: boolean };
37 tick: number;
38 panel: PanelState;
39 copy: Record<string, { revision: number; label: 'COPIED' | 'FAILED' | null; failed: boolean }>;
40 };
41 }
42}
43