SLOPSHOPPER

richmond

AFC Richmond persona: match HUD above the prompt, Roy Kent sounds, Nelson Road themes, locker-room spinner and status lines, a /timer break screen

newbandspinnercommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · richmond
› 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. ✻ Worked for 42s · done 4:20 PM › /richmond ⏱ Серія пенальті ●●●●◯ ctx 49% ┃ ⚽⚽⚽⚽◯ 31% ↻ 09:53 ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⏱ Серія пенальті ●●●●◯ ctx 49% ┃ ⚽⚽⚽⚽◯ 31% ↻ 09:53 ┃
README

richmond — AFC Richmond persona for Claude Code

Claude Code dressed as AFC Richmond from Ted Lasso: a match HUD above the prompt, Roy Kent's "let's go again" when an answer is ready, two Nelson Road themes, locker-room spinner lines and a status line where main is a home game.

All the texts (HUD, spinner verbs, tips, status line, subagent panel) are in Ukrainian.

Requirements

  • Linux. Built and tested on Ubuntu 22.04 with GNOME Terminal 3.44 and Claude Code 2.1.296. The HUD needs Claude Code 2.1.287 or later (mods on by default).
  • jq for /richmond, the status lines and the sounds (sudo apt install jq).
  • Sounds play through paplay (PulseAudio or PipeWire, default on Ubuntu). Without it the plugin stays silent.
  • The break timer (/timer) needs systemd's user manager (systemd-run --user) and, for its break screen, an X11 session (Wayland works through XWayland) and GTK 3 with PyGObject under /usr/bin/python3: sudo apt install python3-gi python3-gi-cairo if they are missing.
  • The terminal profile is for GNOME Terminal. Where it isn't installed, /richmond skips it. The colours are in persona/terminal/*.dconf, so you can copy them into your own terminal.

Install

claude plugin marketplace add kaisa-kucherenko/claude-code-flow
claude plugin install richmond@claude-code-flow

Then, inside Claude Code:

/richmond                  # Nelson Road: navy and gold
/richmond diamond-dogs     # Diamond Dogs: graphite and blue

Restart Claude Code (/exit, then claude -c) and you're on the pitch.

What you get

PartComes withWhat it shows
HUD above the promptthe pluginThe session as a match: context % is the match clock (1st half → 2nd half → extra time at 30 %, the point to hand the session off → penalties from 40 %); /config richmond.toxicityThreshold=40 moves extra time to your own, and penalties follow 10 points later. Five balls for the 5-hour limit, the BELIEVE sign for the 7-day limit, one letter per day.
Soundsthe pluginRoy's "All right, let's go again!" when an answer is ready, Roy swearing the moment Claude asks for a permission or a question. Each moment takes any clip of the set; see below.
Themesthe pluginNelson Road and Diamond Dogs, listed in /theme.
Spinner verbs and tips/richmond60 verbs and 77 tips in the characters' voices. A long turn moves to the next verb every 30 s; /config richmond.spinnerSeconds=60 makes it 60.
Status line/richmond`⚑ project ВДОМА · main [model \effort]`. A working branch is an away game, and its score is commits ahead : behind.
Subagent panel/richmondAgents as the squad and staff, three lines each: Explore is the scout, Plan is Beard at the whiteboard.
Terminal profile/richmond, GNOME TerminalBackground, palette and cursor for new tabs; your font stays.
Break timerthe plugin/timer 45m counts down in the band and rings with a break screen; see below.

Sounds

Three moments have a sound, and each takes any clip from sounds/:

MomentSettingDefault
An answer is ready, nothing left running in the backgroundturnDoneSoundroy-lets-go-again
Claude asks for a permission or a questionneedsYouSoundroy-fuck-no
The break timer ringstimerDoneSoundted-enough-for-today

Pick one in /config (it lists the clips), type /config richmond.turnDoneSound=keeley-well-done, or just tell Claude which clip you want where. CLAUDE_SOUND=off mutes all three.

The clips:

