SLOPSHOPPER

neon-sections

Answer sections and a collapsible work log

newpanerowsguardcommandprompt
v0.6.1MITupdated 2026-10-20FLOWVIA-Labs/flowvia-mods/neon-sections
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neon-sections
│ ┃ Answer sections ✕ › fix the failing auth test and add an audit log call │ ┃ Answer sections │ ┃ o: [✓] Show answer sections ⏺ Read(src/auth.ts) │ ┃ f: [✓] Fold tool calls ⎿ Read 6 lines │ ┃ t: ● Dark theme ○ Light theme ⏺ Update(src/auth.ts) │ ┃ Language ● auto ○ ko ○ en ⎿ Added 2 lines, removed 1 line │ ┃ ██ Conclusion hex: #00D7FF ⏎ Apply Default ⏺ Bash(bun test) │ ┃ Conclusion · Red #FF5F5FConclusion · Orange ⎿ 3 pass, 1 fail │ ┃ Conclusion · Apricot #FFAF87Conclusion · Pal │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ██ Needs your inputhex: #FFD700 ⏎ ApplyDefau │ ┃ Needs your input · Red #FF5F5FNeeds your inp ✻ Worked for 42s · done 4:20 PM │ ┃ Needs your input · Apricot #FFAF87Needs your │ ┃ › /ns-setting │ ┃ ██ Next steps hex: #FF5FFF ⏎ Apply Default │ ┃ Next steps · Red #FF5F5FNext steps · Orange │ ┃ Next steps · Apricot #FFAF87Next steps · Pal │ ┃ │ ┃ ██ Rationale · fixed color · no effects │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Conclusion │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ▣ client module ./effects-grid.tsx │ ┃ ↑↓ effect ←→ section Space toggle Esc │ ┃ exit │ ┃ Tab next field Enter select o show f fold ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Answer sections
Answer sections o: [✓] Show answer sections f: [✓] Fold tool calls t: ● Dark theme ○ Light theme Language ● auto ○ ko ○ en ██ Conclusion hex: #00D7FF ⏎ Apply Default Conclusion · Red #FF5F5FConclusion · Orange #FF8700Conclusio Conclusion · Apricot #FFAF87Conclusion · Pale yellow #FFFF87 ██ Needs your input hex: #FFD700 ⏎ Apply Default Needs your input · Red #FF5F5FNeeds your input · Orange #FF8 Needs your input · Apricot #FFAF87Needs your input · Pale ye ██ Next steps hex: #FF5FFF ⏎ Apply Default Next steps · Red #FF5F5FNext steps · Orange #FF8700Next step Next steps · Apricot #FFAF87Next steps · Pale yellow #FFFF87 ██ Rationale · fixed color · no effects ╭──────────────────────────────────────────────────────────╮ │ Conclusion │ ╰──────────────────────────────────────────────────────────╯ ▣ client module ./effects-grid.tsx ↑↓ effect ←→ section Space toggle Esc exit Tab next field Enter select o show f fold t theme Esc close
README

flowvia-mods

Two mods for Claude Code by FLOWVIA Labs.

ModWhat it does
neon-sectionsSplits 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.
clutchA sports minigame under the spinner while Claude works: penalty shootout, batting, and a 3-point contest, all played with the Space key.

neon-sections drawing an answer as Conclusion, Rationale, Needs your input, and Next steps boxes

clutch: a baseball at-bat under the Claude Code spinner

clutch: the 3-point contest under the Claude Code spinner

Requirements

  • Claude Code v2.1.287 or later in the terminal (mods are on by default from this version), or the desktop app v2.1.286 or later.
  • Tested with Claude Code 2.1.304.
  • clutch runs in the terminal only and needs a window at least 66 columns wide.

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.

Install

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.

Language

Both mods speak English and Korean. Each has a language option:

ValueBehavior
auto (default)Korean when your Claude Code language setting is Korean (korean, ko, ko-KR, 한국어), English otherwise
koAlways Korean
enAlways English

Change it in /plugin → Installed → the mod → Configure options, or in /config.

neon-sections

neon-sections asks Claude to structure answers with these headings and then draws them as boxes:

### Conclusion
### Rationale
### Needs your input
### Next steps
  • A report on finished work can carry a status: ### Conclusion · Done, · Waiting, or · Blocked. It shows as a badge on the Conclusion box.
  • Korean headings (### 결론 and so on) are recognized too, whatever the language setting.
  • Short answers and progress notes between tool calls stay plain text.
  • When a turn ends, its tool calls fold into one Work log line. In the desktop app and the fullscreen terminal you can expand it.
  • Code blocks get a COPY button in the desktop app and the fullscreen terminal.

Settings

Run /ns-setting to open the settings panel. You can set:

  • each section's color for dark and light themes
  • effects: double-border glow, neon on the latest answer only, highlight on hover, blink, and breathe. Effects other than glow work in the desktop app and the fullscreen terminal only.

The same settings are available as commands:

CommandEffect
/ns-setting on / offTurn the boxes on or off
/ns-setting statusShow 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.

clutch

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.

KeyAction
SpaceShoot or swing
1 / 2 / 3Switch to penalty shootout / baseball / 3-point contest
9Pause the game for this turn
  • Game keys are read only while Claude is working and the prompt box is empty. Once you start typing, keys go to the prompt as usual. To start a message with Space, 1, 2, 3, or 9, press 9 first to pause the game.
  • The scoreboard shows what Claude is doing right now (thinking, editing a file, running tests, and so on).
  • /clutch turns the game on or off.

Privacy

  • Neither mod makes network requests.
  • Both mods read your Claude Code settings only for the language value and the light/dark theme, and never send them anywhere.
  • clutch stores two values locally: the last sport played and whether the game is on.

Development

See AGENTS.md for the layout, setup, and test commands.

License

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.

Source 11 files
hooks/register.tsx 378 lines
1import { 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}
378
hooks/i18n.ts 98 lines
1import 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}
98
hooks/panel.tsx 146 lines
1import { 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}
146
hooks/effects.ts 15 lines
1import { 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
15
hooks/colors.ts 57 lines
1import { 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}
57
hooks/code-box.tsx 128 lines
1import { 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}
128
hooks/sections.ts 94 lines
1import 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}
94
hooks/state.ts 6 lines
1import type { NeonSectionsState } from '../types/index'
2
3export const empty: NeonSectionsState = {
4  enabled: true, active: '', latest: '', turns: [], expanded: {},
5}
6
hooks/effects-grid.tsx 55 lines
1import { 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
55
hooks/copy.tsx 14 lines
1import 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
14
types/index.d.ts 43 lines
1export 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