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

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.
Claude Code 2.1.286 or later. The plugin is a hooks module, so older versions do not load it.
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.
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.
1, 2, 3 for the durations, c for config.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.
When a focus block ends:
Pomodoro break — step away from the keyboard.Esc or dismiss (d) closes the Pane only; the break keeps running.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.
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.
/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.
The defaults come from the plugin's settings, which you can change on the plugin's settings screen:
| Setting | Default |
|---|---|
| Focus minutes | 25 |
| Break minutes | 5 |
| Long break minutes | 15 |
| Focuses before long break | 4 |
| Play a sound | on |
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.
afplay on macOS); where there is none, the timer works silently.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`
hooks/pomodoro.ts 1257 lines1/**
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 lines1/** 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