SLOPSHOPPER

pomodoro

Button-first Pomodoro timer above the Claude Code prompt: one-click focus blocks, a live countdown band, break reminders and a config pane.

newpanebandcommandtoaststatus
★ 1v0.5.0MITupdated 2026-10-02sneycampos/claude-pomodoro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pomodoro
│ ┃ pomodoro ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⏺ Read(src/auth.ts) │ ┃ │ ● Pomodoro — pick a focus length ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ 0:00 ░░░░░░░░ ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ Pick a focus length or open config ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ 1: ▶ 30 2: ▶ 45 3: ▶ 60 c: ⚙ d: dismiss ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ focus 25m · break 5m · long 15m · after ✻ Worked for 42s · done 4:20 PM │ ┃ │ 4 │ ┃ │ › /pomodoro │ ┃ ╰─────────────────────────────────────────── ⎿ pomodoro: Pomodoro idle. Press ▶ 30 / ▶ 45 / ▶ 60 or /pomodoro s │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ● Pomodoro · 1: ▶ 30 2: ▶ 45 3: ▶ 60 c: ⚙ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pomodoro: pomodoro · ready

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ● Pomodoro · 1: ▶ 30 2: ▶ 45 3: ▶ 60 c: ⚙ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · pomodoro
╭──────────────────────────────────────────────────────────╮ │ │ │ ● Pomodoro — pick a focus length │ │ │ │ 0:00 ░░░░░░░░ │ │ │ │ Pick a focus length or open config │ │ │ │ 1: ▶ 30 2: ▶ 45 3: ▶ 60 c: ⚙ d: dismiss │ │ │ │ focus 25m · break 5m · long 15m · after 4 │ │ │ ╰──────────────────────────────────────────────────────────╯
Pane · pomodoro-config
╭──────────────────────────────────────────────────────────╮ │ │ │ ● Pomodoro Config │ │ │ │ Focus 15m [25m] 45m │ │ │ │ − + now 25m │ │ │ │ Break 3m [5m] 10m │ │ │ │ Long 10m [15m] 20m │ │ │ │ After 2 3 [4] │ │ │ │ focus 25m · break 5m · long 15m · after 4 │ │ │ │ d: close Esc closes · chips save immediately │ │ │ ╰──────────────────────────────────────────────────────────╯
README

Pomodoro for Claude Code

A button-first Pomodoro timer that lives above the Claude Code prompt: start a focus block with one click, watch the countdown while you work, and get a nudge to step away from the keyboard when it is time for a break.

● Pomodoro · 1: ▶ 30  2: ▶ 45  3: ▶ 60  c: ⚙
● FOCUS 1/4   24:59   ████████   1: pause  2: skip  3: stop
◐ BREAK — stretch / water   4:59   ███░░░░░   1: pause  2: skip  3: stop

Works in the Claude Code terminal and in the Code tab of the Claude desktop app. It follows the host's light, dark and auto themes.

Requirements

Claude Code 2.1.286 or later. The plugin is a hooks module, so older versions do not load it.

Install

From this repository, which is also a plugin marketplace:

claude plugin marketplace add sneycampos/claude-pomodoro
claude plugin install pomodoro@claude-pomodoro

Or inside a session: /plugin marketplace add sneycampos/claude-pomodoro, then /plugin install pomodoro@claude-pomodoro.

From a local checkout, for one session:

claude --plugin-dir /path/to/claude-pomodoro

In the desktop app you can also upload the release zip from the plugins screen.

Using it

Idle band

Shown above the prompt once a session starts. In the desktop app, a brand-new session starts when you send its first message, so the band appears after that. To start a timer with that first message, send /pomodoro start 30.

  • ▶ 30 / ▶ 45 / ▶ 60 start a focus block of that many minutes. The length applies to this block only; later focus blocks use your configured length.
  • ⚙ opens the config Pane.
  • Hotkeys: 1, 2, 3 for the durations, c for config.

Running band

Shows the phase, the time left and a progress bar, with pause / resume, skip and stop (hotkeys 1, 2, 3). The band turns red in the last minute. While Claude Code shows a survey above the prompt, the band steps aside.

Breaks

When a focus block ends:

  1. A chime plays and a toast says Pomodoro break — step away from the keyboard.
  2. A break Pane opens with the countdown and controls. Esc or dismiss (d) closes the Pane only; the break keeps running.
  3. The band and the status line switch to the break colors.

After longAfter focus blocks (4 by default) the break is a long break. When a break ends, a rising chime plays, the Pane closes, a toast says Back to focus, and the next focus block starts on its own. Chimes play only when a block runs out, not when you skip or stop it. Skipping a focus block counts toward the long break, the same as finishing it.

Config Pane

Opened from ⚙ or /pomodoro config. Chip rows set the focus, break and long-break lengths and how many focus blocks come before a long break; − and + step the focus length by one minute. Changes apply at once and are saved for future sessions.

Commands

/pomodoro is registered when the session starts. The desktop app's autocomplete may not list it, but it runs when typed in full.

/pomodoro                          status
/pomodoro start [minutes]          start a focus block (default: configured length)
/pomodoro pause
/pomodoro resume
/pomodoro skip                     end the current phase early
/pomodoro stop
/pomodoro config                   open the config Pane
/pomodoro config focus 25          set one or more of focus|break|long|after
/pomodoro config break=5 long=15
/pomodoro config reset             go back to the plugin settings
/pomodoro pane                     open the timer Pane
/pomodoro pane close

Lengths are minutes, from 1 second (0.0167) to 600; after is a whole number from 1 to 12.

Settings

The defaults come from the plugin's settings, which you can change on the plugin's settings screen:

SettingDefault
Focus minutes25
Break minutes5
Long break minutes15
Focuses before long break4
Play a soundon

A change made in the config Pane or with /pomodoro config is saved and wins over these settings until you change the settings themselves or run /pomodoro config reset.

How it works

  • The timer is kept in the session's plugin state, so it survives a reload of the plugin. The countdown is computed from the block's end time, so a late tick or a sleeping laptop does not make it drift.
  • Every change to the timer goes through one conflict-checked update, so a button press and a clock tick at the same moment cannot undo each other.
  • Chimes play through the platform's audio player (afplay on macOS); where there is none, the timer works silently.
  • Saved config lives in the plugin's own store under your Claude Code configuration directory.

