SLOPSHOPPER

focus-timer

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

newbandcommandtoastprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · focus-timer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /focus-timer ⎿ focus-timer: Focus timer started: 25:00. ◷ Focus · 25:00 left ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ [ Pause ] [ Stop ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ Focus · 25:00 left ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ [ Pause ] [ Stop ]
README

focus-timer

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

What it draws

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 ]
  • The desktop app draws the same band with the progress as a percentage: ◷ Focus · 18:42 left 25%.
  • The time left is computed from the clock on every redraw, once a second, so it never drifts. A paused session stays exactly where you left it.
  • The done line stays until you dismiss it or send your next prompt.

Commands

CommandWhat it does
/focus-timerStarts a session of the default length (25 minutes).
/focus-timer 50Starts a 50-minute session (1 to 180). Restarts one already running.
/focus-timer pausePauses, or resumes a paused session.
/focus-timer stopStops 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.

Options

OptionTypeDefaultWhat it does
defaultMinutesnumber25Length of a session started with a bare /focus-timer (1 to 180).
breakMinutesnumber5The break the end-of-session notification suggests (1 to 60).

Set them with /config, or in settings under pluginConfigs["focus-timer@mod-showroom"].options.

Install

  • Terminal: /plugin install focus-timer --marketplace KROU4/claude-code-mod-showroom
  • Shell: claude plugin install focus-timer --marketplace KROU4/claude-code-mod-showroom
  • Desktop app: + next to the prompt box → Plugins → Add marketplace KROU4/claude-code-mod-showroom → Add plugin

Limits

  • The notification goes through your notification channel (preferredNotifChannel). Where there is none, as in the desktop app, only the toast and the done line show.
  • The band steps aside while a survey uses that space.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 233 lines
1import { 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}
233
types/index.d.ts 17 lines
1export 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