A pomodoro: /focus [minutes] starts it, a band above the prompt counts down, a toast ends it

A minimal pomodoro timer inside Claude Code.
/focus starts a countdown; a dim band above the prompt shows 🎯 Focus N min left, updated every minute.Focus done — take a break and a band with a Dismiss button.| Command | Effect |
|---|---|
/focus | Starts 25 minutes (or reports the time left if a focus is running) |
/focus <minutes> | Starts a focus of 1 to 480 minutes |
/focus stop | Cancels the countdown |
claude --plugin-dir /path/to/ModsTools/mods/focus-mode
claude plugin validate mods/focus-mode
claude plugin test mods/focus-mode # 7 testshooks/register.tsx 90 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { bandText, minutesLeft, parseMinutes } from './format'
5
6const IDLE = { endsAt: null, now: 0, isDone: false }
7const focus = atom({ plugin: 'focus-mode', key: 'focus' } as const, IDLE)
8const DONE_TEXT = 'Focus done — take a break'
9
10let ticker: Timer | null = null
11
12async function tick($: EngineInterface) {
13 const now = await $.clock.now()
14 const state = await read($, focus)
15 if (state.endsAt !== null && now >= state.endsAt) {
16 await update($, focus, () => ({ endsAt: null, now, isDone: true }))
17 $.ui.toast(DONE_TEXT)
18 } else {
19 await update($, focus, s => ({ ...s, now }))
20 }
21}
22
23function ensureTicker($: EngineInterface) {
24 if (ticker === null) ticker = $.clock.every(60_000, () => void tick($))
25}
26
27async function start($: EngineInterface, minutes: number) {
28 const now = await $.clock.now()
29 await update($, focus, () => ({ endsAt: now + minutes * 60_000, now, isDone: false }))
30 ensureTicker($)
31}
32
33async function stop($: EngineInterface) {
34 await update($, focus, () => IDLE)
35 ticker?.cancel()
36 ticker = null
37}
38
39async function answer($: EngineInterface, arg: string): Promise<string> {
40 if (arg === 'stop') {
41 await stop($)
42
43 return 'Focus stopped.'
44 }
45 const state = await read($, focus)
46 if (arg === '' && state.endsAt !== null) {
47 return `Focus: ${minutesLeft(state.endsAt, await $.clock.now())} min left.`
48 }
49 const minutes = parseMinutes(arg)
50 if (minutes === null) return 'Usage: /focus [minutes 1-480] | /focus stop'
51 await start($, minutes)
52
53 return `Focus started: ${minutes} min.`
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({ name: 'focus', description: 'Pomodoro: /focus [minutes] | /focus stop' })
59 const state = await read($, focus)
60 if (state.endsAt !== null) ensureTicker($)
61
62 return next(e)
63 })
64
65 on('prompt.submit', async ($, e, next) => {
66 const state = await read($, focus)
67 if (state.endsAt !== null) ensureTicker($)
68
69 return next(e)
70 })
71
72 on('command.run', { command: 'focus' }, async ($, e) => ({ text: await answer($, e.args.trim()) }))
73
74 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75 const state = await read($, focus)
76 if (e.props.hasSurvey || (state.endsAt === null && !state.isDone)) return next(e)
77 const { Box, Button, Text } = $.ui.resolve(e)
78 if (state.endsAt === null) {
79 return (
80 <Box>
81 <Text>{`🎯 ${DONE_TEXT}`}</Text>
82 <Button key="dismiss" label="Dismiss" onPress={() => update($, focus, () => IDLE)} />
83 </Box>
84 )
85 }
86
87 return <Text dimColor>{bandText(state.endsAt, state.now)}</Text>
88 })
89}
90hooks/format.ts 18 lines1export const DEFAULT_MINUTES = 25
2
3export function parseMinutes(arg: string): number | null {
4 if (arg === '') return DEFAULT_MINUTES
5 if (!/^\d+$/.test(arg)) return null
6 const n = Number(arg)
7
8 return n >= 1 && n <= 480 ? n : null
9}
10
11export function minutesLeft(endsAt: number, now: number): number {
12 return Math.max(1, Math.ceil((endsAt - now) / 60_000))
13}
14
15export function bandText(endsAt: number, now: number): string {
16 return `🎯 Focus ${minutesLeft(endsAt, now)} min left`
17}
18types/index.d.ts 8 lines1export type Focus = { endsAt: number | null; now: number; isDone: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'focus-mode': { focus: Focus }
6 }
7}
8