SLOPSHOPPER

focus-mode

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

newbandcommandtoastprompttimer
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/focus-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · focus-mode
› 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 ⎿ focus-mode: Focus started: 25 min. 🎯 Focus 25 min left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🎯 Focus 25 min left
README

focus-mode

A minimal pomodoro timer inside Claude Code.

What it does

  • /focus starts a countdown; a dim band above the prompt shows 🎯 Focus N min left, updated every minute.
  • When time is up: a toast Focus done — take a break and a band with a Dismiss button.

Commands

CommandEffect
/focusStarts 25 minutes (or reports the time left if a focus is running)
/focus <minutes>Starts a focus of 1 to 480 minutes
/focus stopCancels the countdown

Install

claude --plugin-dir /path/to/ModsTools/mods/focus-mode

Limits

  • Resolution is one minute; the remaining time never displays below 1 min.
  • A hot reload in the middle of a countdown delays the end toast.

Develop

claude plugin validate mods/focus-mode
claude plugin test mods/focus-mode   # 7 tests
Source 3 files
hooks/register.tsx 90 lines
1import { 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}
90
hooks/format.ts 18 lines
1export 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}
18
types/index.d.ts 8 lines
1export 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