SLOPSHOPPER

focus-timer

A focus timer in the band above the prompt: /focus-timer starts a block, the band counts it down with pause, skip and stop buttons, a toast marks each switch…

newbandcommandtoasttimer
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 for 50 min. The band above the prompt counts it down. ⏱ ░░░░░░░░░░░░░░░░ focus 50:00 · round 1 · 0 turns p: Pause s: Skip x: Stop ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⏱ ░░░░░░░░░░░░░░░░ focus 50:00 · round 1 · 0 turns p: Pause s: Skip x: Stop ⟨Claude Code's own drawing⟩
README

focus-timer

A focus timer in the band above the prompt. /focus-timer starts a block; the band counts it down with a progress bar, the round number and how many turns you finished in it, and offers pause, skip and stop. A toast marks each switch between focus and break, and every fourth break is a long one.

⏱ ██████░░░░░░░░░░ focus 31:40 · round 2 · 4 turns  Pause Skip Stop

Focus is drawn in magenta, breaks in cyan, a paused timer dim.

Install

/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install focus-timer@supermods

Use

CommandEffect
/focus-timerStart a focus block of focus_minutes.
/focus-timer 25Start a focus block of 25 minutes (1 to 180).
/focus-timer pausePause the timer, or resume it.
/focus-timer stopStop the timer and remove the band.

Hotkeys while the band has focus: p pause or resume, s skip to the next phase, x stop.

Configuration

OptionDefaultEffect
focus_minutes50Minutes in a focus block.
break_minutes10Minutes in a break.
long_break_minutes25Minutes in the break after every fourth focus block.

What it touches

Events: session.start (registers /focus-timer), turn.complete (main conversation turns, counted per phase), command.run (/focus-timer), ui.render (the band above the prompt).

Capabilities: $.clock (a one-second tick, alive only while a block runs unpaused), $.ui.toast, $.ui.invalidate, $.state. No file, process, network or model access.

Limitations

  • The timer lives in the session: /clear or quitting ends it.
  • The band redraws every second while a timer runs.
