SLOPSHOPPER

rose-pine

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

newspinnerguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-14brimalval/dotfiles/.claude/mods/rose-pine
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rose-pine
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ❀ 42s · 9 tools · +16/−1 › /rosepine ⎿ rose-pine: Usage: /rosepine spinner [shimmer|prism|scanner|tide|pulse|orbit] ⠋ Thinking 0s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ASK /work/app Opus 5.5 ▰▰▰▰▰▱▱▱▱▱ 49% $0.42 auto-accept edits

Draws

Spinner
⠋ Thinking 0s
Prompt hint
ASK /work/app
Turn line
❀ 42s · 9 tools · +16/−1
Source 2 files
hooks/register.tsx 509 lines
1import { 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}
509
types/index.d.ts 22 lines
1export 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