ClipWhat you hear
roy-lets-go-againRoy: “All right, let's go again”
roy-fuck-noRoy: “Fuck no!”
ted-enough-for-todayTed: “I think that's enough for today”, then Roy whistles the team in
roy-okay-were-doneRoy: “Okay, we're done”
roy-whistleRoy's whistle
roy-kent-chantThe crowd: “Roy Kent! He's here, he's there…”
keeley-well-doneKeeley: “Well done”
dani-football-is-lifeDani before his penalty: “Football is life”
dani-football-is-deathDani: “Football is death!”
na-na-dani-rojas“Na-na-na…”, then “Dani Rojas” as he rejoins the team
jamie-tartt-chantThe pub sings “Jamie Tartt, ta-da-da-da-da”
bonfire-richmond-till-we-dieThe team at the bonfire: “Richmond till we die”
so-long-farewell-v1The team's farewell song to Ted, the opening
so-long-farewell-v2The farewell song, “the sun has gone to bed”

Break timer

/timer 45m (or 45, 1h) reminds you to take a break once that time is up. /timer alone says how long is left, /timer off cancels it, and a new time replaces the running one. One timer serves every open Claude Code session, the desktop app's included; the band counts it down as «◷ Тайм-аут через 32:10».

When it is up a break screen opens over your work, Ted calls practice while Roy whistles the team in (or the clip you picked), and the band shows «◷ Тайм-аут» until your next prompt. The screen carries «ТАЙМ-АУТ», a line from persona/breaks.json and one of the GIFs in persona/breaks/, in the Nelson Road navy and gold (screen in the same file). Its «Назад на поле» button unlocks after 10 seconds, so an Enter typed mid-sentence cannot dismiss it unseen; Esc works from then on too.

It opens as an 800×520 window in the middle of the main monitor; /config richmond.breakScreen=fullscreen covers every monitor instead. Whichever persona is on duty at that moment speaks, and a persona without a break screen, or a screen that fails to open, rings with a GNOME banner. With no persona on, the banner says “Time for a break” and the desktop's alarm plays; the countdown in the band shows only while a persona is on duty. CLAUDE_SOUND=off mutes the clip.

The timer belongs to Claude Code, not to your desktop: when the last session exits it is cancelled, and if none is left when it is due it stays silent. A new /timer or the last session exiting also closes a break screen still open. systemd counts the time (a transient unit, claude-persona-timer), so it rings once however many sessions are open.

Switching personas