Development

claude plugin validate .
claude plugin test .

To type-check: Claude Code writes its API types to .claude-plugin/types/ when it loads the plugin from a local folder (claude --plugin-dir .). Then:

npx -p typescript@5 tsc -p .

Layout:

.claude-plugin/plugin.json        manifest and settings
.claude-plugin/marketplace.json   lets this repository act as a marketplace
hooks/hooks.json                  points at the hooks module
hooks/pomodoro.ts                 the plugin
types/index.d.ts                  the plugin's state contract
sounds/                           the break and focus chimes
tests/pomodoro.test.ts            tests run by `claude plugin test`

License

MIT

Source 2 files
hooks/pomodoro.ts 1257 lines
1/**
2 * Pomodoro — button-first Pomodoro for Claude Code.
3 * Status chip + AbovePrompt band (idle start buttons / running controls) +
4 * break Pane + config Pane + /pomodoro.
5 *
6 * The timer lives in $.state (session-scoped, survives hot reloads). Every
7 * change goes through `mutate`, a pure transition applied with `update()` so
8 * a tick and a button press can never overwrite each other. The countdown is
9 * derived from `endsAt` and the clock, so late ticks do not drift. Settings
10 * changed in the config Pane or by /pomodoro config persist in $.store.
11 *
12 * Color note: hybrid theme — outer AbovePrompt band (and Pane chrome) have NO
13 * opaque background (transparent so light/dark/auto match the host). Phase
14 * accents stay truecolor hex on borders / labels / progress; muted chrome uses
15 * Claude semantic keys (`text`, `inactive`, `subtle`, `promptBorder`).
16 */
17
18import { update } from 'claude-code'
19import type {
20  EngineInterface as Engine,
21  On,
22  PluginOptions,
23  RenderChildren,
24  RenderElement,
25  Timer,
26} from 'claude-code'
27import type { TimerConfig, TimerPhase, TimerState } from '../types'
28
29const STATE = { plugin: 'pomodoro', key: 'timer' } as const
30/** $.store key holding config saved from the Pane or /pomodoro config. */
31const STORE_CONFIG = 'config'
32const PANE_ID = 'pomodoro'
33const CONFIG_PANE_ID = 'pomodoro-config'
34
35const DEFAULTS: TimerConfig = {
36  focusMs: 25 * 60_000,
37  breakMs: 5 * 60_000,
38  longBreakMs: 15 * 60_000,
39  longAfter: 4,
40}
41
42/** Accepted ranges for durations (minutes) and the long-break cadence. */
43const MAX_MINUTES = 600
44const MAX_LONG_AFTER = 12
45
46const COLORS = {
47  focus: '#f59e0b',
48  break: '#2dd4bf',
49  long: '#c084fc',
50  paused: '#a3a3a3',
51  urgency: '#f43f5e',
52} as const
53
54/**
55 * Claude theme semantic keys for chrome that should follow light/dark/auto.
56 * Phase accents (COLORS) stay as truecolor hex on borders / labels / bar.
57 */
58const THEME = {
59  /** Default frame when phase border is not in play. */
60  border: 'promptBorder',
61  /** Inactive labels / secondary chrome. */
62  inactive: 'inactive',
63  /** Muted captions / config line. */
64  subtle: 'subtle',
65  /** Body text / time when not using a phase hue. */
66  text: 'text',
67} as const
68
69const ICONS = {
70  focus: '●',
71  break: '◐',
72  long: '◐',
73  paused: '⏸',
74  idle: '●',
75} as const
76
77const BAR_WIDTH = 8
78const URGENCY_MS = 60_000
79const NARROW_COLS = 60
80const BREAK_TOAST_MS = 6_000
81
82/** Quick-start durations shown on the idle band (minutes). */
83const IDLE_START_MINUTES = [30, 45, 60] as const
84
85/** Config chip presets (minutes / counts). */
86const FOCUS_CHIPS = [15, 25, 45] as const
87const BREAK_CHIPS = [3, 5, 10] as const
88const LONG_CHIPS = [10, 15, 20] as const
89const AFTER_CHIPS = [2, 3, 4] as const
90
91/** Ephemeral cancel handle for this module load; countdown lives in $.state. */
92let tick: Timer | undefined
93
94/** Defaults from plugin userConfig, merged at register(). */
95let baseConfig: TimerConfig = { ...DEFAULTS }
96
97/** Whether a chime plays when a block runs out (plugin setting `sound`). */
98let soundOn = true
99
100const SOUNDS = { break: 'sounds/break.wav', focus: 'sounds/focus.wav' } as const
101
102/** Plays a chime without waiting for it; a missing player is not an error. */
103function chime($: Engine, kind: keyof typeof SOUNDS): void {
104  if (!soundOn) return
105  $.audio.play({ asset: SOUNDS[kind] }).catch(() => {
106    // No audio player on this platform, or playback refused.
107  })
108}
109
110/** One second up to MAX_MINUTES; fractions of a minute are allowed. */
111function isValidMinutes(n: number): boolean {
112  return Number.isFinite(n) && n >= 1 / 60 && n <= MAX_MINUTES
113}
114
115function isValidLongAfter(n: number): boolean {
116  return Number.isInteger(n) && n >= 1 && n <= MAX_LONG_AFTER
117}
118
119function isValidConfig(v: unknown): v is TimerConfig {
120  if (!v || typeof v !== 'object') return false
121  const c = v as Record<string, unknown>
122  return (
123    typeof c.focusMs === 'number' &&
124    typeof c.breakMs === 'number' &&
125    typeof c.longBreakMs === 'number' &&
126    typeof c.longAfter === 'number' &&
127    isValidMinutes(c.focusMs / 60_000) &&
128    isValidMinutes(c.breakMs / 60_000) &&
129    isValidMinutes(c.longBreakMs / 60_000) &&
130    isValidLongAfter(c.longAfter)
131  )
132}
133
134function sameConfig(a: TimerConfig, b: TimerConfig): boolean {
135  return (
136    a.focusMs === b.focusMs &&
137    a.breakMs === b.breakMs &&
138    a.longBreakMs === b.longBreakMs &&
139    a.longAfter === b.longAfter
140  )
141}
142
143function configFromOptions(options: PluginOptions): TimerConfig {
144  const minutes = (key: string, fallback: number): number => {
145    const v = options[key]
146    return typeof v === 'number' && isValidMinutes(v) ? v : fallback
147  }
148  const after = options.longAfter
149  return {
150    focusMs: Math.round(minutes('focusMinutes', DEFAULTS.focusMs / 60_000) * 60_000),
151    breakMs: Math.round(minutes('breakMinutes', DEFAULTS.breakMs / 60_000) * 60_000),
152    longBreakMs: Math.round(
153      minutes('longBreakMinutes', DEFAULTS.longBreakMs / 60_000) * 60_000,
154    ),
155    longAfter:
156      typeof after === 'number' && isValidLongAfter(Math.round(after))
157        ? Math.round(after)
158        : DEFAULTS.longAfter,
159  }
160}
161
162/**
163 * Config saved in $.store, kept with the plugin settings it was made under:
164 * when those settings change, they win over the saved config.
165 */
166type SavedConfig = { config: TimerConfig; base: TimerConfig }
167
168async function loadConfig($: Engine): Promise<TimerConfig> {
169  try {
170    const saved = (await $.store.get(STORE_CONFIG)) as Partial<SavedConfig> | undefined
171    if (
172      saved &&
173      isValidConfig(saved.config) &&
174      isValidConfig(saved.base) &&
175      sameConfig(saved.base, baseConfig)
176    ) {
177      return saved.config
178    }
179  } catch {
180    // Unreadable store — fall back to the plugin settings.
181  }
182  return baseConfig
183}
184
185async function saveConfig($: Engine, config: TimerConfig): Promise<void> {
186  try {
187    const saved: SavedConfig = { config, base: baseConfig }
188    await $.store.set(STORE_CONFIG, saved)
189  } catch {
190    // Persisting is best effort; the session keeps the new config regardless.
191  }
192}
193
194async function clearSavedConfig($: Engine): Promise<void> {
195  try {
196    await $.store.delete(STORE_CONFIG)
197  } catch {
198    // ignore
199  }
200}
201
202function idleState(config: TimerConfig = baseConfig): TimerState {
203  return {
204    phase: 'idle',
205    pausedFrom: null,
206    remainingMs: 0,
207    totalMs: 0,
208    endsAt: null,
209    focusesDone: 0,
210    config,
211  }
212}
213
214function isTimerState(v: unknown): v is TimerState {
215  if (!v || typeof v !== 'object') return false
216  const o = v as Record<string, unknown>
217  return (
218    typeof o.phase === 'string' &&
219    typeof o.remainingMs === 'number' &&
220    typeof o.totalMs === 'number' &&
221    typeof o.focusesDone === 'number' &&
222    isValidConfig(o.config)
223  )
224}
225
226/** Fills fields added after a state was written (e.g. `endsAt`). */
227function normalize(v: unknown): TimerState {
228  if (!isTimerState(v)) return idleState()
229  return { ...v, endsAt: typeof v.endsAt === 'number' ? v.endsAt : null }
230}
231
232async function getTimer($: Engine): Promise<TimerState> {
233  const { value } = await $.state.get(STATE)
234  return normalize(value)
235}
236
237/**
238 * Applies a pure transition to the latest state, retrying if another write
239 * landed first, and resolves the state it wrote. `fn` may run more than once,
240 * so it must not have side effects; callers act on the returned state.
241 */
242async function mutate(
243  $: Engine,
244  fn: (t: TimerState) => TimerState,
245): Promise<TimerState> {
246  return update($, STATE, (v) => fn(normalize(v)))
247}
248
249function isRunning(t: TimerState): t is TimerState & { phase: 'focus' | 'break' | 'long' } {
250  return t.phase === 'focus' || t.phase === 'break' || t.phase === 'long'
251}
252
253function formatMmSs(ms: number): string {
254  const total = Math.max(0, Math.ceil(ms / 1000))
255  const m = Math.floor(total / 60)
256  const s = total % 60
257  return `${m}:${String(s).padStart(2, '0')}`
258}
259
260function formatMinutes(ms: number): string {
261  const mins = ms / 60_000
262  return Number.isInteger(mins) ? String(mins) : String(Number(mins.toFixed(2)))
263}
264
265function progressBar(remainingMs: number, totalMs: number): string {
266  if (totalMs <= 0) return '░'.repeat(BAR_WIDTH)
267  const filled = Math.max(
268    0,
269    Math.min(BAR_WIDTH, Math.round((remainingMs / totalMs) * BAR_WIDTH)),
270  )
271  return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
272}
273
274function activePhase(t: TimerState): 'focus' | 'break' | 'long' | null {
275  if (isRunning(t)) return t.phase
276  if (t.phase === 'paused' && t.pausedFrom) return t.pausedFrom
277  return null
278}
279
280function isBreakPhase(phase: TimerPhase | null): boolean {
281  return phase === 'break' || phase === 'long'
282}
283
284function phaseColor(t: TimerState): string {
285  if (t.phase === 'paused') return COLORS.paused
286  if (t.phase === 'idle') return COLORS.focus
287  if (t.remainingMs < URGENCY_MS) return COLORS.urgency
288  if (t.phase === 'focus') return COLORS.focus
289  if (t.phase === 'long') return COLORS.long
290  return COLORS.break
291}
292
293function phaseIcon(t: TimerState): string {
294  if (t.phase === 'paused') return ICONS.paused
295  if (t.phase === 'focus') return ICONS.focus
296  if (t.phase === 'break' || t.phase === 'long') return ICONS.break
297  return ICONS.idle
298}
299
300function focusNumber(t: TimerState): string {
301  return `${Math.min(t.focusesDone + 1, t.config.longAfter)}/${t.config.longAfter}`
302}
303
304function phaseLabel(t: TimerState): string {
305  const active = activePhase(t)
306  if (active === 'focus') return `FOCUS ${focusNumber(t)}`
307  if (active === 'break') return 'BREAK'
308  if (active === 'long') return 'LONG BREAK'
309  return 'POMODORO'
310}
311
312/** Wide-band headline: break states keep reminding the user to pause. */
313function bandHeadline(t: TimerState): string {
314  const active = activePhase(t)
315  if (active === 'break') return 'BREAK — stretch / water'
316  if (active === 'long') return 'LONG BREAK — stretch / water'
317  return phaseLabel(t)
318}
319
320/** Pane headline: break nudge matching the toast cue. */
321function paneHeadline(t: TimerState): string {
322  const active = activePhase(t)
323  if (active === 'break') return 'Pomodoro break — step away from the keyboard'
324  if (active === 'long') return 'Pomodoro long break — step away from the keyboard'
325  if (t.phase === 'idle') return 'Pomodoro — pick a focus length'
326  return phaseLabel(t)
327}
328
329/**
330 * Always return a chip, idle included: without a status entry the Desktop
331 * app may skip painting the idle AbovePrompt band.
332 */
333function statusChip(t: TimerState): string {
334  const active = activePhase(t)
335  if (!active) return 'pomodoro · ready'
336  const time = formatMmSs(t.remainingMs)
337  const label =
338    active === 'focus'
339      ? `focus ${focusNumber(t)}`
340      : active === 'long'
341        ? 'long break'
342        : 'break'
343  if (t.phase === 'paused') return `pomodoro ${time} · paused · ${label}`
344  return `pomodoro ${time} · ${label}`
345}
346
347function syncStatus($: Engine, t: TimerState): void {
348  $.ui.status(statusChip(t))
349}
350
351function ensureTick($: Engine): void {
352  if (tick) return
353  tick = $.clock.every(1000, () => {
354    void onTick($)
355  })
356}
357
358function stopTick(): void {
359  tick?.cancel()
360  tick = undefined
361}
362
363/** Starts a phase now: the countdown ends `ms` from `now`. */
364function running(
365  t: TimerState,
366  phase: 'focus' | 'break' | 'long',
367  ms: number,
368  now: number,
369): TimerState {
370  return {
371    ...t,
372    phase,
373    pausedFrom: null,
374    remainingMs: ms,
375    totalMs: ms,
376    endsAt: now + ms,
377  }
378}
379
380/**
381 * The phase after `t`'s active one ends (or is skipped). A skipped focus
382 * counts toward the long break, as a finished one does.
383 */
384function advance(t: TimerState, now: number): TimerState {
385  if (activePhase(t) === 'focus') {
386    const focusesDone = t.focusesDone + 1
387    const useLong = focusesDone >= t.config.longAfter
388    return {
389      ...running(
390        t,
391        useLong ? 'long' : 'break',
392        useLong ? t.config.longBreakMs : t.config.breakMs,
393        now,
394      ),
395      focusesDone: useLong ? 0 : focusesDone,
396    }
397  }
398  return running(t, 'focus', t.config.focusMs, now)
399}
400
401/** Toasts, panes and the tick that follow a change of phase. */
402async function afterTransition(
403  $: Engine,
404  before: TimerState,
405  after: TimerState,
406  fromClock: boolean,
407): Promise<void> {
408  syncStatus($, after)
409  if (after.phase === 'idle') {
410    stopTick()
411    if (isBreakPhase(activePhase(before))) await closeBreakPane($)
412    return
413  }
414  if (isRunning(after)) ensureTick($)
415
416  const from = activePhase(before)
417  const to = activePhase(after)
418  if (from === 'focus' && isBreakPhase(to) && after.phase !== 'paused') {
419    const long = to === 'long'
420    if (fromClock) chime($, 'break')
421    $.ui.toast(
422      long
423        ? 'Pomodoro long break — step away from the keyboard'
424        : 'Pomodoro break — step away from the keyboard',
425      { timeoutMs: BREAK_TOAST_MS },
426    )
427    await openBreakPane($, long ? 'long' : 'break')
428  } else if (isBreakPhase(from) && to === 'focus') {
429    if (fromClock) chime($, 'focus')
430    await closeBreakPane($)
431    $.ui.toast('Back to focus', { timeoutMs: BREAK_TOAST_MS })
432  }
433}
434
435/** Moves the state from `before` to `fn(latest)` and runs what follows it. */
436async function transition(
437  $: Engine,
438  fn: (t: TimerState) => TimerState,
439  fromClock = false,
440): Promise<{ before: TimerState; after: TimerState }> {
441  let before = idleState()
442  const after = await mutate($, (t) => {
443    before = t
444    return fn(t)
445  })
446  await afterTransition($, before, after, fromClock)
447  return { before, after }
448}
449
450async function onTick($: Engine): Promise<void> {
451  const now = await $.clock.now()
452  await transition($, (t) => {
453    if (!isRunning(t) || t.endsAt === null) return t
454    const remainingMs = Math.max(0, t.endsAt - now)
455    return remainingMs > 0 ? { ...t, remainingMs } : advance(t, now)
456  }, true)
457}
458
459async function startFocus($: Engine, minutes?: number): Promise<string> {
460  const now = await $.clock.now()
461  const { after } = await transition($, (t) => {
462    // A given length applies to this focus only; the saved config is unchanged.
463    const focusMs =
464      minutes !== undefined && isValidMinutes(minutes)
465        ? Math.round(minutes * 60_000)
466        : t.config.focusMs
467    return { ...running(t, 'focus', focusMs, now), focusesDone: 0 }
468  })
469  return `Focus started — ${formatMmSs(after.remainingMs)}`
470}
471
472async function pauseTimer($: Engine): Promise<string> {
473  const now = await $.clock.now()
474  const { before, after } = await transition($, (t) => {
475    if (!isRunning(t)) return t
476    const remainingMs =
477      t.endsAt === null ? t.remainingMs : Math.max(0, t.endsAt - now)
478    return { ...t, phase: 'paused', pausedFrom: t.phase, remainingMs, endsAt: null }
479  })
480  if (before.phase === 'idle') return 'Timer is not running'
481  if (before.phase === 'paused') return 'Timer is already paused'
482  return `Paused — ${formatMmSs(after.remainingMs)} left`
483}
484
485async function resumeTimer($: Engine): Promise<string> {
486  const now = await $.clock.now()
487  const { before, after } = await transition($, (t) => {
488    if (t.phase !== 'paused' || !t.pausedFrom) return t
489    return {
490      ...t,
491      phase: t.pausedFrom,
492      pausedFrom: null,
493      endsAt: now + t.remainingMs,
494    }
495  })
496  if (before.phase !== 'paused') return 'Timer is not paused'
497  return `Resumed — ${formatMmSs(after.remainingMs)} left`
498}
499
500/** Pause when running, resume when paused, decided on the latest state. */
501async function togglePause($: Engine): Promise<string> {
502  const t = await getTimer($)
503  return t.phase === 'paused' ? resumeTimer($) : pauseTimer($)
504}
505
506async function skipPhase($: Engine): Promise<string> {
507  const now = await $.clock.now()
508  const { before, after } = await transition($, (t) =>
509    activePhase(t) ? advance(t, now) : t,
510  )
511  if (!activePhase(before)) return 'Timer is not running'
512  return `Skipped → ${phaseLabel(after)} ${formatMmSs(after.remainingMs)}`
513}
514
515async function stopTimer($: Engine): Promise<string> {
516  await transition($, (t) => idleState(t.config))
517  return 'Timer stopped'
518}
519
520function parseStartMinutes(args: string): number | undefined {
521  const parts = args.trim().split(/\s+/)
522  if (parts[0] !== 'start') return undefined
523  if (parts.length < 2) return undefined
524  const n = Number(parts[1])
525  return isValidMinutes(n) ? n : undefined
526}
527
528function configSummary(c: TimerConfig): string {
529  return [
530    `focus ${formatMinutes(c.focusMs)}m`,
531    `break ${formatMinutes(c.breakMs)}m`,
532    `long ${formatMinutes(c.longBreakMs)}m`,
533    `after ${c.longAfter}`,
534  ].join(' · ')
535}
536
537const DURATION_ERROR = `Durations must be between 1 second and ${MAX_MINUTES} minutes`
538const LONG_AFTER_ERROR = `after must be a whole number from 1 to ${MAX_LONG_AFTER}`
539
540/**
541 * Applies a config change computed from the latest config, shortens the
542 * running block when its own length changed, and saves the result.
543 */
544async function applyConfig(
545  $: Engine,
546  patchFor: (c: TimerConfig) => Partial<TimerConfig>,
547): Promise<string> {
548  const now = await $.clock.now()
549  let error: string | undefined
550  const { after } = await transition($, (t) => {
551    error = undefined
552    const config: TimerConfig = { ...t.config, ...patchFor(t.config) }
553    if (
554      !isValidMinutes(config.focusMs / 60_000) ||
555      !isValidMinutes(config.breakMs / 60_000) ||
556      !isValidMinutes(config.longBreakMs / 60_000)
557    ) {
558      error = DURATION_ERROR
559      return t
560    }
561    if (!isValidLongAfter(config.longAfter)) {
562      error = LONG_AFTER_ERROR
563      return t
564    }
565
566    const next: TimerState = { ...t, config }
567    const active = activePhase(t)
568    const lengthMs =
569      active === 'focus'
570        ? config.focusMs
571        : active === 'break'
572          ? config.breakMs
573          : active === 'long'
574            ? config.longBreakMs
575            : undefined
576    if (lengthMs === undefined || lengthMs === next.totalMs) return next
577
578    const current =
579      isRunning(t) && t.endsAt !== null ? Math.max(0, t.endsAt - now) : t.remainingMs
580    const remainingMs = Math.min(current, lengthMs)
581    return {
582      ...next,
583      remainingMs,
584      totalMs: lengthMs,
585      endsAt: isRunning(t) ? now + remainingMs : null,
586    }
587  })
588  if (error) return error
589  await saveConfig($, after.config)
590  return `Config saved — ${configSummary(after.config)}`
591}
592
593async function resetConfig($: Engine): Promise<string> {
594  await applyConfig($, () => baseConfig)
595  await clearSavedConfig($)
596  return `Config reset to plugin settings — ${configSummary(baseConfig)}`
597}
598
599const CONFIG_USAGE =
600  'usage: /pomodoro config [focus|break|long|after] <n> … | config reset'
601
602async function handleConfig($: Engine, raw: string): Promise<string> {
603  const rest = raw.replace(/^config\s*/i, '').trim()
604  if (!rest) {
605    const t = await getTimer($)
606    return `Config — ${configSummary(t.config)}`
607  }
608  if (rest.toLowerCase() === 'reset') return resetConfig($)
609
610  const tokens = rest.split(/\s+/)
611  const patch: Partial<TimerConfig> = {}
612  let i = 0
613  while (i < tokens.length) {
614    let key = tokens[i]!
615    let valStr: string | undefined
616    if (key.includes('=')) {
617      const [k, v] = key.split('=', 2)
618      key = k!
619      valStr = v
620      i += 1
621    } else {
622      valStr = tokens[i + 1]
623      i += 2
624    }
625    const n = Number(valStr)
626    const k = key.toLowerCase()
627    if (k === 'after') {
628      if (!isValidLongAfter(n)) return LONG_AFTER_ERROR
629      patch.longAfter = n
630      continue
631    }
632    if (!isValidMinutes(n)) return valStr === undefined ? CONFIG_USAGE : DURATION_ERROR
633    if (k === 'focus') patch.focusMs = Math.round(n * 60_000)
634    else if (k === 'break') patch.breakMs = Math.round(n * 60_000)
635    else if (k === 'long') patch.longBreakMs = Math.round(n * 60_000)
636    else return CONFIG_USAGE
637  }
638  if (Object.keys(patch).length === 0) return CONFIG_USAGE
639  return applyConfig($, () => patch)
640}
641
642async function openPane($: Engine): Promise<string> {
643  const opened = await $.ui.open({ id: PANE_ID, title: 'Pomodoro', rows: 8 })
644  if (!opened.isPlaced) {
645    return `Pane opened but not placed yet (${opened.reason ?? 'widen terminal or ask again'})`
646  }
647  return 'Pomodoro pane open'
648}
649
650async function closePane($: Engine): Promise<string> {
651  await $.ui.close({ id: PANE_ID })
652  return 'Pomodoro pane closed'
653}
654
655async function isPaneOpen($: Engine, id: string = PANE_ID): Promise<boolean> {
656  try {
657    const panes = await $.ui.panes()
658    return panes.some((p) => p.id === id)
659  } catch {
660    return false
661  }
662}
663
664/**
665 * Open the dismissible break Pane (focus + Escape-to-close).
666 * Skips if already open. Focus is a request — busy composer still opens
667 * the pane without keyboard. Any open failure keeps toast+band only.
668 */
669async function openBreakPane(
670  $: Engine,
671  kind: 'break' | 'long',
672): Promise<void> {
673  try {
674    if (await isPaneOpen($)) return
675    const title = kind === 'long' ? 'Pomodoro Long Break' : 'Pomodoro Break'
676    try {
677      await $.ui.open({
678        id: PANE_ID,
679        title,
680        focus: true,
681        closeOnEscape: true,
682        rows: 8,
683      })
684    } catch {
685      // Focus/open refused hard — retry without focus so the pane still appears.
686      await $.ui.open({
687        id: PANE_ID,
688        title,
689        closeOnEscape: true,
690        rows: 8,
691      })
692    }
693  } catch {
694    // Toast + band already shown — never crash the phase transition.
695  }
696}
697
698/** Close the break Pane when leaving break (break→focus), if still open. */
699async function closeBreakPane($: Engine): Promise<void> {
700  try {
701    if (!(await isPaneOpen($))) return
702    await $.ui.close({ id: PANE_ID })
703  } catch {
704    // ignore
705  }
706}
707
708async function openConfigPane($: Engine): Promise<string> {
709  try {
710    const opened = await $.ui.open({
711      id: CONFIG_PANE_ID,
712      title: 'Pomodoro Config',
713      focus: true,
714      closeOnEscape: true,
715      rows: 10,
716    })
717    if (!opened.isPlaced) {
718      return `Config pane opened but not placed yet (${opened.reason ?? 'widen terminal or ask again'})`
719    }
720    return 'Pomodoro config open'
721  } catch {
722    try {
723      await $.ui.open({
724        id: CONFIG_PANE_ID,
725        title: 'Pomodoro Config',
726        closeOnEscape: true,
727        rows: 10,
728      })
729      return 'Pomodoro config open'
730    } catch {
731      return 'Could not open config pane'
732    }
733  }
734}
735
736async function closeConfigPane($: Engine): Promise<string> {
737  try {
738    await $.ui.close({ id: CONFIG_PANE_ID })
739  } catch {
740    // ignore
741  }
742  return 'Pomodoro config closed'
743}
744
745type Kit = {
746  // eslint-disable-next-line @typescript-eslint/no-explicit-any
747  Button: (props: any) => RenderElement
748  // eslint-disable-next-line @typescript-eslint/no-explicit-any
749  Text: (props: any) => RenderElement
750}
751
752function idleStartButtons($: Engine, kit: Kit, prefix: string): RenderChildren[] {
753  const { Button, Text } = kit
754  const nodes: RenderChildren[] = []
755  const hotkeys = ['1', '2', '3'] as const
756  IDLE_START_MINUTES.forEach((mins, i) => {
757    if (i > 0) nodes.push(Text({ children: ' ' }))
758    nodes.push(
759      Button({
760        key: `${prefix}start-${mins}`,
761        label: `▶ ${mins}`,
762        hotkey: hotkeys[i],
763        plain: true,
764        onPress: () => {
765          void startFocus($, mins)
766        },
767      }),
768    )
769  })
770  nodes.push(Text({ children: '  ' }))
771  nodes.push(
772    Button({
773      key: `${prefix}config`,
774      label: '⚙',
775      hotkey: 'c',
776      plain: true,
777      onPress: () => {
778        void openConfigPane($)
779      },
780    }),
781  )
782  return nodes
783}
784
785function runningControlButtons(
786  $: Engine,
787  kit: Kit,
788  t: TimerState,
789  prefix: string,
790): RenderChildren[] {
791  const { Button, Text } = kit
792  const pauseLabel = t.phase === 'paused' ? 'resume' : 'pause'
793  return [
794    Button({
795      key: `${prefix}pause`,
796      label: pauseLabel,
797      hotkey: '1',
798      plain: true,
799      onPress: () => {
800        void togglePause($)
801      },
802    }),
803    Text({ children: '  ' }),
804    Button({
805      key: `${prefix}skip`,
806      label: 'skip',
807      hotkey: '2',
808      plain: true,
809      onPress: () => {
810        void skipPhase($)
811      },
812    }),
813    Text({ children: '  ' }),
814    Button({
815      key: `${prefix}stop`,
816      label: 'stop',
817      hotkey: '3',
818      plain: true,
819      onPress: () => {
820        void stopTimer($)
821      },
822    }),
823  ]
824}
825
826function controlButtons(
827  $: Engine,
828  kit: Kit,
829  t: TimerState,
830  prefix: string,
831): RenderChildren[] {
832  if (t.phase === 'idle') return idleStartButtons($, kit, prefix)
833  return runningControlButtons($, kit, t, prefix)
834}
835
836function chipRow(
837  kit: Kit,
838  label: string,
839  chips: readonly number[],
840  current: number,
841  prefix: string,
842  onPick: (n: number) => void,
843  unit: 'm' | '' = 'm',
844): RenderElement {
845  const { Box, Text, Button } = kit as Kit & {
846    // eslint-disable-next-line @typescript-eslint/no-explicit-any
847    Box: (props: any) => RenderElement
848  }
849  const children: RenderChildren[] = [
850    Text({
851      color: THEME.subtle,
852      children: `${label} `,
853    }),
854  ]
855  chips.forEach((n, i) => {
856    if (i > 0) children.push(Text({ children: ' ' }))
857    const selected = n === current
858    children.push(
859      Button({
860        key: `${prefix}${n}`,
861        label: selected ? `[${n}${unit}]` : `${n}${unit}`,
862        plain: true,
863        onPress: () => onPick(n),
864      }),
865    )
866  })
867  return Box({ flexDirection: 'row', alignItems: 'center', children })
868}
869
870async function handleCommand($: Engine, rawIn: string): Promise<{ text: string }> {
871  const raw = (rawIn ?? '').trim()
872  const arg = raw.toLowerCase()
873  if (arg === '' || arg === 'status') {
874    const t = await getTimer($)
875    if (t.phase === 'idle') {
876      return {
877        text: `Pomodoro idle. Press ▶ 30 / ▶ 45 / ▶ 60 or /pomodoro start [minutes] · ${configSummary(t.config)}`,
878      }
879    }
880    const cue = isBreakPhase(activePhase(t))
881      ? ' — step away from the keyboard'
882      : ''
883    return {
884      text: `${phaseLabel(t)}${cue} · ${formatMmSs(t.remainingMs)} · ${t.phase}`,
885    }
886  }
887  if (arg.startsWith('start')) {
888    const minutes = parseStartMinutes(arg)
889    if (arg !== 'start' && minutes === undefined) {
890      return { text: 'usage: /pomodoro start [minutes]' }
891    }
892    return { text: await startFocus($, minutes) }
893  }
894  if (arg === 'pause') return { text: await pauseTimer($) }
895  if (arg === 'resume') return { text: await resumeTimer($) }
896  if (arg === 'skip') return { text: await skipPhase($) }
897  if (arg === 'stop') return { text: await stopTimer($) }
898  if (arg === 'pane' || arg === 'pane open') {
899    return { text: await openPane($) }
900  }
901  if (arg === 'pane close') return { text: await closePane($) }
902  if (arg === 'config' || arg === 'config open') {
903    // Bare config opens the button Pane; slash setters still work via args.
904    return { text: await openConfigPane($) }
905  }
906  if (arg === 'config close') return { text: await closeConfigPane($) }
907  if (arg.startsWith('config')) {
908    return { text: await handleConfig($, raw) }
909  }
910  return {
911    text: 'usage: /pomodoro start [minutes]|pause|resume|skip|stop|config …|pane [close]',
912  }
913}
914
915export function register(on: On, options: PluginOptions = {}): void {
916  baseConfig = configFromOptions(options)
917  soundOn = options.sound !== false
918
919  on('session.start', async ($, e, next) => {
920    stopTick()
921    // A refused register (e.g. a name clash) must not abort the rest of
922    // session.start, or the band never gets its idle state and first draw.
923    try {
924      await $.command.register({
925        name: 'pomodoro',
926        description:
927          'Pomodoro: start, pause, resume, skip, stop, config, or pane',
928        argumentHint:
929          '[start [minutes]|pause|resume|skip|stop|config …|pane [close]]',
930        immediate: true,
931      })
932    } catch {
933      // Band buttons still work without the slash command.
934    }
935
936    // Seed the state with the saved config (or the plugin settings) and write
937    // it, so AbovePrompt subscribers draw. A running block survives a reload
938    // and keeps counting from its endsAt.
939    const config = await loadConfig($)
940    const seeded = await mutate($, (t) => ({ ...t, config }))
941
942    if (seeded.phase !== 'idle' && seeded.phase !== 'paused') {
943      ensureTick($)
944    }
945    syncStatus($, seeded)
946
947    // Force AbovePrompt (idle start chips included) to draw on session start.
948    // Types: invalidate("ui.render") re-runs cached render hooks; status pin
949    // keeps the plugin visible under the prompt. Deferred pass for Desktop
950    // bind / hot-reload timing after session.start returns.
951    $.ui.invalidate('ui.render')
952    $.clock.after(0, async () => {
953      syncStatus($, await getTimer($))
954      $.ui.invalidate('ui.render')
955    })
956    return next(e)
957  })
958
959  on('session.end', async ($, e, next) => {
960    stopTick()
961    $.ui.status(undefined)
962    return next(e)
963  })
964
965  on('command.run', { command: 'pomodoro' }, async ($, e) => {
966    return handleCommand($, e.args ?? '')
967  })
968
969  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
970    const t = await getTimer($)
971    if (e.props.hasSurvey) return next(e)
972
973    const { Box, Text, Button } = $.ui.resolve(e)
974    const color = phaseColor(t)
975    const icon = phaseIcon(t)
976    const wide = (e.props.bodyColumns ?? 80) >= NARROW_COLS
977
978    // Idle: compact button-first strip — Pomodoro · [▶ 30] [▶ 45] [▶ 60] · [⚙]
979    if (t.phase === 'idle') {
980      const children: RenderChildren[] = [
981        Text({ color, bold: true, children: `${icon} Pomodoro` }),
982        Text({ color: THEME.subtle, children: ' · ' }),
983        ...idleStartButtons($, { Button, Text }, 'band-'),
984      ]
985      return Box({
986        flexDirection: 'row',
987        alignItems: 'center',
988        paddingX: 1,
989        borderStyle: 'round',
990        borderColor: THEME.border,
991        children,
992      })
993    }
994
995    const time = formatMmSs(t.remainingMs)
996    const onBreak = isBreakPhase(activePhase(t))
997    const children: RenderChildren[] = [
998      Text({ color, bold: true, children: `${icon} ` }),
999    ]
1000
1001    if (wide) {
1002      children.push(
1003        Text({ color, bold: true, children: bandHeadline(t) }),
1004        Text({ color: THEME.text, children: `   ${time}   ` }),
1005        Text({ color, children: progressBar(t.remainingMs, t.totalMs) }),
1006        Text({ children: '   ' }),
1007      )
1008    } else {
1009      const short =
1010        onBreak && (activePhase(t) === 'long' || t.pausedFrom === 'long')
1011          ? 'LONG '
1012          : onBreak
1013            ? 'BREAK '
1014            : ''
1015      children.push(
1016        Text({ color, bold: true, children: `${short}${time} ` }),
1017      )
1018    }
1019
1020    children.push(...runningControlButtons($, { Button, Text }, t, 'band-'))
1021
1022    // One-row compact band — no opaque strip bg (light/dark compatible);
1023    // phase accents live on border / labels / progress only.
1024    return Box({
1025      flexDirection: 'row',
1026      alignItems: 'center',
1027      paddingX: 1,
1028      borderStyle: 'round',
1029      borderColor: color,
1030      children,
1031    })
1032  })
1033
1034  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1035    const t = await getTimer($)
1036    const { Box, Text, Button } = $.ui.resolve(e)
1037    const color = phaseColor(t)
1038    const icon = phaseIcon(t)
1039    const time = formatMmSs(t.remainingMs)
1040    const onBreak = isBreakPhase(activePhase(t))
1041    const headline = paneHeadline(t)
1042    const hint = onBreak
1043      ? `Remaining ${time} — Esc or dismiss to close`
1044      : t.phase === 'paused'
1045        ? 'Paused'
1046        : t.phase === 'focus'
1047          ? 'Focus block'
1048          : 'Pick a focus length or open config'
1049
1050    const paneControls: RenderChildren[] = [
1051      ...controlButtons($, { Button, Text }, t, 'pane-'),
1052    ]
1053    // Dismiss closes the pane only — timer keeps running (Escape does the same).
1054    paneControls.push(
1055      Text({ children: '  ' }),
1056      Button({
1057        key: 'pane-dismiss',
1058        label: 'dismiss',
1059        hotkey: 'd',
1060        plain: true,
1061        onPress: () => {
1062          void $.ui.close({ id: PANE_ID })
1063        },
1064      }),
1065    )
1066
1067    // Pane chrome: no opaque chassis wash — phase accents on border/text only.
1068    return Box({
1069      flexDirection: 'column',
1070      paddingX: 1,
1071      paddingY: 1,
1072      gap: 1,
1073      borderStyle: 'round',
1074      borderColor: color,
1075      children: [
1076        Box({
1077          flexDirection: 'row',
1078          children: [
1079            Text({ color, bold: true, children: `${icon} ${headline}` }),
1080          ],
1081        }),
1082        Box({
1083          flexDirection: 'row',
1084          children: [
1085            Text({ color: THEME.text, children: `${time}  ` }),
1086            Text({
1087              color,
1088              children: progressBar(t.remainingMs, t.totalMs),
1089            }),
1090          ],
1091        }),
1092        Text({
1093          color: onBreak ? COLORS.break : THEME.subtle,
1094          children: hint,
1095        }),
1096        Box({
1097          flexDirection: 'row',
1098          children: paneControls,
1099        }),
1100        Text({
1101          color: THEME.subtle,
1102          children: configSummary(t.config),
1103        }),
1104      ],
1105    })
1106  })
1107
1108  on(
1109    'ui.render',
1110    { component: 'Pane', requestId: CONFIG_PANE_ID },
1111    async ($, e) => {
1112      const t = await getTimer($)
1113      const kit = $.ui.resolve(e)
1114      const { Box, Text, Button } = kit
1115      const focusMin = Math.round(t.config.focusMs / 60_000)
1116      const breakMin = Math.round(t.config.breakMs / 60_000)
1117      const longMin = Math.round(t.config.longBreakMs / 60_000)
1118      const after = t.config.longAfter
1119
1120      // Steps the latest focus length by whole minutes, so presses made
1121      // before a redraw all count.
1122      const bumpFocus = (delta: number) =>
1123        applyConfig($, (c) => {
1124          const minutes = Math.round(c.focusMs / 60_000) + delta
1125          return {
1126            focusMs: Math.min(MAX_MINUTES, Math.max(1, minutes)) * 60_000,
1127          }
1128        })
1129
1130      return Box({
1131        flexDirection: 'column',
1132        paddingX: 1,
1133        paddingY: 1,
1134        gap: 1,
1135        borderStyle: 'round',
1136        borderColor: THEME.border,
1137        children: [
1138          Text({
1139            color: COLORS.focus,
1140            bold: true,
1141            children: '● Pomodoro Config',
1142          }),
1143          chipRow(
1144            kit as Kit & { Box: typeof Box },
1145            'Focus',
1146            FOCUS_CHIPS,
1147            focusMin,
1148            'cfg-focus-',
1149            (n) => {
1150              void applyConfig($, () => ({ focusMs: n * 60_000 }))
1151            },
1152          ),
1153          Box({
1154            flexDirection: 'row',
1155            children: [
1156              Button({
1157                key: 'cfg-focus-minus',
1158                label: '−',
1159                plain: true,
1160                onPress: () => {
1161                  void bumpFocus(-1)
1162                },
1163              }),
1164              Text({ children: ' ' }),
1165              Button({
1166                key: 'cfg-focus-plus',
1167                label: '+',
1168                plain: true,
1169                onPress: () => {
1170                  void bumpFocus(1)
1171                },
1172              }),
1173              Text({
1174                color: THEME.subtle,
1175                children: `  now ${formatMinutes(t.config.focusMs)}m`,
1176              }),
1177            ],
1178          }),
1179          chipRow(
1180            kit as Kit & { Box: typeof Box },
1181            'Break',
1182            BREAK_CHIPS,
1183            breakMin,
1184            'cfg-break-',
1185            (n) => {
1186              void applyConfig($, () => ({ breakMs: n * 60_000 }))
1187            },
1188          ),
1189          chipRow(
1190            kit as Kit & { Box: typeof Box },
1191            'Long ',
1192            LONG_CHIPS,
1193            longMin,
1194            'cfg-long-',
1195            (n) => {
1196              void applyConfig($, () => ({ longBreakMs: n * 60_000 }))
1197            },
1198          ),
1199          chipRow(
1200            kit as Kit & { Box: typeof Box },
types/index.d.ts 39 lines
1/** Active or idle phase of the Pomodoro timer. */
2export type TimerPhase = 'idle' | 'focus' | 'break' | 'long' | 'paused'
3
4/** How long each phase lasts and when a long break is due. */
5export type TimerConfig = {
6  focusMs: number
7  breakMs: number
8  longBreakMs: number
9  longAfter: number
10}
11
12/**
13 * Hot-reload-safe timer snapshot held in `$.state`.
14 * Countdown lives here — never in module-level variables.
15 */
16export type TimerState = {
17  phase: TimerPhase
18  /** Phase restored by resume when `phase` is `paused`. */
19  pausedFrom: 'focus' | 'break' | 'long' | null
20  /** Time left; frozen while paused, refreshed from `endsAt` while running. */
21  remainingMs: number
22  /** Duration of the current (or paused) block, for the progress bar. */
23  totalMs: number
24  /** Epoch ms when the running block ends; `null` while idle or paused. */
25  endsAt: number | null
26  /**
27   * Focuses completed in the current cycle (0 .. longAfter-1).
28   * Displayed focus number is `focusesDone + 1` while in focus.
29   */
30  focusesDone: number
31  config: TimerConfig
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    pomodoro: { timer: TimerState }
37  }
38}
39