A Pomodoro countdown above the prompt, with pause, stop and an end-of-session notification.

A Pomodoro countdown above the prompt, with pause, stop and an end-of-session notification.
In the terminal, while a session runs:
◷ Focus · 18:42 left ▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ [ Pause ] [ Stop ]
Paused:
◷ Focus · paused · 18:42 left ▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ [ Resume ] [ Stop ]
When it ends, a native notification and a toast say Focus session done. Take a 5 minute break., and the band shows:
✓ Focus done · 25:00 [ Dismiss ]
◷ Focus · 18:42 left 25%.| Command | What it does |
|---|---|
/focus-timer | Starts a session of the default length (25 minutes). |
/focus-timer 50 | Starts a 50-minute session (1 to 180). Restarts one already running. |
/focus-timer pause | Pauses, or resumes a paused session. |
/focus-timer stop | Stops the session and hides the band. |
The command runs at once, even while Claude is working. With the band focused (ctrl+x tab, or a click), p pauses or resumes, s stops and d dismisses the done line.
Claude Code already has a built-in /focus, so this command is /focus-timer.
| Option | Type | Default | What it does |
|---|---|---|---|
defaultMinutes | number | 25 | Length of a session started with a bare /focus-timer (1 to 180). |
breakMinutes | number | 5 | The break the end-of-session notification suggests (1 to 60). |
Set them with /config, or in settings under pluginConfigs["focus-timer@mod-showroom"].options.
/plugin install focus-timer --marketplace KROU4/claude-code-mod-showroomclaude plugin install focus-timer --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add pluginpreferredNotifChannel). Where there is none, as in the desktop app, only the toast and the done line show.MIT. See LICENSE.
hooks/register.tsx 233 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { FocusTimer } from '../types'
5
6const timer = atom({ plugin: 'focus-timer', key: 'timer' } as const, null)
7
8const MINUTE = 60_000
9const USAGE = 'Usage: /focus-timer [minutes 1-180] | pause | stop'
10
11// The one-second ticker of this environment; a reload drops it with its timers.
12let ticker: Timer | undefined
13
14const isRunning = (t: FocusTimer | null): t is FocusTimer =>
15 t !== null && !t.isDone && t.pausedRemaining === null
16
17const left = (t: FocusTimer, now: number) =>
18 t.pausedRemaining ?? Math.max(0, t.endsAt - now)
19
20// 25:00, or 1:30:00 from an hour up; seconds round up so 00:00 means done.
21function clockText(ms: number) {
22 const total = Math.max(0, Math.ceil(ms / 1000))
23 const hours = Math.floor(total / 3600)
24 const mm = String(Math.floor(total / 60) % 60).padStart(2, '0')
25 const ss = String(total % 60).padStart(2, '0')
26
27 return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
28}
29
30function bar(fraction: number, width: number) {
31 const full = Math.min(width, Math.max(0, Math.round(fraction * width)))
32
33 return '▰'.repeat(full) + '▱'.repeat(width - full)
34}
35
36function minutesOption(value: unknown, fallback: number, max: number) {
37 const n = Math.round(Number(value))
38
39 return Number.isFinite(n) && n >= 1 && n <= max ? n : fallback
40}
41
42function stopTicking() {
43 ticker?.cancel()
44 ticker = undefined
45}
46
47// Ends one interval. A late tick of an older interval must not cancel the
48// ticker started since, so `ticker` is cleared only while it is still `own`.
49function endInterval(own: Timer) {
50 own.cancel()
51 if (ticker === own) ticker = undefined
52}
53
54// Each second: redraw from the clock (so nothing drifts), or finish once
55// endsAt has passed. Only the tick whose write ends the session announces it.
56async function tick($: EngineInterface, doneText: string, own: Timer) {
57 const now = await $.clock.now()
58 const t = await read($, timer)
59 if (!isRunning(t)) return endInterval(own)
60 if (now < t.endsAt) return $.ui.invalidate('ui.render')
61
62 endInterval(own)
63 let isFinished = false
64 await update($, timer, cur => {
65 isFinished = isRunning(cur) && now >= cur.endsAt
66 return isFinished && cur !== null ? { ...cur, isDone: true } : cur
67 })
68 if (!isFinished) return
69 $.ui.toast(doneText)
70 await $.ui.notify(doneText, { title: 'Focus timer' })
71}
72
73function startTicking($: EngineInterface, doneText: string) {
74 stopTicking()
75 const own = $.clock.every(1000, () => {
76 tick($, doneText, own).catch(err => $.ui.log(`focus-timer: ${String(err)}`, { to: 'debug' }))
77 })
78 ticker = own
79}
80
81async function start($: EngineInterface, minutes: number, doneText: string) {
82 const now = await $.clock.now()
83 const totalMs = minutes * MINUTE
84 await update($, timer, () => ({
85 totalMs,
86 endsAt: now + totalMs,
87 pausedRemaining: null,
88 isDone: false,
89 }))
90 startTicking($, doneText)
91}
92
93async function togglePause($: EngineInterface, doneText: string) {
94 const now = await $.clock.now()
95 const after = await update($, timer, cur => {
96 if (cur === null || cur.isDone) return cur
97
98 return cur.pausedRemaining === null
99 ? { ...cur, pausedRemaining: left(cur, now) }
100 : { ...cur, endsAt: now + cur.pausedRemaining, pausedRemaining: null }
101 })
102 if (isRunning(after)) startTicking($, doneText)
103 else stopTicking()
104
105 return { after, now }
106}
107
108async function stop($: EngineInterface) {
109 stopTicking()
110 await update($, timer, () => null)
111}
112
113function dismiss($: EngineInterface) {
114 return update($, timer, cur => (cur?.isDone ? null : cur))
115}
116
117export const register: Register = (on, options) => {
118 const defaultMinutes = minutesOption(options.defaultMinutes, 25, 180)
119 const breakMinutes = minutesOption(options.breakMinutes, 5, 60)
120 const doneText = `Focus session done. Take a ${breakMinutes} minute break.`
121
122 // Claude Code ships its own /focus (the focus view), so the timer is /focus-timer.
123 on('session.start', async ($, e, next) => {
124 await $.command.register({
125 name: 'focus-timer',
126 description: `Start a focus session (${defaultMinutes} min by default), pause/resume or stop it`,
127 argumentHint: '[minutes|pause|stop]',
128 immediate: true,
129 })
130 // This fires again on a reload, a worker respawn or an enable, which drop
131 // the ticker but keep the state: pick a running countdown up again.
132 if (isRunning(await read($, timer))) startTicking($, doneText)
133
134 return next(e)
135 })
136
137 on('command.run', { command: 'focus-timer' }, async ($, e) => {
138 const arg = e.args.trim().toLowerCase()
139 const before = await read($, timer)
140 const isActive = before !== null && !before.isDone
141
142 if (arg === 'stop') {
143 await stop($)
144
145 return { text: isActive ? 'Focus timer stopped.' : 'No focus timer is running.' }
146 }
147
148 if (arg === 'pause') {
149 const { after, now } = await togglePause($, doneText)
150 if (after === null || after.isDone) return { text: 'No focus timer is running.' }
151 const rest = clockText(left(after, now))
152
153 return {
154 text: isRunning(after)
155 ? `Focus timer resumed: ${rest} left.`
156 : `Focus timer paused: ${rest} left.`,
157 }
158 }
159
160 const minutes = arg === '' ? defaultMinutes : Number(arg)
161 if (!Number.isInteger(minutes) || minutes < 1 || minutes > 180) return { text: USAGE }
162 await start($, minutes, doneText)
163
164 return { text: `Focus timer started: ${clockText(minutes * MINUTE)}.` }
165 })
166
167 // The done line stays until dismissed or the person's next prompt. Never a
168 // reason to hold a prompt back: a failure lets it through.
169 on('prompt.submit', async ($, e, next) => {
170 if (!e.isMeta && (await read($, timer))?.isDone) await dismiss($)
171
172 return next(e)
173 }).catch(($, e, next) => next(e))
174
175 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
176 const t = await read($, timer)
177 if (t === null || e.props.hasSurvey) return next(e)
178
179 const { Box, Button, Text } = $.ui.resolve(e)
180
181 if (t.isDone) {
182 return (
183 <Box flexDirection="row" gap={1}>
184 <Text>
185 <Text color="success">✓</Text> Focus done · {clockText(t.totalMs)}
186 </Text>
187 <Button
188 key="dismiss"
189 label="Dismiss"
190 hotkey="d"
191 role="dismiss"
192 onPress={() => dismiss($)}
193 />
194 </Box>
195 )
196 }
197
198 const rest = left(t, await $.clock.now())
199 const isPaused = t.pausedRemaining !== null
200 const fraction = 1 - rest / t.totalMs
201 // The terminal gets a bar of block glyphs sized to the band; elsewhere a percentage.
202 const width = Math.max(4, Math.min(20, e.props.bodyColumns - 56))
203 const progress =
204 e.surface === 'terminal' ? (
205 <Text color="claude" dimColor={isPaused}>
206 {bar(fraction, width)}
207 </Text>
208 ) : (
209 <Text dimColor tabular>
210 {`${Math.floor(fraction * 100)}%`}
211 </Text>
212 )
213
214 return (
215 <Box flexDirection="row" gap={1}>
216 <Text tabular>
217 <Text color="claude">◷</Text> Focus · {isPaused ? 'paused · ' : ''}
218 {clockText(rest)} left
219 </Text>
220 {progress}
221 <Button
222 key="pause"
223 label={isPaused ? 'Resume' : 'Pause'}
224 hotkey="p"
225 variant="primary"
226 onPress={() => togglePause($, doneText)}
227 />
228 <Button key="stop" label="Stop" hotkey="s" onPress={() => stop($)} />
229 </Box>
230 )
231 })
232}
233types/index.d.ts 17 lines1export type FocusTimer = {
2 /** The session's length, in milliseconds. */
3 totalMs: number
4 /** When the session ends, in `$.clock.now()` milliseconds; read while running. */
5 endsAt: number
6 /** Milliseconds left while paused; null while running. */
7 pausedRemaining: number | null
8 /** True once the session ran out, until dismissed or the next prompt. */
9 isDone: boolean
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'focus-timer': { timer: FocusTimer | null }
15 }
16}
17