The marketplace also has witcher. Install both and switch with one command: /witcher puts the Witcher on duty, /richmond brings Richmond back. The HUD, sounds, status line and spinner verbs follow within seconds, in every open session. The theme takes one pick in /theme (a plugin can't switch a custom theme in a running session), tips change from the next session and the terminal colours from the next tab. Until you pick one, both play their sounds and one HUD covers the other.

What /richmond changes, and how to undo it

The plugin can't set your theme, spinner verbs, tips or status line itself: those are keys in your ~/.claude/settings.json. So /richmond runs scripts/wear.sh, which writes them:

  • theme, spinnerVerbs, spinnerTipsOverride, statusLine, subagentStatusLine;
  • enabledPlugins, only if richmond is switched off there: it is switched back on;
  • ~/.claude/personas/active, which names the persona on duty: with several persona plugins installed, only that one shows its HUD and plays sounds;
  • the default GNOME Terminal profile.

The first run saves the previous values of the five keys in the first line to ~/.claude/personas/backup.json. /richmond off puts them back and silences the HUD and sounds of every persona plugin; they stay installed, and /richmond puts the persona back on. Run it before claude plugin uninstall, or the status lines keep running from their copies. Everything else in your settings stays as it is, including whatever you changed while the persona was on. The status line scripts are copied to ~/.claude/personas/ because the plugin's own folder changes path with every update.

Development

claude plugin validate plugins/richmond
claude plugin test plugins/richmond        # the HUD and the /richmond command
bash plugins/richmond/tests/wear.test.sh   # wear.sh against a throwaway config
bash plugins/richmond/tests/sound.test.sh  # which persona plays, and which clip
bash plugins/richmond/tests/timer.test.sh  # timer.sh with systemd, the banner and the break screen stubbed

Rights

An unofficial, non-commercial fan project. Ted Lasso, AFC Richmond and their characters belong to their rights holders (Apple TV+, Warner Bros. Television). The clips are short excerpts: lines of a second or two, and chants and songs of up to 18 seconds. The break-screen GIFs in persona/breaks/ are short loops of scenes from the series, taken from Tenor. If you hold the rights and want them out, open an issue.

The break screen is set in Fira Sans and Fira Sans Extra Condensed by the Mozilla Foundation and Telefonica S.A., under the SIL Open Font License 1.1 (fonts/OFL.txt); the three files are the unmodified static fonts from Google Fonts.

Source 3 files
hooks/register.tsx 381 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Usage } from '../types'
5import { applyTimerFiles, formatLeft, isCounting, isExit, takeSeen, timerArgs, timerViewAt, TIMER_COMMAND } from './timer'
6
7// Nelson Road's HUD: the session is one match whose clock is the context fill,
8// the 5-hour window is five balls (one per hour) and the 7-day window is the
9// BELIEVE sign over the locker-room door (one letter per day). Both resources
10// read as what is left, so a full row is good news; the match clock reads as
11// time spent, so a late minute is the warning.
12const usage = atom({ plugin: 'richmond', key: 'usage' } as const, {
13  ctx: null,
14  fiveHour: null,
15  fiveHourReset: null,
16  sevenDay: null,
17  sevenDayReset: null,
18} as Usage)
19
20// The match follows a session that is handed off to a fresh one between 30 and
21// 40 % of context: regular time ends at 30, extra time at 40, then penalties.
22// Where to hand off is a habit, not a law, so `toxicityThreshold` moves full time.
23const DEFAULT_FULL_TIME = 30
24// Extra time lasts this long past full time; the shoot-out starts after it.
25const EXTRA_TIME_LENGTH = 10
26
27// The sign's canonical look: yellow capitals on a royal-blue board.
28const SIGN_BLUE = '#2F4BB0'
29const SIGN_YELLOW = '#FFD93D'
30const SIGN_FADED = '#7F8FD6'
31const SIGN_TORN = '#8a2b2b'
32
33type Phase = { label: string; kicks?: string; tone: 'text' | 'warning' | 'error' }
34
35// Context percent → match phase. One number on the band is enough: the percent
36// is what the handoff decision is made on, the phase says how late it is.
37export function phase(ctx: number, fullTime = DEFAULT_FULL_TIME): Phase {
38  const extraTime = fullTime + EXTRA_TIME_LENGTH
39  if (ctx <= 0) return { label: 'Стартовий свисток', tone: 'text' }
40  // Two equal halves, so half time moves with full time
41  if (ctx < fullTime / 2) return { label: '1-й тайм', tone: 'text' }
42  if (ctx < fullTime) return { label: '2-й тайм', tone: 'text' }
43  if (ctx < extraTime) return { label: 'Додатковий час', tone: 'warning' }
44  // One kick every two percent; after the fifth the shoot-out just goes on,
45  // kick by kick, the way a real one does.
46  const kicks = Math.floor((ctx - extraTime) / 2)
47  if (kicks <= 5) return { label: 'Серія пенальті', kicks: '●'.repeat(kicks) + '◯'.repeat(5 - kicks), tone: 'error' }
48  return { label: 'Серія пенальті', kicks: `●●●●● +${kicks - 5}`, tone: 'error' }
49}
50
51// Whole units left out of `total`, so the last ball / letter only goes once
52// the window is actually spent.
53export function unitsLeft(percentUsed: number, total: number): number {
54  const left = Math.min(100, Math.max(0, 100 - percentUsed))
55  return Math.ceil((left / 100) * total)
56}
57
58const DAYS = ['нд', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб']
59
60function hhmm(iso: string | null): string {
61  if (!iso) return ''
62  const d = new Date(iso)
63  if (Number.isNaN(d.getTime())) return ''
64  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
65}
66
67function dayHhmm(iso: string | null): string {
68  if (!iso) return ''
69  const d = new Date(iso)
70  if (Number.isNaN(d.getTime())) return ''
71  return `${DAYS[d.getDay()]} ${hhmm(iso)}`
72}
73
74type Dollar = EngineInterface
75
76async function refreshUsage($: Dollar) {
77  const u = await $.session.usage()
78  const limit = (kind: string) => u.rateLimits.find(r => r.kind === kind)
79  const next: Usage = {
80    ctx: u.context.percent ?? (u.context.tokens ? Math.round((u.context.tokens / u.context.window) * 100) : null),
81    fiveHour: limit('five_hour')?.percentUsed ?? null,
82    fiveHourReset: limit('five_hour')?.resetsAt ?? null,
83    sevenDay: limit('seven_day')?.percentUsed ?? null,
84    sevenDayReset: limit('seven_day')?.resetsAt ?? null,
85  }
86  await update($, usage, () => next)
87}
88
89// Several persona plugins can sit side by side; personas/active names the one
90// on duty and the others keep quiet. Undefined when the marker can't be read:
91// at session start that means nobody has chosen yet, so a lone plugin works
92// straight after install; mid-session it is a switch in progress.
93async function isOnDuty($: Dollar): Promise<boolean | undefined> {
94  try {
95    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
96    const active = String(await $.fs.read(`${dir}/personas/active`)).trim()
97    return active === 'richmond'
98  } catch {
99    return undefined
100  }
101}
102
103// Unknown until first asked: a default either way would flash the wrong band.
104let onDuty: boolean | undefined
105let stopWatching: (() => void) | undefined
106
107// The switch can come from another plugin's command or another session, and
108// neither can reach this module, so it polls the marker and redraws on change.
109const DUTY_POLL_MS = 3000
110
111// The break timer's state files, shared by every session and both personas
112async function timerDir($: Dollar): Promise<string | null> {
113  try {
114    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
115    return `${dir}/persona-timer`
116  } catch {
117    return null
118  }
119}
120
121async function readOrNull($: Dollar, path: string): Promise<string | null> {
122  try {
123    return String(await $.fs.read(path))
124  } catch {
125    return null
126  }
127}
128
129async function refreshTimer($: Dollar): Promise<boolean> {
130  const dir = await timerDir($)
131  if (!dir) return false
132  return applyTimerFiles(
133    await readOrNull($, `${dir}/timer.json`),
134    await readOrNull($, `${dir}/rang.json`),
135    await readOrNull($, `${dir}/seen`),
136  )
137}
138
139async function runTimer($: Dollar, args: string): Promise<string> {
140  const r = await $.process
141    .run(['bash', `${$.plugin.root}/scripts/timer.sh`, ...timerArgs(args)])
142    .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
143  return (r.exitCode === 0 ? r.stdout : r.stderr).trim()
144}
145
146async function recheckDuty($: Dollar) {
147  const timerChanged = await refreshTimer($)
148  const now = await isOnDuty($)
149  if (now !== undefined && now !== onDuty) {
150    onDuty = now
151    $.ui.invalidate('ui.render')
152  } else if (timerChanged && onDuty) {
153    $.ui.invalidate('ui.render')
154  }
155}
156
157// The engine samples the spinner word from the verbs it read at session start,
158// so a persona put on mid-session would keep the previous one's words; on duty,
159// this plugin swaps in its own.
160let verbs: readonly string[] = []
161
162async function loadVerbs($: Dollar) {
163  try {
164    verbs = JSON.parse(String(await $.fs.read(`${$.plugin.root}/persona/verbs.json`))).verbs ?? []
165  } catch {
166    verbs = []
167  }
168}
169
170// The engine samples its word once per turn, so the verb starts from it (or
171// from a hash of a word that isn't this persona's) and moves `step` verbs on,
172// one step per `spinnerSeconds`: a long turn doesn't sit on one line, and
173// redraws within a step keep the same verb.
174export function ownVerb(sampled: string, own: readonly string[], step = 0): string {
175  if (own.length === 0) return sampled
176  let start = own.indexOf(sampled)
177  if (start < 0) {
178    let h = 0
179    for (const ch of sampled) h = (h * 31 + ch.charCodeAt(0)) >>> 0
180    start = h % own.length
181  }
182  return own[(start + step) % own.length]!
183}
184
185// When each sampled word was first drawn: the main spinner and each agent's
186// carry their own word, so each counts its steps from its own start.
187const firstSeen = new Map<string, number>()
188
189async function stepsSince($: Dollar, word: string, stepMs: number): Promise<number> {
190  const now = await $.clock.now()
191  if (firstSeen.size > 64) firstSeen.clear()
192  const start = firstSeen.get(word) ?? now
193  firstSeen.set(word, start)
194  return Math.floor((now - start) / stepMs)
195}
196
197// Redraws often enough that a step lands within a few seconds of falling due
198const SPINNER_TICK_MS = 5000
199// The break countdown shows seconds
200const TIMER_TICK_MS = 1000
201
202// Wearing the persona rewrites the person's own settings, so it is a command
203// they run, never a side effect of installing the plugin. wear.sh owns the
204// list of themes and says what changed and where it shows up.
205async function wear($: Dollar, args: string): Promise<string> {
206  const arg = args.trim()
207  const argv = ['bash', `${$.plugin.root}/scripts/wear.sh`, ...(arg === 'off' ? ['off'] : ['on', ...(arg ? [arg] : [])])]
208  const r = await $.process.run(argv).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
209  if (r.exitCode !== 0) return `Failed: ${r.stderr.trim()}`
210  return r.stdout.trim()
211}
212
213export const register: Register = (on, options) => {
214  const stepMs = Math.max(10, Number(options.spinnerSeconds) || 30) * 1000
215  // Same bounds as toxicityThreshold in plugin.json; the clamp covers a value the host saved unchecked
216  const fullTime = Math.min(90, Math.max(5, Number(options.toxicityThreshold) || DEFAULT_FULL_TIME))
217
218  on('session.start', async ($, e, next) => {
219    await $.command.register({
220      name: 'richmond',
221      description: 'Put on the AFC Richmond persona: theme, spinner, status line, terminal profile',
222      argumentHint: '[nelson-road | diamond-dogs | off]',
223    })
224    // Both persona plugins carry /timer and the first to start owns it; the
225    // other's refusal must not cost it the rest of its start
226    await $.command.register(TIMER_COMMAND).catch(() => undefined)
227    // The persona on duty when the timer rings may not be the plugin that set
228    // it, so each plugin says where its phrases and clip live
229    const dir = await timerDir($)
230    if (dir) await $.fs.write(`${dir}/roots/richmond`, $.plugin.root).catch(() => undefined)
231    await refreshTimer($)
232    onDuty = (await isOnDuty($)) ?? true
233    // Each session start re-arms the poll with its own `$`
234    stopWatching?.()
235    const duty = $.clock.every(DUTY_POLL_MS, () => void recheckDuty($))
236    const tick = $.clock.every(SPINNER_TICK_MS, () => {
237      if (onDuty) $.ui.invalidate('ui.render')
238    })
239    const countdown = $.clock.every(TIMER_TICK_MS, async () => {
240      if (onDuty && isCounting(await $.clock.now())) $.ui.invalidate('ui.render')
241    })
242    stopWatching = () => {
243      duty.cancel()
244      tick.cancel()
245      countdown.cancel()
246    }
247    await loadVerbs($)
248    await refreshUsage($)
249    return next(e)
250  })
251
252  on('command.run', { command: 'richmond' }, async ($, e) => {
253    const text = await wear($, e.args)
254    await recheckDuty($)
255    return { text }
256  })
257
258  on('command.run', { command: 'timer' }, async ($, e) => {
259    const text = await runTimer($, e.args)
260    await refreshTimer($)
261    $.ui.invalidate('ui.render')
262    return { text }
263  })
264
265  on('prompt.submit', async ($, e, next) => {
266    const id = takeSeen()
267    if (id) {
268      const dir = await timerDir($)
269      if (dir) await $.fs.write(`${dir}/seen`, id).catch(() => undefined)
270      if (onDuty) $.ui.invalidate('ui.render')
271    }
272    return next(e)
273  })
274
275  on('session.end', async ($, e, next) => {
276    if (isExit(e.reason)) {
277      // Every exit hook shares one short bound, so reap takes what is left of it
278      const timeoutMs = Math.max(200, Math.floor(next.budget.remainingMs))
279      await $.process.run(['bash', `${$.plugin.root}/scripts/timer.sh`, 'reap'], { timeoutMs }).catch(() => undefined)
280    }
281    return next(e)
282  })
283
284  on('session.measure', async ($, e, next) => {
285    await refreshUsage($)
286    return next(e)
287  })
288
289  on('turn.complete', async ($, e, next) => {
290    await refreshUsage($)
291    return next(e)
292  })
293
294  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
295    // A message (compacting, a hook running) says what is happening, and on the
296    // desktop the word names the step being done; both stay as they are
297    if (!onDuty || e.surface !== 'terminal' || e.props.message !== null) return next(e)
298    const step = await stepsSince($, e.props.word, stepMs)
299    return next({ ...e, props: { ...e.props, word: ownVerb(e.props.word, verbs, step) } })
300  })
301
302  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303    onDuty ??= (await isOnDuty($)) ?? true
304    if (e.props.hasSurvey || !onDuty) return next(e)
305
306    const u = await read($, usage)
307    const { Box, Text } = $.ui.resolve(e)
308
309    const wide = e.props.bodyColumns >= 110
310    const label = (long: string, short: string) => (wide ? long : short)
311    const resourceColor = (used: number) => (used >= 90 ? 'error' : used >= 70 ? 'warning' : 'success')
312    const rest = timerViewAt(await $.clock.now())
313
314    const ctx = u.ctx ?? 0
315    const match = phase(ctx, fullTime)
316
317    const balls = u.fiveHour !== null ? unitsLeft(u.fiveHour, 5) : 0
318    const letters = u.sevenDay !== null ? unitsLeft(u.sevenDay, 7) : 7
319    const torn = u.sevenDay !== null && letters === 0
320    const sign = 'BELIEVE'.split('')
321
322    return (
323      <Box flexDirection="column" marginTop={1}>
324        <Box flexDirection="row">
325          <Text>
326            <Text color={match.tone}>⏱ </Text>
327            <Text color={match.tone} bold={match.tone === 'error'}>{match.label}</Text>
328            {match.kicks ? <Text color={match.tone}> {match.kicks}</Text> : null}
329            <Text dimColor>  ctx </Text>
330            <Text color={match.tone}>{ctx}%</Text>
331            <Text dimColor>  ┃  </Text>
332          </Text>
333          {u.fiveHour !== null ? (
334            <Text>
335              <Text color={resourceColor(u.fiveHour)}>{'⚽'.repeat(balls)}</Text>
336              <Text dimColor>{'◯ '.repeat(5 - balls)}</Text>
337              <Text dimColor> {label('5h ', '')}</Text>
338              <Text color={resourceColor(u.fiveHour)}>{u.fiveHour}%</Text>
339              <Text dimColor>{u.fiveHourReset ? ` ↻ ${hhmm(u.fiveHourReset)}` : ''}</Text>
340              <Text dimColor>  ┃  </Text>
341            </Text>
342          ) : null}
343          {u.sevenDay !== null ? (
344            <Text>
345              {torn ? <Text color="error">✂ </Text> : null}
346              <Text backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}> </Text>
347              {sign.map((ch, i) => (
348                <Text
349                  key={String(i)}
350                  backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}
351                  color={i < letters ? SIGN_YELLOW : SIGN_FADED}
352                  bold={i < letters}
353                >
354                  {ch}
355                </Text>
356              ))}
357              <Text backgroundColor={torn ? SIGN_TORN : SIGN_BLUE}> </Text>
358              <Text dimColor> {label('7d ', '')}</Text>
359              <Text color={resourceColor(u.sevenDay)}>{u.sevenDay}%</Text>
360              <Text dimColor>{u.sevenDayReset ? ` ↻ ${dayHhmm(u.sevenDayReset)}` : ''}</Text>
361            </Text>
362          ) : null}
363          {rest.kind === 'counting' ? (
364            <Text>
365              <Text dimColor>  ┃  </Text>
366              <Text color={SIGN_YELLOW}>◷ </Text>
367              <Text dimColor>{label('Тайм-аут через ', '')}</Text>
368              <Text color={SIGN_YELLOW}>{formatLeft(rest.leftMs)}</Text>
369            </Text>
370          ) : rest.kind === 'rang' ? (
371            <Text>
372              <Text dimColor>  ┃  </Text>
373              <Text color={SIGN_YELLOW} bold>◷ Тайм-аут</Text>
374            </Text>
375          ) : null}
376        </Box>
377      </Box>
378    )
379  })
380}
381
hooks/timer.ts 82 lines
1// The /timer break reminder as the HUD sees it. scripts/timer.sh owns the timer
2// itself (systemd counts the time and rings), so every session, whichever
3// plugin set it, reads the same files under persona-timer/. The engine does not
4// let `$` cross an import, so this file holds the state and the rules and each
5// register.tsx does the reading and writing.
6
7export type TimerView = { kind: 'none' } | { kind: 'counting'; leftMs: number } | { kind: 'rang' }
8
9let timer: { id: string; endsAt: number } | null = null
10let rang: { id: string } | null = null
11let seen: string | null = null
12
13function parse(text: string | null): Record<string, unknown> | null {
14  if (!text) return null
15  try {
16    return JSON.parse(text) as Record<string, unknown>
17  } catch {
18    return null
19  }
20}
21
22// Takes what the three state files hold now (null when missing) and says
23// whether what the band shows has changed
24export function applyTimerFiles(timerText: string | null, rangText: string | null, seenText: string | null): boolean {
25  const t = parse(timerText)
26  const r = parse(rangText)
27  const nextTimer = t && typeof t.endsAt === 'number' ? { id: String(t.id), endsAt: t.endsAt } : null
28  const nextRang = r ? { id: String(r.id) } : null
29  const nextSeen = seenText?.trim() || null
30  const changed = nextTimer?.id !== timer?.id || nextRang?.id !== rang?.id || nextSeen !== seen
31  timer = nextTimer
32  rang = nextRang
33  seen = nextSeen
34  return changed
35}
36
37// The ring clears timer.json within seconds of the end; one still there long
38// after is a timer lost to a reboot or a killed session, not one about to ring
39const STALE_AFTER_MS = 60_000
40
41export function isCounting(now: number): boolean {
42  return timer !== null && now - timer.endsAt < STALE_AFTER_MS
43}
44
45export function timerViewAt(now: number): TimerView {
46  if (timer && now - timer.endsAt < STALE_AFTER_MS) return { kind: 'counting', leftMs: Math.max(0, timer.endsAt - now) }
47  if (rang && rang.id !== seen) return { kind: 'rang' }
48  return { kind: 'none' }
49}
50
51// A prompt means the person is back: the reminder has done its job, in every
52// session at once. Returns the id to record as seen, or null when nothing rang.
53export function takeSeen(): string | null {
54  if (!rang || rang.id === seen) return null
55  seen = rang.id
56  return seen
57}
58
59export function formatLeft(ms: number): string {
60  const total = Math.ceil(ms / 1000)
61  const h = Math.floor(total / 3600)
62  const m = Math.floor((total % 3600) / 60)
63  const ss = String(total % 60).padStart(2, '0')
64  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
65}
66
67export function timerArgs(args: string): string[] {
68  const arg = args.trim()
69  return arg === '' ? ['status'] : arg.toLowerCase() === 'off' ? ['off'] : ['set', arg]
70}
71
72// A /clear or a resume goes on in the same process, so only a real exit counts
73export function isExit(reason: string): boolean {
74  return reason === 'prompt_input_exit' || reason === 'logout' || reason === 'other'
75}
76
77export const TIMER_COMMAND = {
78  name: 'timer',
79  description: 'Remind me to take a break: /timer 45m or 1h; no argument shows the timer, off cancels it',
80  argumentHint: '[45m | 1h | off]',
81}
82
types/index.d.ts 14 lines
1export type Usage = {
2  ctx: number | null
3  fiveHour: number | null
4  fiveHourReset: string | null
5  sevenDay: number | null
6  sevenDayReset: string | null
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'richmond': { usage: Usage }
12  }
13}
14