Rosé Pine Moon for the bottom of Claude Code: a lualine-style band, palette spinners, turn summaries and mode pills

hooks/register.tsx 509 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { Git, Repo, SpinnerStyle, Tick, TurnStat } from '../types'
5
6// Rosé Pine Moon
7const P = {
8 base: '#232136',
9 surface: '#2a273f',
10 overlay: '#393552',
11 muted: '#6e6a86',
12 subtle: '#908caa',
13 text: '#e0def4',
14 love: '#eb6f92',
15 gold: '#f6c177',
16 rose: '#ea9a97',
17 pine: '#3e8fb0',
18 foam: '#9ccfd8',
19 iris: '#c4a7e7',
20 hlMed: '#44415a',
21 hlHigh: '#56526e',
22} as const
23
24const ACCENTS = [P.love, P.gold, P.rose, P.pine, P.foam, P.iris]
25
26const MODES: Record<string, { label: string; color: string }> = {
27 default: { label: 'ASK', color: P.rose },
28 acceptEdits: { label: 'EDITS', color: P.iris },
29 plan: { label: 'PLAN', color: P.pine },
30 auto: { label: 'AUTO', color: P.foam },
31 bypassPermissions: { label: 'YOLO', color: P.love },
32 dontAsk: { label: 'STRICT', color: P.gold },
33}
34
35const SPIN_COLOR: Record<string, string> = {
36 thinking: P.iris,
37 responding: P.rose,
38 'tool-use': P.foam,
39 'tool-input': P.pine,
40 requesting: P.gold,
41}
42
43const STYLES: SpinnerStyle[] = ['shimmer', 'prism', 'scanner', 'tide', 'pulse', 'orbit']
44const TICK_MS = 90
45const STORE_SPINNER = 'spinner'
46
47const mode = atom({ plugin: 'rose-pine', key: 'mode' } as const, 'default')
48const model = atom({ plugin: 'rose-pine', key: 'model' } as const, '')
49const git = atom({ plugin: 'rose-pine', key: 'git' } as const, null)
50const repo = atom({ plugin: 'rose-pine', key: 'repo' } as const, null)
51const tick = atom({ plugin: 'rose-pine', key: 'tick' } as const, { n: 0, now: 0, startedAt: 0 })
52const spinner = atom({ plugin: 'rose-pine', key: 'spinner' } as const, 'shimmer')
53const turns = atom({ plugin: 'rose-pine', key: 'turns' } as const, [])
54
55const lines = (s: unknown) => (typeof s === 'string' && s.length > 0 ? s.split('\n').length : 0)
56
57// The engine's own permission-mode indicator (`⏵⏵ auto mode on (shift+tab to cycle)`);
58// the band's mode block replaces it.
59const MODE_RE = /(?:⏵⏵|⏸|⏵)?\s*(?:auto mode|auto-accept edits|accept edits|plan mode|bypass permissions|don'?t ask)(?: mode)?\s+on\b(?:\s*\([^)]*\))?/gi
60
61export const stripMode = (hint: string) =>
62 hint
63 .replace(MODE_RE, '')
64 .replace(/(?:\s*·\s*){2,}/g, ' · ')
65 .replace(/^\s*·\s*|\s*·\s*$/g, '')
66 .trim()
67
68// What the engine draws ahead of the footer in each non-default mode; it sits
69// outside PromptHint, so the line leaves it room instead of hiding it.
70const PILLS: Record<string, string> = {
71 acceptEdits: '⏵⏵ accept edits on',
72 plan: '⏸ plan mode on',
73 auto: '⏵⏵ auto mode on',
74 bypassPermissions: '⏵⏵ bypass permissions on',
75 dontAsk: "⏵⏵ don't ask on",
76}
77
78const CYCLE_RE = /\s*\(shift\+tab to cycle\)/gi
79
80export const hasModePill = (hint: string) => /\(shift\+tab to cycle\)/i.test(hint) || stripMode(hint) !== hint.trim()
81
82// `claude-opus-5-5` → `Opus 5.5`
83export const prettyModel = (id: string) => {
84 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
85 return m ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}` : id
86}
87
88export const formatDuration = (ms: number) => {
89 const s = Math.max(0, Math.round(ms / 1000))
90 if (s < 60) return `${s}s`
91 const m = Math.floor(s / 60)
92 if (m < 60) return `${m}m ${s % 60}s`
93 return `${Math.floor(m / 60)}h ${m % 60}m`
94}
95
96export const parseGit = (out: string): Git | null => {
97 const [head, ...rest] = out.split('\n')
98 if (head === undefined || !head.startsWith('## ')) return null
99 const info = head.slice(3)
100 const branch = (info.split('...')[0] ?? '').replace(/^No commits yet on /, '').split(' ')[0] ?? ''
101 const ahead = Number(/ahead (\d+)/.exec(info)?.[1] ?? 0)
102 const behind = Number(/behind (\d+)/.exec(info)?.[1] ?? 0)
103 return { branch, dirty: rest.filter(l => l.trim() !== '').length, ahead, behind }
104}
105
106export const meter = (percent: number, cells: number) => {
107 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
108 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(cells - filled) }
109}
110
111const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() ?? p
112const parent = (p: string) => p.replace(/\/+$/, '').replace(/\/[^/]*$/, '')
113
114// `git rev-parse --show-toplevel --git-dir --git-common-dir`, absolute paths.
115// A linked worktree's git dir differs from the common dir, which is always the
116// main checkout's `.git` (or a bare `name.git`), wherever the worktree lives.
117export const parseRepo = (out: string): Repo | null => {
118 const [top, gitDir, common] = out.trim().split('\n')
119 if (!top || !gitDir || !common) return null
120 if (gitDir === common) return { top, project: base(top) }
121 const project = base(common) === '.git' ? base(parent(common)) : base(common).replace(/\.git$/, '')
122 return { top, project, worktree: base(top) }
123}
124
125const width = (s: string) => [...s].length
126
127// The location block's text from widest to narrowest: inside a repo it reads
128// `project[ ⎇ worktree]/sub/dirs`, elsewhere the `~`-relative path.
129export const pathCandidates = (cwd: string, home: string | undefined, r: Repo | null) => {
130 let head: string
131 let segs: string[]
132 const inRepo = r !== null && (cwd === r.top || cwd.startsWith(`${r.top}/`))
133 if (inRepo) {
134 head = r.worktree ? `${r.project} ⎇ ${r.worktree}` : r.project
135 segs = cwd.slice(r.top.length).split('/').filter(Boolean)
136 } else if (home && (cwd === home || cwd.startsWith(`${home}/`))) {
137 head = '~'
138 segs = cwd.slice(home.length).split('/').filter(Boolean)
139 } else {
140 head = '/'
141 segs = cwd.split('/').filter(Boolean)
142 }
143 const join = (parts: string[]) =>
144 parts.length === 0 ? head : head === '/' ? `/${parts.join('/')}` : `${head}/${parts.join('/')}`
145 const n = segs.length
146 const out = [join(segs)]
147 if (n > 2) out.push(join(['…', ...segs.slice(-2)]))
148 if (n > 1) out.push(join(['…', segs[n - 1]!]))
149 if (inRepo && n > 0) out.push(head)
150 if (n > 0) out.push(segs[n - 1]!)
151 return [...new Set(out)]
152}
153
154// The widest candidate that fits `budget` columns once padded, or null.
155export const fitPath = (candidates: string[], budget: number) =>
156 candidates.find(c => width(c) + 2 <= budget) ?? null
157
158const refreshGit = async ($: EngineInterface) => {
159 try {
160 const cwd = await $.session.cwd()
161 const [st, rp] = await Promise.all([
162 $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd, timeoutMs: 3000 }),
163 $.process.run(
164 ['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'],
165 { cwd, timeoutMs: 3000 },
166 ),
167 ])
168 const next = st.exitCode === 0 ? parseGit(st.stdout) : null
169 const where = rp.exitCode === 0 ? parseRepo(rp.stdout) : null
170 await update($, git, () => next)
171 await update($, repo, () => where)
172 } catch {
173 await update($, git, () => null)
174 await update($, repo, () => null)
175 }
176}
177
178const refreshModel = async ($: EngineInterface) => {
179 try {
180 const name = await $.session.model()
181 await update($, model, () => name)
182 } catch {}
183}
184
185const captureMode = async ($: EngineInterface, value: string | undefined) => {
186 if (value) await update($, mode, () => value)
187}
188
189export const register: Register = on => {
190 let timer: Timer | undefined
191 let gitTimer: Timer | undefined
192 let live = { tools: 0, added: 0, removed: 0 }
193 let seen = { hint: '', modes: [] as readonly string[] }
194
195 const stopTicking = () => {
196 timer?.cancel()
197 timer = undefined
198 }
199
200 on('session.start', async ($, e, next) => {
201 await $.command.register({
202 name: 'rosepine',
203 description: 'Rosé Pine mod: `spinner [style]` cycles or picks the spinner style',
204 argumentHint: `spinner [${STYLES.join('|')}] | debug`,
205 })
206 const saved = await $.store.get(STORE_SPINNER)
207 if (typeof saved === 'string' && (STYLES as string[]).includes(saved)) {
208 await update($, spinner, () => saved as SpinnerStyle)
209 }
210 const { permissions } = (await $.settings.read()) as { permissions?: { defaultMode?: string } }
211 await captureMode($, permissions?.defaultMode)
212 void refreshGit($)
213 void refreshModel($)
214 gitTimer?.cancel()
215 gitTimer = $.clock.every(15_000, () => void refreshGit($))
216
217 return next(e)
218 })
219
220 on('command.run', { command: 'rosepine' }, async ($, e) => {
221 const [sub, pick] = e.args.trim().split(/\s+/)
222 if (sub === 'debug') {
223 return { text: `PromptHint: ${JSON.stringify(seen.hint)}\nSessionMode: ${JSON.stringify(seen.modes)}` }
224 }
225 if (sub !== 'spinner') {
226 return { text: `Usage: /rosepine spinner [${STYLES.join('|')}]` }
227 }
228 const current = await read($, spinner)
229 const chosen =
230 pick && (STYLES as string[]).includes(pick)
231 ? (pick as SpinnerStyle)
232 : STYLES[(STYLES.indexOf(current) + 1) % STYLES.length]!
233 await update($, spinner, () => chosen)
234 await $.store.set(STORE_SPINNER, chosen)
235
236 return { text: `Spinner: ${chosen}` }
237 })
238
239 // Permission mode rides on the classic hook inputs.
240 on('classic.UserPromptSubmit', async ($, e, next) => {
241 await captureMode($, e.permission_mode)
242 return next(e)
243 })
244 on('classic.PostToolUse', async ($, e, next) => {
245 if (!e.agent_id) await captureMode($, e.permission_mode)
246 return next(e)
247 })
248 on('classic.Stop', async ($, e, next) => {
249 await captureMode($, e.permission_mode)
250 return next(e)
251 })
252 on('classic.PostModelSwitch', async ($, e, next) => {
253 const r = await next(e)
254 void refreshModel($)
255 return r
256 })
257
258 on('turn.start', async ($, e, next) => {
259 {
260 live = { tools: 0, added: 0, removed: 0 }
261 const now = await $.clock.now()
262 await update($, tick, () => ({ n: 0, now, startedAt: now }))
263 stopTicking()
264 timer = $.clock.every(TICK_MS, () => {
265 void $.clock.now().then(t => update($, tick, (prev: Tick) => ({ ...prev, n: prev.n + 1, now: t })))
266 })
267 }
268 return next(e)
269 })
270
271 on('tool.call', async ($, e, next) => {
272 const result = await next(e)
273 live.tools += 1
274 if (result.result !== undefined && !result.isError) {
275 if (e.tool === 'Edit') {
276 live.removed += lines(e.old_string)
277 live.added += lines(e.new_string)
278 } else if (e.tool === 'Write') {
279 live.added += lines(e.content)
280 }
281 }
282 return result
283 })
284
285 on('turn.complete', async ($, e, next) => {
286 if (e.agentId === undefined) {
287 stopTicking()
288 const stat: TurnStat = { durationMs: e.durationMs, ...live }
289 await update($, turns, (list: TurnStat[]) => [...list, stat].slice(-300))
290 void refreshGit($)
291 void refreshModel($)
292 }
293 return next(e)
294 })
295
296 on('turn.abort', async ($, e, next) => {
297 stopTicking()
298 return next(e)
299 })
300
301 // 2. The spinner, in a style picked with /rosepine spinner.
302 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
303 if (e.surface !== 'terminal') return next(e)
304 const { Text } = $.ui.resolve(e)
305 const style = await read($, spinner)
306 const t = await read($, tick)
307 const n = t.n
308 const accent = SPIN_COLOR[e.props.mode] ?? P.iris
309 const word = e.props.message ?? e.props.word
310 const elapsed = t.startedAt > 0 ? formatDuration(t.now - t.startedAt) : ''
311
312 let glyph: RenderChildren = null
313 let label: RenderChildren = <Text color={accent}>{word}</Text>
314
315 if (style === 'shimmer') {
316 const braille = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
317 glyph = <Text color={accent}>{braille[n % braille.length]}</Text>
318 const head = n % (word.length + 6)
319 label = [...word].map((ch, i) => {
320 const d = Math.abs(i - head)
321 return <Text color={d === 0 ? P.text : d === 1 ? P.subtle : accent}>{ch}</Text>
322 })
323 } else if (style === 'prism') {
324 glyph = <Text color={ACCENTS[n % ACCENTS.length]}>◆</Text>
325 label = [...word].map((ch, i) => (
326 <Text color={ACCENTS[(Math.floor(n / 2) + i) % ACCENTS.length]}>{ch}</Text>
327 ))
328 } else if (style === 'scanner') {
329 const width = 8
330 const span = (width - 1) * 2
331 const pos = n % span < width ? n % span : span - (n % span)
332 glyph = [...Array(width)].map((_, i) => {
333 const d = Math.abs(i - pos)
334 const color = d === 0 ? P.love : d === 1 ? P.rose : d === 2 ? P.muted : P.hlMed
335 return <Text color={color}>▪</Text>
336 })
337 } else if (style === 'tide') {
338 const wave = '▁▂▃▄▅▆▇█▇▆▅▄▃▂'
339 glyph = [...Array(8)].map((_, i) => {
340 const level = wave[(n + i) % wave.length]!
341 return <Text color={wave.indexOf(level) >= 4 ? P.foam : P.pine}>{level}</Text>
342 })
343 } else if (style === 'pulse') {
344 const ramp = [P.hlMed, P.hlHigh, P.muted, P.subtle, accent, accent, P.subtle, P.muted, P.hlHigh]
345 glyph = <Text color={ramp[n % ramp.length]}>●</Text>
346 label = <Text color={ramp[(n + 2) % ramp.length]}>{word}</Text>
347 } else {
348 const orbit = '◜◝◞◟'
349 glyph = <Text color={ACCENTS[Math.floor(n / 4) % ACCENTS.length]}>{orbit[n % orbit.length]}</Text>
350 }
351
352 return (
353 <Text>
354 {glyph} {label}
355 <Text color={P.muted}>{e.props.suffix}</Text>
356 {elapsed && <Text color={P.muted}>{` ${elapsed}`}</Text>}
357 </Text>
358 )
359 })
360
361 // 3. Turn summaries.
362 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
363 const { Text } = $.ui.resolve(e)
364 const list = await read($, turns)
365 let best: TurnStat | undefined
366 for (const s of list) {
367 const d = Math.abs(s.durationMs - e.props.durationMs)
368 if (d < 1500 && (!best || d < Math.abs(best.durationMs - e.props.durationMs))) best = s
369 }
370
371 return (
372 <Text>
373 <Text color={P.rose}>❀ </Text>
374 <Text color={P.subtle}>{formatDuration(e.props.durationMs)}</Text>
375 {best && best.tools > 0 && (
376 <Text color={P.muted}>{` · ${best.tools} tool${best.tools === 1 ? '' : 's'}`}</Text>
377 )}
378 {best && (best.added > 0 || best.removed > 0) && (
379 <Text>
380 <Text color={P.muted}> · </Text>
381 <Text color={P.foam}>{`+${best.added}`}</Text>
382 <Text color={P.muted}>/</Text>
383 <Text color={P.love}>{`−${best.removed}`}</Text>
384 </Text>
385 )}
386 </Text>
387 )
388 })
389
390 // 4. Session mode labels as pills.
391 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
392 seen = { ...seen, modes: e.props.modes }
393 const modes = e.props.modes.filter(label => stripMode(label) !== '')
394 if (modes.length === 0) return next({ ...e, props: { ...e.props, modes } })
395 const { Box, Text } = $.ui.resolve(e)
396 return (
397 <Box flexDirection="row" gap={1}>
398 {modes.map((label, i) => (
399 <Text backgroundColor={P.overlay} color={ACCENTS[(i + 5) % ACCENTS.length]}>
400 {` ${label} `}
401 </Text>
402 ))}
403 </Box>
404 )
405 })
406
407 // 1. The lualine: the whole footer under the prompt, flat blocks like nvim's.
408 // The engine's footer (mode pill, hints, pills) is replaced, not added to.
409 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
410 seen = { ...seen, hint: e.props.hint }
411 if (e.surface !== 'terminal') return next(e)
412 const { Box, Text } = $.ui.resolve(e)
413
414 const m = MODES[await read($, mode)] ?? { label: (await read($, mode)).toUpperCase(), color: P.subtle }
415 const g = await read($, git)
416 const r = await read($, repo)
417 const name = prettyModel((await read($, model)) || (await $.session.model()))
418 const usage = await $.session.usage()
419 const cwd = await $.session.cwd()
420 const home = await $.env.get('HOME')
421 const pct = usage.context.percent
422 const pill = PILLS[await read($, mode)]
423 // The row's 2-column indent, the pill and the ` · ` after it, and one
424 // column of slack so the line never wraps.
425 const reserved = pill ? [...pill].length + 3 : 0
426 const cols = Math.max(20, (e.viewport?.columns ?? 100) - 3 - reserved)
427 const hint = stripMode(e.props.hint.replace(CYCLE_RE, '').replace(/\s*·?\s*←\s*for agents/i, ''))
428 let agents = 0
429 try {
430 agents = (await $.agent.list()).filter(a => a.status === 'running' || a.status === 'pending').length
431 } catch {}
432 const ctxColor = pct === undefined ? P.muted : pct >= 80 ? P.love : pct >= 50 ? P.gold : P.foam
433 const bar = meter(pct ?? 0, cols >= 90 ? 10 : 5)
434 const cost = usage.cost?.usd
435 const costText = cost === undefined ? '' : ` $${cost.toFixed(2)} `
436 const agentsText = agents > 0 ? `← ${agents} agent${agents === 1 ? '' : 's'} ` : ''
437 const stats = g
438 ? `${g.dirty > 0 ? ` ●${g.dirty}` : ''}${g.ahead > 0 ? ` ↑${g.ahead}` : ''}${g.behind > 0 ? ` ↓${g.behind}` : ''}`
439 : ''
440
441 // Width budget: everything but the path is measured, the path gets what is
442 // left. Give up the cost first, then the model, then path detail, then the
443 // branch's dirty/ahead/behind counts.
444 const candidates = pathCandidates(cwd, home, r)
445 const steps = [
446 { showCost: true, showModel: true, showStats: true, fullOnly: true },
447 { showCost: false, showModel: true, showStats: true, fullOnly: true },
448 { showCost: false, showModel: false, showStats: true, fullOnly: false },
449 { showCost: false, showModel: false, showStats: false, fullOnly: false },
450 ]
451 let fit = steps[steps.length - 1]!
452 let dir: string | null = null
453 for (const step of steps) {
454 const used =
455 (pill ? 0 : width(m.label) + 2) +
456 (g ? 4 + width(g.branch) + (step.showStats ? width(stats) : 0) : 0) +
457 width(agentsText) +
458 (step.showModel ? width(name) + 1 : 0) +
459 (7 + bar.filled.length + bar.empty.length) +
460 (step.showCost ? width(costText) : 0) +
461 1
462 const pick = fitPath(step.fullOnly ? candidates.slice(0, 1) : candidates, cols - used)
463 fit = step
464 dir = pick
465 if (pick !== null) break
466 }
467
468 return (
469 <Box flexDirection="row" height={1} width={cols} overflow="hidden">
470 {!pill && (
471 <Text backgroundColor={m.color} color={P.base} bold>
472 {` ${m.label} `}
473 </Text>
474 )}
475 {dir !== null && (
476 <Text backgroundColor={P.overlay} color={P.text}>
477 {` ${dir} `}
478 </Text>
479 )}
480 {g && (
481 <Text backgroundColor={P.surface}>
482 <Text color={P.iris}>{` ${g.branch}`}</Text>
483 {fit.showStats && g.dirty > 0 && <Text color={P.gold}>{` ●${g.dirty}`}</Text>}
484 {fit.showStats && g.ahead > 0 && <Text color={P.foam}>{` ↑${g.ahead}`}</Text>}
485 {fit.showStats && g.behind > 0 && <Text color={P.love}>{` ↓${g.behind}`}</Text>}
486 <Text> </Text>
487 </Text>
488 )}
489 <Box flexGrow={1} height={1} justifyContent="center">
490 {(e.props.isWorking || e.props.isDraft) && hint !== '' && (
491 <Text color={P.muted} wrap="truncate">{hint}</Text>
492 )}
493 </Box>
494 {agentsText !== '' && <Text color={P.foam}>{agentsText}</Text>}
495 {fit.showModel && <Text color={P.subtle}>{`${name} `}</Text>}
496 <Text backgroundColor={P.surface}>
497 <Text color={ctxColor}>{` ${bar.filled}`}</Text>
498 <Text color={P.hlHigh}>{bar.empty}</Text>
499 <Text color={P.subtle}>{pct === undefined ? ' --% ' : ` ${String(pct).padStart(3)}% `}</Text>
500 </Text>
501 {fit.showCost && costText !== '' && (
502 <Text backgroundColor={P.overlay} color={P.gold}>{costText}</Text>
503 )}
504 <Text backgroundColor={m.color}> </Text>
505 </Box>
506 )
507 })
508}
509types/index.d.ts 22 lines1export type SpinnerStyle = 'shimmer' | 'prism' | 'scanner' | 'tide' | 'pulse' | 'orbit'
2export type Git = { branch: string; dirty: number; ahead: number; behind: number }
3// Where the checkout is: its root, the project it belongs to, and the
4// worktree's name when it is a linked worktree.
5export type Repo = { top: string; project: string; worktree?: string }
6export type TurnStat = { durationMs: number; tools: number; added: number; removed: number }
7export type Tick = { n: number; now: number; startedAt: number }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'rose-pine': {
12 mode: string
13 model: string
14 git: Git | null
15 repo: Repo | null
16 tick: Tick
17 spinner: SpinnerStyle
18 turns: TurnStat[]
19 }
20 }
21}
22