Source 2 files
hooks/register.tsx 161 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3
4import type { FocusBlock, FocusPhase } from '../types';
5
6// Not 'focus': the engine has a built-in /focus and refuses the name.
7const COMMAND = 'focus-timer';
8const TICK_MS = 1000;
9const BAR_CELLS = 16;
10const ROUNDS_PER_LONG_BREAK = 4;
11const MINUTE_MS = 60000;
12const MAX_MINUTES = 180;
13const COLORS: Record<FocusPhase, string> = { focus: 'magenta', break: 'cyan' };
14
15type Settings = { focusMs: number; breakMs: number; longBreakMs: number };
16
17const block = atom({ plugin: 'focus-timer', key: 'block' } as const, null);
18// The one-second tick, alive only while a block runs unpaused.
19let ticking: { cancel: () => void } | null = null;
20
21function plural(count: number, noun: string): string {
22  return `${count} ${noun}${count === 1 ? '' : 's'}`;
23}
24
25function startBlock(phase: FocusPhase, now: number, lengthMs: number, rounds: number): FocusBlock {
26  return { phase, endsAt: now + lengthMs, lengthMs, pausedLeftMs: null, rounds, turns: 0 };
27}
28
29export function clock(ms: number): string {
30  const seconds = Math.max(0, Math.ceil(ms / 1000));
31  return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
32}
33
34export function bar(leftMs: number, lengthMs: number, cells: number): string {
35  const done = Math.min(cells, Math.round(((lengthMs - leftMs) / lengthMs) * cells));
36  return '█'.repeat(done) + '░'.repeat(cells - done);
37}
38
39export function leftMs(current: FocusBlock, now: number): number {
40  return current.pausedLeftMs ?? Math.max(0, current.endsAt - now);
41}
42
43/** The phase that follows: a break after focus (a long one every fourth round), focus after a break. */
44export function nextPhase(current: FocusBlock, now: number, settings: Settings): FocusBlock {
45  if (current.phase === 'focus') {
46    const rounds = current.rounds + 1;
47    return startBlock('break', now, rounds % ROUNDS_PER_LONG_BREAK === 0 ? settings.longBreakMs : settings.breakMs, rounds);
48  }
49  return startBlock('focus', now, settings.focusMs, current.rounds);
50}
51
52export function describe(current: FocusBlock, now: number): string {
53  const state = current.pausedLeftMs === null ? current.phase : `${current.phase}, paused`;
54  return `${state} ${clock(leftMs(current, now))} · round ${current.rounds + 1} · ${plural(current.turns, 'turn')}`;
55}
56
57async function switchPhase($: EngineInterface, settings: Settings): Promise<void> {
58  const [current, now] = await Promise.all([read($, block), $.clock.now()]);
59  if (current === null) return;
60  const following = nextPhase(current, now, settings);
61  await update($, block, () => following);
62  const minutes = Math.round(following.lengthMs / MINUTE_MS);
63  $.ui.toast(following.phase === 'break' ? `Focus block ${following.rounds} done. Break for ${minutes} min.` : `Break over. Focus for ${minutes} min.`);
64}
65
66async function tick($: EngineInterface, settings: Settings): Promise<void> {
67  const [current, now] = await Promise.all([read($, block), $.clock.now()]);
68  if (current === null || current.pausedLeftMs !== null) {
69    ticking?.cancel();
70    ticking = null;
71    return;
72  }
73  if (now >= current.endsAt) await switchPhase($, settings);
74  else $.ui.invalidate('ui.render');
75}
76
77function startTicking($: EngineInterface, settings: Settings): void {
78  ticking ??= $.clock.every(TICK_MS, () => void tick($, settings));
79}
80
81async function togglePause($: EngineInterface, settings: Settings): Promise<void> {
82  const now = await $.clock.now();
83  const toggled = await update($, block, current => {
84    if (current === null) return current;
85    return current.pausedLeftMs === null
86      ? { ...current, pausedLeftMs: leftMs(current, now) }
87      : { ...current, endsAt: now + current.pausedLeftMs, pausedLeftMs: null };
88  });
89  if (toggled !== null && toggled.pausedLeftMs === null) startTicking($, settings);
90}
91
92export const register: Register = (on, options) => {
93  const minutes = (key: string) => Number(options[key]) * MINUTE_MS;
94  const settings: Settings = { focusMs: minutes('focus_minutes'), breakMs: minutes('break_minutes'), longBreakMs: minutes('long_break_minutes') };
95
96  on('session.start', async ($, e, next) => {
97    await $.command.register({
98      name: COMMAND,
99      description: `Start a focus block (/${COMMAND} [minutes]); /${COMMAND} pause, /${COMMAND} stop`,
100    });
101    // A reload keeps the block in state but drops the old module's timer.
102    const current = await read($, block);
103    if (current !== null && current.pausedLeftMs === null) startTicking($, settings);
104
105    return next(e);
106  });
107
108  on('turn.complete', async ($, e, next) => {
109    if (e.agentId === undefined) await update($, block, current => (current === null ? current : { ...current, turns: current.turns + 1 }));
110
111    return next(e);
112  });
113
114  on('command.run', { command: COMMAND }, async ($, e) => {
115    const word = e.args.trim();
116    const current = await read($, block);
117    if (word === 'stop') {
118      await update($, block, () => null);
119      return { text: current === null ? 'No focus timer is running.' : `Focus timer stopped after ${plural(current.rounds, 'finished block')}.` };
120    }
121    if (word === 'pause') {
122      if (current === null) return { text: `No focus timer is running. /${COMMAND} starts one.` };
123      await togglePause($, settings);
124      return { text: current.pausedLeftMs === null ? 'Focus timer paused.' : 'Focus timer resumed.' };
125    }
126    const asked = Number(word);
127    if (word !== '' && !(Number.isFinite(asked) && asked > 0 && asked <= MAX_MINUTES)) return { text: `Usage: /${COMMAND} [minutes, up to ${MAX_MINUTES}] | pause | stop` };
128    const lengthMs = word === '' ? settings.focusMs : asked * MINUTE_MS;
129    const started = startBlock('focus', await $.clock.now(), lengthMs, current?.rounds ?? 0);
130    await update($, block, () => started);
131    startTicking($, settings);
132
133    return { text: `Focus for ${Math.round(lengthMs / MINUTE_MS)} min. The band above the prompt counts it down.` };
134  });
135
136  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
137    const [current, now] = await Promise.all([read($, block), $.clock.now()]);
138    if (current === null || e.props.hasSurvey) return next(e);
139
140    const { Box, Button, Text } = $.ui.resolve(e);
141    const cells = Math.max(4, Math.min(BAR_CELLS, Math.floor(e.props.bodyColumns / 5)));
142
143    const below = await next(e);
144    const row = (
145      <Box>
146        <Text color={COLORS[current.phase]} dimColor={current.pausedLeftMs !== null}>
147          ⏱ {bar(leftMs(current, now), current.lengthMs, cells)}{' '}
148        </Text>
149        <Text wrap="truncate-end">{describe(current, now)} </Text>
150        <Button key="focus-pause" label={current.pausedLeftMs === null ? 'Pause' : 'Resume'} hotkey="p" plain dimColor onPress={() => togglePause($, settings)} />
151        <Text> </Text>
152        <Button key="focus-skip" label="Skip" hotkey="s" plain dimColor onPress={() => switchPhase($, settings)} />
153        <Text> </Text>
154        <Button key="focus-stop" label="Stop" hotkey="x" plain dimColor onPress={() => update($, block, () => null)} />
155      </Box>
156    );
157
158    return below ? <Box flexDirection="column">{row}{below}</Box> : row;
159  });
160};
161
types/index.d.ts 21 lines
1export type FocusPhase = 'focus' | 'break';
2
3export type FocusBlock = {
4  phase: FocusPhase;
5  /** When the phase ends, in `$.clock.now()` milliseconds. */
6  endsAt: number;
7  lengthMs: number;
8  /** Milliseconds left while paused; null while running. */
9  pausedLeftMs: number | null;
10  /** Focus blocks finished so far. */
11  rounds: number;
12  /** Turns finished in the current phase. */
13  turns: number;
14};
15
16declare module 'claude-code' {
17  interface PluginState {
18    'focus-timer': { block: FocusBlock | null };
19  }
20}
21