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…

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.
/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install focus-timer@supermods
| Command | Effect |
|---|---|
/focus-timer | Start a focus block of focus_minutes. |
/focus-timer 25 | Start a focus block of 25 minutes (1 to 180). |
/focus-timer pause | Pause the timer, or resume it. |
/focus-timer stop | Stop the timer and remove the band. |
Hotkeys while the band has focus: p pause or resume, s skip to the next phase, x stop.
| Option | Default | Effect |
|---|---|---|
focus_minutes | 50 | Minutes in a focus block. |
break_minutes | 10 | Minutes in a break. |
long_break_minutes | 25 | Minutes in the break after every fourth focus block. |
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.
/clear or quitting ends it.hooks/register.tsx 161 lines1import { 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};
161types/index.d.ts 21 lines1export 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