Slap your MacBook and Claude feels it: a live motion meter, bonks, and slaps that stop Claude, run a command or send a prompt.


Slap your MacBook and Claude feels it. slapmod is a free, open-source mod for Claude Code that reads your MacBook's built-in motion sensor, so when you smack the laptop it goes bonk, a little meter in the side panel jumps, and if Claude was halfway through doing something you didn't like, the slap stops it and Claude gets told on your next message that you slapped it.
<a href="https://slapmac.com/?utm_source=github&utm_medium=readme&utm_campaign=slapmod"><img src="docs/slapmac.jpg" alt="SlapMac: slap your MacBook and it screams back" width="100%"></a> <b><a href="https://slapmac.com/?utm_source=github&utm_medium=readme&utm_campaign=slapmod">Get SlapMac</a></b>, the Mac app that screams back when you slap it. With it, every slap plays the SlapMac pack you picked, your own custom sounds or a whip that lashes across your screen.
The easy way: paste this repo's link into Claude Code (or any coding agent on your Mac) and say "install this":
Install this: https://github.com/tonnoz/slapmod
The agent follows the steps in For AI agents below.
Or do it yourself. At the Claude Code prompt in a terminal:
/plugin install slapmod --marketplace tonnoz/slapmod
Answer y to add the marketplace, then pick the user scope.
From a shell, the same thing in one line:
claude plugin install slapmod --marketplace tonnoz/slapmod
In the Claude desktop app: click + next to the prompt box, then Plugins, Add marketplace with tonnoz/slapmod, then Add plugin.
Then run /reload-plugins in an open session, or start a new one. Slap the laptop, and type /slap to see the meter.
Update with claude plugin update slapmod@slapmod. Remove with claude plugin uninstall slapmod@slapmod.
If someone gave you this repository's link and asked you to install it, do exactly this, in order:
uname -m must print arm64, and pmset -g batt must mention InternalBattery. If either check fails, stop and tell the person slapmod needs an Apple Silicon MacBook, M1 Pro or newer (desktop Macs have no motion sensor).claude --version. If the command isn't found, stop and tell the person to install it from the Claude desktop app instead: + next to the prompt box, then Plugins, Add marketplace with tonnoz/slapmod, then Add plugin.claude plugin install slapmod --marketplace tonnoz/slapmod. This adds the tonnoz/slapmod marketplace to the person's user settings and installs slapmod for their user.claude plugin update slapmod@slapmod instead./slap settings. Don't pass --config.claude plugin list should show slapmod@slapmod as enabled./reload-plugins, or to start a new Claude Code session. You can't run that command yourself. Then they can slap the laptop, open the meter with /slap, and tune it with /slap settings.Slap the laptop. The status line keeps count, and a toast tells you how hard it was. Type /slap to open the panel with the live motion meter and your slap count for this session and of all time, and /slap again closes it.
By default one slap stops Claude if it's in the middle of something, and your next message reaches Claude with a note saying you slapped it. Two quick slaps (within about 0.8 seconds) do that and then send Claude "That's not it. Step back and try a different approach.", so it changes course without you typing a word.
A few things to try:
/slap settings, tap the desk and watch the meter. A shake that crosses the ┃ counts as a slap, so press − or + until normal typing stays left of it and a real slap goes past it./compact with /slap double command /compact, and smack the laptop when the context gets long.Type /slap settings for the settings page. It works in the terminal and in the desktop app:
Changes save straight away. Every setting is also a row in /config, and you can set them from the prompt:
| Command | What it does | |||
|---|---|---|---|---|
/slap sensitivity 1-10 | 1 means only a real smack counts, 10 means a tap on the desk counts. 5 is the default. | |||
/slap on <action> [text] | What one slap does: stop (default), tell, command /compact, prompt Try again, smaller this time., or off. | |||
/slap double <action> [text] | What two quick slaps do. The default sends the "try a different approach" prompt. | |||
| `/slap sound bonk\ | whip\ | voice\ | off` | A cartoon bonk that gets meaner the harder you hit, one of five whip cracks (never the same one twice running, louder the harder you hit), your Mac's own voice yelling one of five lines (Ow!, Hey!, Ouch! Rude., Okay, okay!, What did I do?), never the same one twice running, or nothing. |
/slap status | Shows how it's set up. | |||
/slap test | Plays the sound. |
A few rules keep the command and prompt actions from going off by accident:
/slap itself./clear wipes your conversation just as if you'd typed it.If you have a few Claude sessions open, they all count the slap, but only one plays the sound, so you don't get a choir. A command or prompt only goes to the session you last typed in. Stopping is different: a slap stops every session that's in the middle of a turn, because it has no way to know which Claude you're mad at.
Changing the permission mode (what Shift+Tab does) can't be bound to a slap. Claude Code only takes mode changes from your own keystrokes, which is a good thing when the alternative is a bump on the desk switching you into auto-accept.
Everything slapmod does on your Mac, so there are no surprises:
native/slapsense, the motion helper built from the Swift source in helper/, from the moment a Claude Code session starts until it ends, except while SlapMac is listening (see below). It reads the accelerometer and prints slap events, and it exits when Claude Code does./Applications/SlapMac.app or ~/Applications/SlapMac.app holds Contents/MacOS/slapmac-bridge, and if neither does, it asks Spotlight with /usr/bin/mdfind "kMDItemCFBundleIdentifier == 'com.tonnoz.slapmac'". Without SlapMac it looks again every 5 minutes.slapmac-bridge status every 15 seconds when SlapMac is installed, to see whether it's listening, and /usr/bin/pgrep -x slapmac when there's no bridge or no answer, to see whether an older SlapMac is running (it plays its own sounds, so slapmod stays quiet).slapmac-bridge stream in place of the motion helper while SlapMac is listening, and slapmac-bridge play when you test the sound then. The bridge is part of SlapMac, signed by its developer, and only talks to SlapMac over a socket on your Mac./bin/chmod +x on the helper once, only if an install dropped its executable bit and the helper couldn't start./usr/bin/say five times when a session starts, only if the sound is voice, to make the voice lines once in your temp folder. It reads them into memory and deletes the files with /bin/rm, so a slap plays a ready clip instead of waiting the best part of a second for the voice to load.afplay. It falls back to say for a voice line that isn't ready yet.It also keeps a small lock file, slapmod-sound.lock, in your user's temp folder, so only one session plays sounds.
off: a note such as "the user slapped their MacBook 2 times since your last message, the hardest a solid one". It goes with your next message, or inside the prompt a slap sends.slapmod comes from the developer of SlapMac, the Mac app that talks back when you slap it, and the two work together. When SlapMac 1.6.0 or later is installed, activated and listening, slapmod hands it the slap detection: SlapMac feels the slap and plays the pack you picked in it, and slapmod gets that same slap through SlapMac's slapmac-bridge, so the count, the meter and what happens to Claude match what you heard. While it does:
When SlapMac is paused, has Slap Mode off, isn't activated or quits, slapmod goes straight back to its own helper and sounds, and picks SlapMac up again within 15 seconds of it listening. An older SlapMac without the bridge keeps slapmod quiet while it runs, as before.
The helper opens the accelerometer through IOKit HID, filters out gravity, finds slaps and prints one JSON line per event, and the mod reads those lines, draws the panel and plays the sounds. SlapMac's bridge prints the same kind of lines, each tagged "protocol": 1, so the mod reads either one the same way. The binary in native/ is built from helper/ by Scripts/build-helper.sh, signed with a Developer ID and notarized by Apple, so you can read every line that goes into it.
swift test --package-path helper runs the detector tests, and Scripts/build-helper.sh builds native/slapsense. It signs ad hoc unless you copy .env.example to .env.local and fill in your own Developer ID identity and notarytool profile.claude --plugin-dir . from the repo once so Claude Code lays the API types in .claude-plugin/types/, then pnpm install, pnpm typecheck, pnpm test and pnpm validate.python3 Scripts/make-sounds.py. The five whip cracks are public-domain (CC0) recordings from Freesound, trimmed and levelled, see NOTICE.Mods are early access in Claude Code and the API moves between releases. This one was built and tested on Claude Code 2.1.306.
Found a bug, or slapmod counts slaps you didn't make? Open an issue and say which Mac you have and what the meter showed. For a security problem, report it privately through the repository's Security tab instead.
The original slap-your-laptop idea is spank by taigrr, and reading the Apple Silicon accelerometer comes from taigrr's apple-silicon-accelerometer work.
MIT licensed, see LICENSE. The whip cracks are public-domain recordings by G.Lamont, SciFiSounds, JayRom01 and JacksonML on Freesound, see NOTICE.
hooks/register.tsx 72 lines1import type { Register } from 'claude-code'
2
3import { cleanText, readSettings } from './format'
4import { live, setRunningTurn, settings } from './state'
5import { clearTurn, markActive, runSlapCommand, takePending } from './actions'
6import { startSession } from './helper'
7import { renderPane } from './pane'
8
9// Literals in this file, so the loader can read what each hook matches.
10const PANE = 'slapmod'
11const COMMAND = 'slap'
12/** A slap's command or prompt, as the person types it in /config. */
13const TEXT_KEY = /^slapmod(@[^.]+)?\.(slapText|doubleSlapText)$/
14const RUNS_ITSELF = /^\/?slap(\s|$)/
15
16export const register: Register = (on, options) => {
17 Object.assign(settings, readSettings(options))
18
19 on('session.start', async ($, e, next) => {
20 await $.command.register({
21 name: COMMAND,
22 description: 'Show the slapmod meter, or /slap settings to tune it',
23 argumentHint: '[settings|status|test|sensitivity 1-10|on|double <action> [text]|sound <sound>]',
24 immediate: true,
25 })
26 const started = await next(e)
27 await startSession($)
28
29 return started
30 })
31
32 on('turn.start', async ($, e, next) => {
33 if (e.agentId === undefined) await setRunningTurn($, e.turnId)
34
35 return next(e)
36 })
37
38 on('turn.complete', async ($, e, next) => {
39 if (e.agentId === undefined) await clearTurn($, e.turnId)
40
41 return next(e)
42 })
43
44 // Claude hears about the slaps it missed with the next prompt, once.
45 on('prompt.submit', async ($, e, next) => {
46 void markActive($, e.origin.kind)
47 if (e.command !== undefined || e.isMeta) return next(e)
48 const note = await takePending($)
49
50 return note === undefined ? next(e) : next({ ...e, context: [...(e.context ?? []), note] })
51 })
52
53 on('command.run', { command: COMMAND }, ($, e) => runSlapCommand($, e.args))
54
55 // The text a slap runs or sends, typed in /config: tidied, and never /slap itself.
56 on('config.set', ($, e, next) => {
57 if (!TEXT_KEY.test(e.key) || typeof e.value !== 'string') return next(e)
58 const text = cleanText(e.value)
59 if (RUNS_ITSELF.test(text)) return { deny: "A slap can't run /slap itself." }
60
61 return next({ ...e, value: text })
62 })
63
64 on('ui.close', { id: PANE }, ($, e, next) => {
65 live.isPaneOpen = false
66
67 return next(e)
68 })
69
70 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
71}
72hooks/format.ts 429 lines1// Pure helpers: the helper's line protocol, the settings, the gestures, the
2// meter's drawing and the note Claude reads. No `$` here, so tests can call
3// them directly.
4
5import type { SlapMacLink } from '../types'
6
7export const HISTORY_LENGTH = 120
8export const MAX_LEVEL = 10
9export const DOUBLE_WINDOW_MS = 800
10export const SEND_MIN_STRENGTH = 0.35
11export const SEND_COOLDOWN_MS = 5_000
12export const MAX_TEXT_LENGTH = 2_000
13/** The line protocol of SlapMac's `slapmac-bridge`, which every line it prints names. */
14export const BRIDGE_PROTOCOL = 1
15export const BRIDGE_IN_APP = 'Contents/MacOS/slapmac-bridge'
16export const NO_SLAPMAC: SlapMacLink = { mode: 'none' }
17
18const BLOCKS = '▁▂▃▄▅▆▇█'
19const THRESHOLD_MARK = '┃'
20const LEVEL_FLOOR_G = 0.003
21const LEVEL_CEILING_G = 1.5
22const LOOSEST_THRESHOLD_G = 0.25
23const THRESHOLD_SPAN = 0.1
24const LIGHT_BELOW = 0.35
25const SOLID_BELOW = 0.7
26const ECHO_RATIO = 0.7
27
28export const LEVELS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] as const
29export const ACTIONS = ['stop', 'tell', 'command', 'prompt', 'off'] as const
30export const SOUNDS = ['bonk', 'whip', 'voice', 'off'] as const
31export type Action = (typeof ACTIONS)[number]
32export type Sound = (typeof SOUNDS)[number]
33export type Gesture = 'single' | 'double'
34export type Strength = 'light' | 'solid' | 'hard'
35
36/** What the Mac says to a slap: one at random, never the same twice running. */
37export const VOICE_LINES = ['Ow!', 'Hey!', 'Ouch! Rude.', 'Okay, okay!', 'What did I do?'] as const
38
39export type Settings = {
40 level: number
41 onSlap: Action
42 slapText: string
43 onDoubleSlap: Action
44 doubleSlapText: string
45 sound: Sound
46}
47
48/** The /config row of each setting, by its userConfig field. */
49export type SettingField = 'sensitivity' | 'onSlap' | 'slapText' | 'onDoubleSlap' | 'doubleSlapText' | 'sound'
50export const SETTING_FIELDS: readonly SettingField[] = [
51 'sensitivity',
52 'onSlap',
53 'slapText',
54 'onDoubleSlap',
55 'doubleSlapText',
56 'sound',
57]
58export type SettingWrite = { field: SettingField; value: string }
59
60export const DEFAULT_SETTINGS: Settings = {
61 level: 5,
62 onSlap: 'stop',
63 slapText: '',
64 onDoubleSlap: 'prompt',
65 doubleSlapText: "That's not it. Step back and try a different approach.",
66 sound: 'bonk',
67}
68
69export const GESTURE_FIELDS: Record<Gesture, { action: 'onSlap' | 'onDoubleSlap'; text: 'slapText' | 'doubleSlapText' }> = {
70 single: { action: 'onSlap', text: 'slapText' },
71 double: { action: 'onDoubleSlap', text: 'doubleSlapText' },
72}
73
74/** A line from slapsense, or from SlapMac's bridge, which adds its pack and threshold. */
75export type HelperEvent =
76 | { type: 'ready'; version: string; model: string; protocol?: number; voices?: string; threshold?: number }
77 | { type: 'settings'; voices: string; threshold: number }
78 | { type: 'leader' }
79 | { type: 'level'; g: number }
80 | { type: 'slap'; g: number; strength: number }
81 | { type: 'error'; code: string; message: string }
82
83/** Slaps Claude has not heard about yet. */
84export type Pending = { count: number; hardest: number; didStop: boolean }
85
86export const NOTHING_PENDING: Pending = { count: 0, hardest: 0, didStop: false }
87
88function choice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
89 return allowed.find(one => one === value) ?? fallback
90}
91
92export function cleanText(value: unknown): string {
93 return typeof value === 'string' ? value.trim().slice(0, MAX_TEXT_LENGTH) : ''
94}
95
96export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
97 return {
98 level: Number(choice(options.sensitivity, LEVELS, String(DEFAULT_SETTINGS.level) as (typeof LEVELS)[number])),
99 onSlap: choice(options.onSlap, ACTIONS, DEFAULT_SETTINGS.onSlap),
100 slapText: cleanText(options.slapText),
101 onDoubleSlap: choice(options.onDoubleSlap, ACTIONS, DEFAULT_SETTINGS.onDoubleSlap),
102 doubleSlapText: typeof options.doubleSlapText === 'string' ? cleanText(options.doubleSlapText) : DEFAULT_SETTINGS.doubleSlapText,
103 sound: choice(options.sound, SOUNDS, DEFAULT_SETTINGS.sound),
104 }
105}
106
107/** What a setting holds now, as its /config row spells it. */
108export function settingValue(settings: Settings, field: SettingField): string {
109 return field === 'sensitivity' ? String(settings.level) : settings[field]
110}
111
112/** A sensitivity level from 1 to 10 as the smallest shake, in g, that counts. */
113export function levelToThreshold(level: number): number {
114 const clamped = Math.min(MAX_LEVEL, Math.max(1, Math.round(level)))
115
116 return LOOSEST_THRESHOLD_G * THRESHOLD_SPAN ** ((clamped - 1) / (MAX_LEVEL - 1))
117}
118
119export function lineSplitter(): (text: string) => string[] {
120 let rest = ''
121
122 return text => {
123 const parts = (rest + text).split('\n')
124 rest = parts.pop() ?? ''
125
126 return parts.filter(line => line.trim() !== '')
127 }
128}
129
130export function parseEvent(line: string): HelperEvent | undefined {
131 try {
132 const event: unknown = JSON.parse(line)
133 if (typeof event !== 'object' || event === null || !('type' in event)) return undefined
134
135 return event as HelperEvent
136 } catch {
137 return undefined
138 }
139}
140
141function answer(stdout: string): Record<string, unknown> | undefined {
142 const event = parseEvent(stdout.split('\n').find(line => line.trim() !== '') ?? '')
143 if (event === undefined || (event as { protocol?: unknown }).protocol !== BRIDGE_PROTOCOL) return undefined
144
145 return event as Record<string, unknown>
146}
147
148/** What `slapmac-bridge status` printed, as a link; undefined when SlapMac gave no status this mod reads. */
149export function linkFromStatus(stdout: string): SlapMacLink | undefined {
150 const status = answer(stdout)
151 if (status?.type !== 'status') return undefined
152 if (status.available === true && typeof status.voices === 'string' && typeof status.threshold === 'number') {
153 return { mode: 'linked', voices: status.voices, threshold: status.threshold }
154 }
155
156 return { mode: 'idle', reason: typeof status.reason === 'string' ? status.reason : "SlapMac isn't listening." }
157}
158
159/** What `slapmac-bridge play` printed: undefined when SlapMac played, or why it didn't. */
160export function playRefusal(stdout: string): string | undefined {
161 const played = answer(stdout)
162 if (played?.type === 'played' && played.ok === true) return undefined
163 if (typeof played?.reason === 'string') return played.reason
164 if (typeof played?.message === 'string') return played.message
165
166 return "SlapMac didn't answer."
167}
168
169/** Where SlapMac's bridge may be: the app folders first, then what Spotlight found, each once. */
170export function bridgeCandidates(home: string, spotlight: string): string[] {
171 const apps = [
172 '/Applications/SlapMac.app',
173 ...(home === '' ? [] : [`${home}/Applications/SlapMac.app`]),
174 ...spotlight
175 .split('\n')
176 .map(line => line.trim())
177 .filter(line => line.endsWith('.app')),
178 ]
179
180 return [...new Set(apps.map(app => `${app}/${BRIDGE_IN_APP}`))]
181}
182
183export function isSameLink(a: SlapMacLink, b: SlapMacLink): boolean {
184 return JSON.stringify(a) === JSON.stringify(b)
185}
186
187/** Whether this mod plays the sounds: not while SlapMac does. */
188export function playsOwnSounds(link: SlapMacLink): boolean {
189 return link.mode === 'none' || link.mode === 'idle'
190}
191
192/** The smallest slap, in g: SlapMac's while it listens, else this mod's sensitivity. */
193export function thresholdOf(settings: Pick<Settings, 'level'>, link: SlapMacLink): number {
194 return link.mode === 'linked' ? link.threshold : levelToThreshold(settings.level)
195}
196
197/** One line on what SlapMac is doing, or undefined when it isn't there. */
198export function slapMacNote(link: SlapMacLink): string | undefined {
199 switch (link.mode) {
200 case 'none':
201 return undefined
202 case 'legacy':
203 return 'SlapMac is running, so it plays the sounds.'
204 case 'idle':
205 return `${link.reason} slapmod plays its own sounds.`
206 case 'linked':
207 return `SlapMac is listening and plays the ${link.voices} pack.`
208 }
209}
210
211export function strengthOf(strength: number): Strength {
212 if (strength < LIGHT_BELOW) return 'light'
213 if (strength < SOLID_BELOW) return 'solid'
214
215 return 'hard'
216}
217
218/** A shake in g as 0 to 1, on a log scale so a tap still shows. */
219export function scale(g: number): number {
220 if (g <= LEVEL_FLOOR_G) return 0
221
222 return Math.min(1, Math.log(g / LEVEL_FLOOR_G) / Math.log(LEVEL_CEILING_G / LEVEL_FLOOR_G))
223}
224
225export function sparkline(levels: readonly number[], width: number): string {
226 return levels
227 .slice(-Math.max(0, width))
228 .map(g => BLOCKS[Math.round(scale(g) * (BLOCKS.length - 1))])
229 .join('')
230}
231
232/** A bar of the shake, with a mark where a slap starts counting. */
233export function meter(g: number, width: number, threshold?: number): string {
234 const room = Math.max(0, width)
235 const filled = Math.round(scale(g) * room)
236 const cells = Array.from({ length: room }, (_, index): string => (index < filled ? '█' : '░'))
237 if (threshold !== undefined && room > 0) {
238 cells[Math.min(room - 1, Math.round(scale(threshold) * room))] = THRESHOLD_MARK
239 }
240
241 return cells.join('')
242}
243
244export function dial(level: number): string {
245 return '▮'.repeat(level) + '▯'.repeat(MAX_LEVEL - level)
246}
247
248/** One of `count` clips other than the last one played, from `random` in [0, 1). */
249export function pickOther(previous: number, count: number, random: number): number {
250 if (previous < 0 || previous >= count) return Math.floor(random * count)
251
252 return (previous + 1 + Math.floor(random * (count - 1))) % count
253}
254
255export function plural(count: number, one: string, many: string): string {
256 return `${count} ${count === 1 ? one : many}`
257}
258
259/** A command's text as `$.command.run` takes it: the name without its slash. */
260export function parseCommandText(text: string): { command: string; args: string } | undefined {
261 const match = /^\/?([\w:-]+)\s*([\s\S]*)$/.exec(text.trim())
262 if (match === null) return undefined
263
264 return { command: match[1] ?? '', args: (match[2] ?? '').trim() }
265}
266
267/** Why a slap may not run this text, or undefined when it may. */
268export function textRefusal(action: Action, text: string): string | undefined {
269 if (action !== 'command' || text === '') return undefined
270 const parsed = parseCommandText(text)
271 if (parsed === undefined) return 'That needs to be a slash command, like /compact.'
272 if (parsed.command === 'slap') return "A slap can't run /slap itself."
273
274 return undefined
275}
276
277export function actionText(action: Action, text: string): string {
278 switch (action) {
279 case 'stop':
280 return 'stops Claude'
281 case 'tell':
282 return 'tells Claude'
283 case 'command':
284 return text === '' ? 'runs a command (none set yet)' : `runs ${text.startsWith('/') ? text : `/${text}`}`
285 case 'prompt':
286 return text === '' ? 'sends a prompt (none written yet)' : `sends "${text}"`
287 case 'off':
288 return 'does nothing'
289 }
290}
291
292export function describeSettings(settings: Settings, link: SlapMacLink = NO_SLAPMAC): string {
293 const isLinked = link.mode === 'linked'
294 const sensitivity = `Sensitivity ${settings.level} of ${MAX_LEVEL} (${levelToThreshold(settings.level).toFixed(3)} g)`
295 const note = slapMacNote(link)
296
297 return [
298 isLinked ? `Sensitivity: set in SlapMac (${link.threshold.toFixed(3)} g). Without it: ${settings.level} of ${MAX_LEVEL}` : sensitivity,
299 `A slap ${actionText(settings.onSlap, settings.slapText)}`,
300 `A double slap ${actionText(settings.onDoubleSlap, settings.doubleSlapText)}`,
301 isLinked ? `Sound: SlapMac's ${link.voices} pack. Without it: ${settings.sound}` : `Sound: ${settings.sound}`,
302 ...(note === undefined || isLinked ? [] : [note]),
303 ].join('\n')
304}
305
306export type SlapCommand =
307 | { kind: 'toggle' }
308 | { kind: 'settings' }
309 | { kind: 'status' }
310 | { kind: 'test' }
311 | { kind: 'set'; writes: SettingWrite[] }
312 | { kind: 'error'; message: string }
313
314const USAGE =
315 'Try /slap, /slap settings, /slap status, /slap test, /slap sensitivity 1-10, /slap on|double stop|tell|command|prompt|off [text], or /slap sound bonk|voice|off.'
316
317/** Reads what follows /slap. Writes put a text before its action, so the action finds it. */
318export function parseSlapArgs(args: string): SlapCommand {
319 const [word = '', ...rest] = args.trim().split(/\s+/)
320 const tail = args.trim().slice(word.length).trim()
321 switch (word) {
322 case '':
323 return { kind: 'toggle' }
324 case 'settings':
325 case 'status':
326 case 'test':
327 return { kind: word }
328 case 'sensitivity': {
329 const level = Number(rest[0])
330 if (!Number.isInteger(level) || level < 1 || level > MAX_LEVEL) {
331 return { kind: 'error', message: `Sensitivity goes from 1 to ${MAX_LEVEL}.` }
332 }
333
334 return { kind: 'set', writes: [{ field: 'sensitivity', value: String(level) }] }
335 }
336 case 'sound': {
337 const sound = SOUNDS.find(one => one === rest[0])
338 if (sound === undefined) return { kind: 'error', message: 'The sound is bonk, whip, voice or off.' }
339
340 return { kind: 'set', writes: [{ field: 'sound', value: sound }] }
341 }
342 case 'on':
343 case 'double': {
344 const fields = GESTURE_FIELDS[word === 'on' ? 'single' : 'double']
345 const action = ACTIONS.find(one => one === rest[0])
346 if (action === undefined) return { kind: 'error', message: 'The action is stop, tell, command, prompt or off.' }
347 const text = cleanText(tail.slice(action.length))
348 const refusal = textRefusal(action, text)
349 if (refusal !== undefined) return { kind: 'error', message: refusal }
350 const writes: SettingWrite[] = []
351 if (text !== '' && (action === 'command' || action === 'prompt')) writes.push({ field: fields.text, value: text })
352 writes.push({ field: fields.action, value: action })
353
354 return { kind: 'set', writes }
355 }
356 default:
357 return { kind: 'error', message: USAGE }
358 }
359}
360
361export type GestureState = { id: number; firstAt: number; isWaiting: boolean; quietUntil: number }
362export const GESTURE_START: GestureState = { id: 0, firstAt: -Infinity, isWaiting: false, quietUntil: -Infinity }
363export type GestureEvent = { type: 'slap'; at: number } | { type: 'due'; id: number }
364export type GestureEffect =
365 | { type: 'fire'; gesture: Gesture; action: Action }
366 | { type: 'schedule'; id: number; inMs: number }
367
368/**
369 * Turns slaps into actions. The first slap fires its action at once, unless a
370 * double slap is set and the action sends something: then it waits out the
371 * window, and a second slap inside it fires the double action instead. Slaps
372 * for one window after a double count but trigger nothing.
373 */
374export function gesture(
375 state: GestureState,
376 event: GestureEvent,
377 settings: Pick<Settings, 'onSlap' | 'onDoubleSlap'>,
378): { state: GestureState; effects: GestureEffect[] } {
379 if (event.type === 'due') {
380 if (event.id !== state.id || !state.isWaiting) return { state, effects: [] }
381
382 return { state: { ...state, isWaiting: false }, effects: [{ type: 'fire', gesture: 'single', action: settings.onSlap }] }
383 }
384 const { at } = event
385 if (at < state.quietUntil) return { state, effects: [] }
386 if (state.isWaiting && at - state.firstAt <= DOUBLE_WINDOW_MS) {
387 const effects: GestureEffect[] =
388 settings.onDoubleSlap === 'off' ? [] : [{ type: 'fire', gesture: 'double', action: settings.onDoubleSlap }]
389
390 return { state: { id: state.id + 1, firstAt: -Infinity, isWaiting: false, quietUntil: at + DOUBLE_WINDOW_MS }, effects }
391 }
392 const id = state.id + 1
393 const hasDouble = settings.onDoubleSlap !== 'off'
394 const sends = settings.onSlap === 'command' || settings.onSlap === 'prompt'
395 const isDeferred = hasDouble && sends
396 // A late timer must not swallow the first slap's action.
397 const overdue: GestureEffect[] =
398 state.isWaiting && isDeferred ? [{ type: 'fire', gesture: 'single', action: settings.onSlap }] : []
399 const effects: GestureEffect[] =
400 settings.onSlap === 'off'
401 ? []
402 : isDeferred
403 ? [{ type: 'schedule', id, inMs: DOUBLE_WINDOW_MS }]
404 : [{ type: 'fire', gesture: 'single', action: settings.onSlap }]
405
406 return { state: { id, firstAt: at, isWaiting: hasDouble, quietUntil: state.quietUntil }, effects: [...overdue, ...effects] }
407}
408
409export type EchoGuard = { until: number; strength: number }
410
411/** A slap felt while our own sound plays, and much weaker than the slap that played it, is its echo. */
412export function isEcho(now: number, guard: EchoGuard | undefined, strength: number): boolean {
413 return guard !== undefined && now < guard.until && strength < guard.strength * ECHO_RATIO
414}
415
416export function canSend(now: number, lastSentAt: number): boolean {
417 return now - lastSentAt >= SEND_COOLDOWN_MS
418}
419
420/** What Claude reads with the next prompt about the slaps it missed. */
421export function slapNote(pending: Pending): string {
422 const hardest = strengthOf(pending.hardest)
423 if (pending.didStop) {
424 return `slapmod: the user stopped your last turn by slapping their MacBook (a ${hardest} slap). Take it as a sign that turn was going the wrong way.`
425 }
426
427 return `slapmod: the user slapped their MacBook ${plural(pending.count, 'time', 'times')} since your last message, the hardest a ${hardest} one.`
428}
429hooks/state.ts 177 lines1// The mod's state, and the one file that reads and writes it: the loader
2// accepts `$.state` sources declared in the file that uses them.
3//
4// Atoms hold what a drawing reads or what must survive a hot reload, which
5// every settings change causes; `settings` and `live` are the module's own and
6// start over with it.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Timer } from 'claude-code'
10
11import { DEFAULT_SETTINGS, GESTURE_START, HISTORY_LENGTH, NOTHING_PENDING, NO_SLAPMAC } from './format'
12import type { EchoGuard, GestureState, Pending, SettingField, Settings } from './format'
13import type { LastSlap, Sensor, SlapMacLink } from '../types'
14
15export const PANE = 'slapmod'
16export const HELPER = 'native/slapsense'
17export const PANE_PADDING = 1
18
19export type View = 'meter' | 'settings'
20/** A voice line made ahead of time: a WAV's bytes, and how long it plays. */
21export type VoiceClip = { base64: string; ms: number }
22
23const sensor = atom({ plugin: 'slapmod', key: 'sensor' } as const, { status: 'starting' })
24const levels = atom({ plugin: 'slapmod', key: 'levels' } as const, [])
25const slaps = atom({ plugin: 'slapmod', key: 'slaps' } as const, 0)
26const total = atom({ plugin: 'slapmod', key: 'total' } as const, 0)
27const last = atom({ plugin: 'slapmod', key: 'last' } as const, null)
28const slapMac = atom({ plugin: 'slapmod', key: 'slapMac' } as const, NO_SLAPMAC)
29const jolt = atom({ plugin: 'slapmod', key: 'jolt' } as const, PANE_PADDING)
30const view = atom({ plugin: 'slapmod', key: 'view' } as const, 'meter')
31const dial = atom({ plugin: 'slapmod', key: 'dial' } as const, null)
32const runningTurn = atom({ plugin: 'slapmod', key: 'runningTurn' } as const, null)
33const pending = atom({ plugin: 'slapmod', key: 'pending' } as const, NOTHING_PENDING)
34
35/** The settings this load was given; a change to them reloads the module. */
36export const settings: Settings = { ...DEFAULT_SETTINGS }
37
38export const live: {
39 isPaneOpen: boolean
40 isSoundLeader: boolean
41 /** Where SlapMac stands; the `slapMac` atom shows the same to the pane. */
42 link: SlapMacLink
43 /** SlapMac's bridge, once found. */
44 bridge: string | undefined
45 bridgeSearchedAt: number
46 /** Set when the bridge failed in a way that retrying won't fix, for this load. */
47 isBridgeBroken: boolean
48 isCheckingSlapMac: boolean
49 sessionId: string
50 configKeys: Partial<Record<SettingField, string>>
51 guard: EchoGuard | undefined
52 gesture: GestureState
53 firstStrength: number
54 lastSentAt: number
55 dialTimer: Timer | undefined
56 /** The voice lines made ahead of time, by their place in VOICE_LINES. */
57 voices: (VoiceClip | undefined)[]
58 lastVoice: number
59 lastWhip: number
60} = {
61 isPaneOpen: false,
62 isSoundLeader: false,
63 link: NO_SLAPMAC,
64 bridge: undefined,
65 bridgeSearchedAt: -Infinity,
66 isBridgeBroken: false,
67 isCheckingSlapMac: false,
68 sessionId: '',
69 configKeys: {},
70 guard: undefined,
71 gesture: GESTURE_START,
72 firstStrength: 0,
73 lastSentAt: -Infinity,
74 dialTimer: undefined,
75 voices: [],
76 lastVoice: -1,
77 lastWhip: -1,
78}
79
80export function readSensor($: EngineInterface): Promise<Sensor> {
81 return read($, sensor)
82}
83
84export function setSensor($: EngineInterface, next: Sensor): Promise<Sensor> {
85 return update($, sensor, () => next)
86}
87
88export function readLevels($: EngineInterface): Promise<number[]> {
89 return read($, levels)
90}
91
92export function pushLevel($: EngineInterface, g: number): Promise<number[]> {
93 return update($, levels, list => [...(list ?? []), g].slice(-HISTORY_LENGTH))
94}
95
96export function readSlaps($: EngineInterface): Promise<number> {
97 return read($, slaps)
98}
99
100export function countSlap($: EngineInterface): Promise<number> {
101 return update($, slaps, n => (n ?? 0) + 1)
102}
103
104export function readTotal($: EngineInterface): Promise<number> {
105 return read($, total)
106}
107
108export function addToTotal($: EngineInterface): Promise<number> {
109 return update($, total, n => (n ?? 0) + 1)
110}
111
112/** Brings the all-time count up to what the store kept from earlier sessions. */
113export function raiseTotal($: EngineInterface, stored: number): Promise<number> {
114 return update($, total, n => Math.max(n ?? 0, stored))
115}
116
117export function readLast($: EngineInterface): Promise<LastSlap | null> {
118 return read($, last)
119}
120
121export function setLast($: EngineInterface, slap: LastSlap): Promise<LastSlap | null> {
122 return update($, last, () => slap)
123}
124
125export function readSlapMac($: EngineInterface): Promise<SlapMacLink> {
126 return read($, slapMac)
127}
128
129export function setSlapMac($: EngineInterface, link: SlapMacLink): Promise<SlapMacLink> {
130 return update($, slapMac, () => link)
131}
132
133export function readJolt($: EngineInterface): Promise<number> {
134 return read($, jolt)
135}
136
137export function setJolt($: EngineInterface, offset: number): Promise<number> {
138 return update($, jolt, () => offset)
139}
140
141export function readView($: EngineInterface): Promise<View> {
142 return read($, view)
143}
144
145export function setView($: EngineInterface, page: View): Promise<View> {
146 return update($, view, () => page)
147}
148
149export function readDial($: EngineInterface): Promise<number | null> {
150 return read($, dial)
151}
152
153export function setDial($: EngineInterface, change: (draft: number | null) => number | null): Promise<number | null> {
154 return update($, dial, draft => change(draft ?? null))
155}
156
157export function readRunningTurn($: EngineInterface): Promise<string | null> {
158 return read($, runningTurn)
159}
160
161export function setRunningTurn($: EngineInterface, turnId: string | null): Promise<string | null> {
162 return update($, runningTurn, () => turnId)
163}
164
165/** Forgets the running turn once it ends, unless another has started since. */
166export function endRunningTurn($: EngineInterface, turnId: string): Promise<string | null> {
167 return update($, runningTurn, current => (current === turnId ? null : (current ?? null)))
168}
169
170export function readPending($: EngineInterface): Promise<Pending> {
171 return read($, pending)
172}
173
174export function setPending($: EngineInterface, change: (was: Pending) => Pending): Promise<Pending> {
175 return update($, pending, was => change(was ?? NOTHING_PENDING))
176}
177hooks/actions.ts 556 lines1// What a slap does, and how settings are written. Every function here takes
2// `$` from a hook, so each is declared at the top level, as the loader asks.
3
4import type { CommandRunResult, EngineInterface } from 'claude-code'
5
6import {
7 GESTURE_FIELDS,
8 MAX_LEVEL,
9 NOTHING_PENDING,
10 SEND_MIN_STRENGTH,
11 SETTING_FIELDS,
12 VOICE_LINES,
13 bridgeCandidates,
14 canSend,
15 cleanText,
16 describeSettings,
17 gesture,
18 isEcho,
19 isSameLink,
20 linkFromStatus,
21 parseCommandText,
22 parseSlapArgs,
23 pickOther,
24 playRefusal,
25 playsOwnSounds,
26 plural,
27 settingValue,
28 slapNote,
29 strengthOf,
30 textRefusal,
31} from './format'
32import { NO_SLAPMAC } from './format'
33import type { Action, Gesture, GestureEffect, SettingField, SettingWrite, Strength } from './format'
34import type { SlapMacLink } from '../types'
35import {
36 PANE,
37 PANE_PADDING,
38 addToTotal,
39 countSlap,
40 endRunningTurn,
41 live,
42 readDial,
43 readPending,
44 readRunningTurn,
45 readSlapMac,
46 setDial,
47 setJolt,
48 setLast,
49 setPending,
50 setRunningTurn,
51 setSlapMac,
52 setView,
53 settings,
54} from './state'
55
56export const TOTAL_KEY = 'totalSlaps'
57const ACTIVE_KEY = 'activeSession'
58const PENDING_WRITES_KEY = 'pendingWrites'
59const MAX_WRITE_TRIES = 3
60const DIAL_COMMIT_MS = 600
61const ECHO_TAIL_MS = 150
62const VOICE_GUARD_MS = 1_200
63const VOICE_MIME = 'audio/wav'
64const VOICE_FORMAT = ['--file-format=WAVE', '--data-format=LEI16@22050']
65const VOICE_BYTES_PER_SECOND = 22_050 * 2
66const WAV_HEADER_BYTES = 44
67const BASE64_BYTES_PER_CHAR = 3 / 4
68const FALLBACK_TMPDIR = '/tmp/'
69const BASE_GAIN = 0.6
70const STRENGTH_GAIN = 0.6
71const TOAST_MS = 1_500
72const STOP_TOAST_MS = 3_000
73const TEST_STRENGTH = 0.5
74const JOLT_FRAMES = [3, 0, 2, PANE_PADDING]
75const JOLT_FRAME_MS = 50
76const SETTINGS_ROWS = 20
77const SLAPMAC_PROCESS = 'slapmac'
78const SLAPMAC_QUERY = "kMDItemCFBundleIdentifier == 'com.tonnoz.slapmac'"
79/** How often to look for SlapMac's bridge again while it can't be found. */
80const BRIDGE_SEARCH_MS = 300_000
81/** What a setting SlapMac takes over while it listens is called, for the note after saving it. */
82const SLAPMAC_FIELDS: Partial<Record<SettingField, string>> = { sensitivity: 'the sensitivity', sound: 'the sound' }
83/** Where a prompt the person typed comes from: the terminal, a remote client, the desktop app. */
84const PERSON_ORIGINS = ['composer', 'bridge', 'sdk']
85
86type Clip = { asset: string; ms: number }
87
88/** The bonks, by how hard the slap was, with how long each plays. */
89const BONKS: Record<Strength, Clip> = {
90 light: { asset: 'sounds/bonk-light.wav', ms: 180 },
91 solid: { asset: 'sounds/bonk.wav', ms: 280 },
92 hard: { asset: 'sounds/bonk-hard.wav', ms: 450 },
93}
94/** Five whip cracks, played in turn at random; the slap's strength sets the volume. */
95const WHIPS: readonly Clip[] = [
96 { asset: 'sounds/whip-1.mp3', ms: 1_000 },
97 { asset: 'sounds/whip-2.mp3', ms: 446 },
98 { asset: 'sounds/whip-3.mp3', ms: 586 },
99 { asset: 'sounds/whip-4.mp3', ms: 1_000 },
100 { asset: 'sounds/whip-5.mp3', ms: 892 },
101]
102const GESTURE_NAMES: Record<Gesture, string> = { single: 'slap', double: 'double slap' }
103
104type Queued = { writes: SettingWrite[]; tries: number }
105type ActiveSession = { id: string; at: number }
106
107function isQueued(value: unknown): value is Queued {
108 return typeof value === 'object' && value !== null && 'writes' in value && Array.isArray(value.writes)
109}
110
111function isActiveSession(value: unknown): value is ActiveSession {
112 return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string'
113}
114
115export async function play($: EngineInterface, strength: number): Promise<void> {
116 const kind = strengthOf(strength)
117 const now = await $.clock.now()
118 if (settings.sound === 'bonk' || settings.sound === 'whip') {
119 const clip = settings.sound === 'bonk' ? BONKS[kind] : pickWhip()
120 live.guard = { until: now + clip.ms + ECHO_TAIL_MS, strength }
121 await $.audio.play({ asset: clip.asset }, { gain: BASE_GAIN + STRENGTH_GAIN * strength })
122 }
123 if (settings.sound === 'voice') {
124 const line = pickOther(live.lastVoice, VOICE_LINES.length, Math.random())
125 live.lastVoice = line
126 const clip = live.voices[line]
127 if (clip !== undefined) {
128 live.guard = { until: now + clip.ms + ECHO_TAIL_MS, strength }
129 await $.audio.play({ base64: clip.base64, mime: VOICE_MIME }, { gain: BASE_GAIN + STRENGTH_GAIN * strength })
130 return
131 }
132 // Not made yet: speak it, which waits on the voice to load.
133 live.guard = { until: now + VOICE_GUARD_MS, strength }
134 await $.audio.speak(VOICE_LINES[line] ?? VOICE_LINES[0])
135 }
136}
137
138/**
139 * Makes the voice lines once, with the Mac's own voice, so a slap plays a file
140 * as quickly as a bonk instead of waiting on `say` to load the voice.
141 */
142export async function prepareVoices($: EngineInterface): Promise<void> {
143 if (settings.sound !== 'voice') return
144 const tmp = (await $.env.get('TMPDIR').catch(() => undefined)) ?? FALLBACK_TMPDIR
145 const dir = tmp.endsWith('/') ? tmp : `${tmp}/`
146 for (const [line, text] of VOICE_LINES.entries()) {
147 const path = `${dir}slapmod-voice-${live.sessionId === '' ? 'session' : live.sessionId}-${line}.wav`
148 const isMade = await $.process.run(['/usr/bin/say', ...VOICE_FORMAT, '-o', path, text]).then(
149 result => result.exitCode === 0,
150 () => false,
151 )
152 if (!isMade) continue
153 const clip = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
154 await $.process.run(['/bin/rm', '-f', path]).catch(() => undefined)
155 if (clip !== undefined) live.voices[line] = { base64: clip.base64, ms: clipMs(clip.base64) }
156 }
157}
158
159function clipMs(base64: string): number {
160 const bytes = base64.length * BASE64_BYTES_PER_CHAR - WAV_HEADER_BYTES
161
162 return Math.round((Math.max(0, bytes) / VOICE_BYTES_PER_SECOND) * 1_000)
163}
164
165function pickWhip(): Clip {
166 live.lastWhip = pickOther(live.lastWhip, WHIPS.length, Math.random())
167
168 return WHIPS[live.lastWhip] ?? BONKS.solid
169}
170
171/** Plays what a slap would: SlapMac's pack while it listens. Says how that went. */
172export async function playTest($: EngineInterface): Promise<{ text: string; isPlayed: boolean }> {
173 if (live.link.mode === 'linked' && live.bridge !== undefined) {
174 const { voices } = live.link
175 const result = await $.process.run([live.bridge, 'play', '--strength', String(TEST_STRENGTH)]).catch(() => undefined)
176 const refusal = result === undefined ? "SlapMac didn't answer." : playRefusal(result.stdout)
177
178 return refusal === undefined
179 ? { text: `SlapMac played its ${voices} pack.`, isPlayed: true }
180 : { text: `SlapMac: ${refusal}`, isPlayed: false }
181 }
182 if (settings.sound === 'off') return { text: 'Sounds are off. Turn them on in /slap settings.', isPlayed: false }
183 await play($, TEST_STRENGTH).catch(() => undefined)
184
185 return { text: 'Bonk.', isPlayed: true }
186}
187
188/** The pane's Test sound: quiet when it plays, a toast when it can't. */
189export async function testSound($: EngineInterface): Promise<void> {
190 const tested = await playTest($)
191 if (!tested.isPlayed) $.ui.toast(tested.text)
192}
193
194function joltPane($: EngineInterface): void {
195 JOLT_FRAMES.forEach((offset, frame) => {
196 $.clock.after(frame * JOLT_FRAME_MS, () => void setJolt($, offset))
197 })
198}
199
200export async function stopTurn($: EngineInterface): Promise<boolean> {
201 const turnId = await readRunningTurn($)
202 if (turnId === null) return false
203 await setRunningTurn($, null)
204
205 return $.turn.abort({ turnId }).then(
206 () => true,
207 () => false,
208 )
209}
210
211export async function slapped($: EngineInterface, g: number, strength: number): Promise<void> {
212 // Our own sound shakes the case too: a much weaker slap felt while it plays is the echo.
213 const now = await $.clock.now()
214 if (isEcho(now, live.guard, strength)) return
215
216 const count = await countSlap($)
217 const all = await addToTotal($)
218 await setLast($, { g, strength })
219 void $.store.set(TOTAL_KEY, all)
220 $.ui.status(`👋 ${plural(count, 'slap', 'slaps')}`)
221 if (live.isPaneOpen) {
222 joltPane($)
223 } else {
224 $.ui.toast(`👋 A ${strengthOf(strength)} slap`, { timeoutMs: TOAST_MS })
225 }
226 // One session plays per slap, and none while SlapMac plays its own.
227 if (live.isSoundLeader && playsOwnSounds(live.link)) void play($, strength).catch(() => undefined)
228
229 if (settings.onSlap !== 'off' || settings.onDoubleSlap !== 'off') {
230 await setPending($, was => ({ ...was, count: was.count + 1, hardest: Math.max(was.hardest, strength) }))
231 }
232 const next = gesture(live.gesture, { type: 'slap', at: now }, settings)
233 live.gesture = next.state
234 await applyEffects($, next.effects, strength)
235}
236
237async function applyEffects($: EngineInterface, effects: readonly GestureEffect[], strength: number): Promise<void> {
238 for (const effect of effects) {
239 if (effect.type === 'schedule') {
240 live.firstStrength = strength
241 $.clock.after(effect.inMs, () => void onDue($, effect.id))
242 continue
243 }
244 await fire($, effect.gesture, effect.action, strength)
245 }
246}
247
248async function onDue($: EngineInterface, id: number): Promise<void> {
249 const next = gesture(live.gesture, { type: 'due', id }, settings)
250 live.gesture = next.state
251 await applyEffects($, next.effects, live.firstStrength)
252}
253
254async function fire($: EngineInterface, which: Gesture, action: Action, strength: number): Promise<void> {
255 if (action === 'stop') {
256 if (await stopTurn($)) {
257 await setPending($, was => ({ ...was, didStop: true }))
258 $.ui.toast('Slap! Claude stopped.', { timeoutMs: STOP_TOAST_MS })
259 }
260 return
261 }
262 if (action !== 'command' && action !== 'prompt') return
263
264 const text = settings[GESTURE_FIELDS[which].text]
265 if (text === '') {
266 $.ui.toast(`Set what a ${GESTURE_NAMES[which]} ${action === 'command' ? 'runs' : 'sends'} in /slap settings.`)
267 return
268 }
269 if (strength < SEND_MIN_STRENGTH) {
270 $.ui.toast(`Slap harder to ${action === 'command' ? 'run it' : 'send it'}.`)
271 return
272 }
273 const now = await $.clock.now()
274 if (!canSend(now, live.lastSentAt)) {
275 $.ui.toast('Easy. One every few seconds.')
276 return
277 }
278 // Every open session feels the slap; only the one you typed in last acts.
279 if (!(await isActiveHere($))) return
280 live.lastSentAt = now
281 if (action === 'command') {
282 await runCommand($, text)
283 } else {
284 await sendPrompt($, text)
285 }
286}
287
288async function isActiveHere($: EngineInterface): Promise<boolean> {
289 const active = await $.store.get(ACTIVE_KEY).catch(() => undefined)
290
291 return isActiveSession(active) ? active.id === live.sessionId : live.isSoundLeader
292}
293
294/** Remembers that the person typed in this session, the one a slap's command or prompt goes to. */
295export async function markActive($: EngineInterface, originKind: string): Promise<void> {
296 if (!PERSON_ORIGINS.includes(originKind) || live.sessionId === '') return
297 const record: ActiveSession = { id: live.sessionId, at: await $.clock.now() }
298 await $.store.set(ACTIVE_KEY, record).catch(() => undefined)
299}
300
301async function runCommand($: EngineInterface, text: string): Promise<void> {
302 const parsed = parseCommandText(text)
303 const refusal = textRefusal('command', text)
304 if (parsed === undefined || refusal !== undefined) {
305 $.ui.toast(`slapmod: ${refusal ?? 'that is not a command.'}`)
306 return
307 }
308 const isKnown = await $.command.list().then(
309 list => list.some(command => command.name === parsed.command),
310 () => true,
311 )
312 if (!isKnown) {
313 $.ui.toast(`slapmod: there's no /${parsed.command} command.`)
314 return
315 }
316 $.ui.toast(`👋 Running /${parsed.command}`)
317 // Runs once the session is idle, so never wait on it here.
318 void $.command.run({ command: parsed.command, args: parsed.args }).catch(() => {
319 $.ui.toast(`slapmod: /${parsed.command} didn't run.`)
320 })
321}
322
323async function sendPrompt($: EngineInterface, text: string): Promise<void> {
324 // This mod's own prompt hook never sees its own prompt, so the note rides in the text.
325 const note = await takePending($)
326 $.ui.toast('👋 Sending your slap prompt')
327 // Starts a turn once the session is idle, so never wait on it here.
328 void $.prompt.submit({ text: note === undefined ? text : `${text}\n\n[${note}]`, asUser: true }).catch(() => {
329 $.ui.toast("slapmod: the prompt didn't go through.")
330 })
331}
332
333/** The note about the slaps Claude missed, once, for the next prompt. */
334export async function takePending($: EngineInterface): Promise<string | undefined> {
335 if (settings.onSlap === 'off' && settings.onDoubleSlap === 'off') return undefined
336 const was = await readPending($)
337 if (was.count === 0) return undefined
338 await setPending($, () => NOTHING_PENDING)
339
340 return slapNote(was)
341}
342
343export async function clearTurn($: EngineInterface, turnId: string): Promise<void> {
344 await endRunningTurn($, turnId)
345}
346
347export async function findConfigKeys($: EngineInterface): Promise<void> {
348 const rows = await $.config.list().catch(() => [])
349 for (const field of SETTING_FIELDS) {
350 const pattern = new RegExp(`^slapmod(@[^.]+)?\\.${field}$`)
351 const row = rows.find(one => pattern.test(one.key))
352 if (row !== undefined) live.configKeys[field] = row.key
353 }
354}
355
356/**
357 * Writes settings through /config. Each write reloads the module, which may
358 * cut the rest short, so the writes wait in the store and the next load
359 * finishes them; a write already in force is skipped.
360 */
361export async function writeSettings($: EngineInterface, writes: readonly SettingWrite[]): Promise<string | undefined> {
362 const due = writes.filter(write => settingValue(settings, write.field) !== write.value)
363 if (due.length === 0) return undefined
364 const queued: Queued = { writes: due, tries: 0 }
365 await $.store.set(PENDING_WRITES_KEY, queued)
366
367 return flushSettings($)
368}
369
370export async function flushSettings($: EngineInterface): Promise<string | undefined> {
371 const queued = await $.store.get(PENDING_WRITES_KEY).catch(() => undefined)
372 if (!isQueued(queued)) return undefined
373 const remaining = queued.writes.filter(write => settingValue(settings, write.field) !== write.value)
374 if (remaining.length === 0 || queued.tries >= MAX_WRITE_TRIES) {
375 await $.store.delete(PENDING_WRITES_KEY)
376 return remaining.length === 0 ? undefined : "Couldn't save that setting."
377 }
378 const next: Queued = { writes: remaining, tries: queued.tries + 1 }
379 await $.store.set(PENDING_WRITES_KEY, next)
380 for (const write of remaining) {
381 const key = live.configKeys[write.field] ?? `slapmod.${write.field}`
382 const result = await $.config.set({ key, value: write.value }).catch((error: unknown) => ({
383 deny: error instanceof Error ? error.message : "Couldn't save that setting.",
384 }))
385 if (result.deny !== undefined) {
386 await $.store.delete(PENDING_WRITES_KEY)
387 return result.deny
388 }
389 }
390 await $.store.delete(PENDING_WRITES_KEY)
391
392 return undefined
393}
394
395export async function saveSetting($: EngineInterface, field: SettingField, value: string): Promise<void> {
396 const refusal = await writeSettings($, [{ field, value }])
397 if (refusal !== undefined) $.ui.toast(`slapmod: ${refusal}`)
398}
399
400export async function saveText($: EngineInterface, which: Gesture, value: string): Promise<void> {
401 const fields = GESTURE_FIELDS[which]
402 const text = cleanText(value)
403 const refusal = textRefusal(settings[fields.action], text)
404 if (refusal !== undefined) {
405 $.ui.toast(refusal)
406 return
407 }
408 await saveSetting($, fields.text, text)
409}
410
411export async function nudgeDial($: EngineInterface, delta: number): Promise<void> {
412 await setDial($, draft => Math.min(MAX_LEVEL, Math.max(1, (draft ?? settings.level) + delta)))
413 // Saving reloads the module: wait for the presses to stop, then save once.
414 live.dialTimer?.cancel()
415 live.dialTimer = $.clock.after(DIAL_COMMIT_MS, () => void commitDial($))
416}
417
418async function commitDial($: EngineInterface): Promise<void> {
419 live.dialTimer = undefined
420 const level = await readDial($)
421 if (level === null) return
422 if (level === settings.level) {
423 await setDial($, () => null)
424 return
425 }
426 const refusal = await writeSettings($, [{ field: 'sensitivity', value: String(level) }])
427 if (refusal !== undefined) {
428 $.ui.toast(`slapmod: ${refusal}`)
429 await setDial($, () => null)
430 }
431}
432
433/** After a reload, the dial either shows the saved level or still has a change to save. */
434export async function reconcileDial($: EngineInterface): Promise<void> {
435 const level = await readDial($)
436 if (level === null) return
437 if (level === settings.level) {
438 await setDial($, () => null)
439 return
440 }
441 live.dialTimer = $.clock.after(DIAL_COMMIT_MS, () => void commitDial($))
442}
443
444/**
445 * Asks SlapMac whether it detects and voices the slaps. A SlapMac with the
446 * bridge answers for itself; an older one is only seen running, and plays its
447 * own sounds either way. Skipped while the bridge streams: the stream says
448 * when that ends.
449 */
450export async function checkSlapMac($: EngineInterface): Promise<void> {
451 if (live.isCheckingSlapMac || live.link.mode === 'linked') return
452 live.isCheckingSlapMac = true
453 try {
454 await setLink($, await askSlapMac($))
455 } finally {
456 live.isCheckingSlapMac = false
457 }
458}
459
460/** Sets where SlapMac stands. The atom outlives a reload and this load's copy doesn't, so the atom is what's compared. */
461export async function setLink($: EngineInterface, link: SlapMacLink): Promise<void> {
462 live.link = link
463 if (isSameLink(link, await readSlapMac($))) return
464 await setSlapMac($, link)
465}
466
467/** Whether SlapMac's bridge runs in the helper's place. */
468export function isBridged(): boolean {
469 return live.link.mode === 'linked' && live.bridge !== undefined
470}
471
472async function askSlapMac($: EngineInterface): Promise<SlapMacLink> {
473 const bridge = await findBridge($)
474 if (bridge !== undefined) {
475 const result = await $.process.run([bridge, 'status']).catch(() => undefined)
476 // Gone since it was found: look again next time.
477 if (result === undefined) live.bridge = undefined
478 const link = result === undefined ? undefined : linkFromStatus(result.stdout)
479 if (link !== undefined) return link
480 }
481 const isRunning = await $.process.run(['/usr/bin/pgrep', '-x', SLAPMAC_PROCESS]).then(
482 result => result.exitCode === 0,
483 () => false,
484 )
485
486 return isRunning ? { mode: 'legacy' } : NO_SLAPMAC
487}
488
489/** Finds SlapMac's bridge: the app folders first, then Spotlight, every few minutes until it turns up. */
490async function findBridge($: EngineInterface): Promise<string | undefined> {
491 if (live.isBridgeBroken) return undefined
492 if (live.bridge !== undefined) return live.bridge
493 const now = await $.clock.now()
494 if (now - live.bridgeSearchedAt < BRIDGE_SEARCH_MS) return undefined
495 live.bridgeSearchedAt = now
496 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
497 for (const spotlight of [false, true]) {
498 const found = spotlight
499 ? await $.process.run(['/usr/bin/mdfind', SLAPMAC_QUERY]).then(
500 result => result.stdout,
501 () => '',
502 )
503 : ''
504 for (const path of bridgeCandidates(home, found)) {
505 if (await $.fs.exists(path).catch(() => false)) {
506 live.bridge = path
507 return path
508 }
509 }
510 }
511
512 return undefined
513}
514
515export async function openPane($: EngineInterface, page: 'meter' | 'settings'): Promise<CommandRunResult> {
516 await setView($, page)
517 // The settings page takes the keyboard, so Tab walks its buttons and Esc goes back to the prompt.
518 const opened = await $.ui.open({
519 id: PANE,
520 title: 'slapmod',
521 ...(page === 'settings' ? { rows: SETTINGS_ROWS, focus: true } : {}),
522 })
523 live.isPaneOpen = true
524
525 return opened.isPlaced ? {} : { text: `The slapmod pane is waiting for room (${opened.reason}).` }
526}
527
528export async function runSlapCommand($: EngineInterface, args: string): Promise<CommandRunResult> {
529 const parsed = parseSlapArgs(args)
530 switch (parsed.kind) {
531 case 'toggle':
532 if (live.isPaneOpen) {
533 await $.ui.close({ id: PANE })
534 return {}
535 }
536 return openPane($, 'meter')
537 case 'settings':
538 return openPane($, 'settings')
539 case 'status':
540 return { text: describeSettings(settings, live.link) }
541 case 'test':
542 return { text: (await playTest($)).text }
543 case 'error':
544 return { text: parsed.message }
545 case 'set': {
546 const refusal = await writeSettings($, parsed.writes)
547 if (refusal !== undefined) return { text: refusal }
548 const takenOver = parsed.writes.map(write => SLAPMAC_FIELDS[write.field]).find(name => name !== undefined)
549 if (live.link.mode === 'linked' && takenOver !== undefined) {
550 return { text: `Saved. SlapMac sets ${takenOver} while it's listening, so this applies without it.` }
551 }
552 return { text: 'Saved.' }
553 }
554 }
555}
556hooks/helper.ts 199 lines1// The motion helper's life: started with the session at this load's
2// threshold, read line by line, and gone with the module. While SlapMac
3// listens, its bridge runs in the helper's place and SlapMac does the
4// detecting and the sounds; when it stops, the helper takes over again.
5
6import type { EngineInterface } from 'claude-code'
7
8import { BRIDGE_PROTOCOL, NO_SLAPMAC, levelToThreshold, lineSplitter, parseEvent } from './format'
9import type { HelperEvent } from './format'
10import { HELPER, PANE, live, pushLevel, raiseTotal, setSensor, settings } from './state'
11import {
12 TOTAL_KEY,
13 checkSlapMac,
14 findConfigKeys,
15 flushSettings,
16 isBridged,
17 prepareVoices,
18 reconcileDial,
19 setLink,
20 slapped,
21} from './actions'
22
23const LEVEL_HZ = 10
24const RESTART_DELAYS_MS = [1_000, 5_000, 30_000]
25const STEADY_RUN_MS = 60_000
26const SLAPMAC_CHECK_MS = 15_000
27const THRESHOLD_DIGITS = 3
28/** A bridge that ends this many times in a row before it's ready is left alone for this load. */
29const MAX_BRIDGE_FAILURES = 3
30
31type Ended = 'exited' | 'unstartable' | 'unsupported' | 'switched'
32/** How a bridge stream ended: SlapMac's reason when it gave one. */
33type BridgeEnd = { isReady: boolean; isBroken: boolean; reason: string | undefined }
34
35function wait($: EngineInterface, ms: number): Promise<void> {
36 return new Promise(resolve => {
37 $.clock.after(ms, resolve)
38 })
39}
40
41async function handle($: EngineInterface, event: HelperEvent): Promise<void> {
42 switch (event.type) {
43 case 'ready':
44 await setSensor($, { status: 'ready', model: event.model })
45 if (event.voices !== undefined && event.threshold !== undefined) {
46 await setLink($, { mode: 'linked', voices: event.voices, threshold: event.threshold })
47 }
48 return
49 case 'settings':
50 await setLink($, { mode: 'linked', voices: event.voices, threshold: event.threshold })
51 return
52 case 'leader':
53 live.isSoundLeader = true
54 return
55 case 'level':
56 if (live.isPaneOpen) await pushLevel($, event.g)
57 return
58 case 'slap':
59 await slapped($, event.g, event.strength)
60 return
61 case 'error':
62 await setSensor($, { status: 'stopped', reason: event.message })
63 if (event.code === 'no-sensor') $.ui.toast(`slapmod: ${event.message}`)
64 return
65 }
66}
67
68async function runHelper($: EngineInterface): Promise<Ended> {
69 await setSensor($, { status: 'starting' })
70 const child = $.process.spawn({
71 argv: [
72 `${$.plugin.root}/${HELPER}`,
73 '--threshold',
74 levelToThreshold(settings.level).toFixed(THRESHOLD_DIGITS),
75 '--level-hz',
76 String(LEVEL_HZ),
77 ],
78 })
79 const lines = lineSplitter()
80 let hasStarted = false
81 let ended: Ended = 'exited'
82 try {
83 for await (const { stream, text } of child) {
84 hasStarted = true
85 // SlapMac started listening: leaving the loop ends the helper, and its bridge takes over.
86 if (isBridged()) {
87 ended = 'switched'
88 break
89 }
90 if (stream !== 'stdout') continue
91 for (const line of lines(text)) {
92 const event = parseEvent(line)
93 if (event === undefined) continue
94 if (event.type === 'error' && event.code === 'no-sensor') ended = 'unsupported'
95 await handle($, event)
96 }
97 }
98 } catch {
99 if (!hasStarted) return 'unstartable'
100 }
101 live.isSoundLeader = false
102
103 return ended
104}
105
106/** Streams SlapMac's slaps through its bridge until SlapMac stops listening or quits. */
107async function runBridge($: EngineInterface, bridge: string): Promise<BridgeEnd> {
108 await setSensor($, { status: 'starting' })
109 const child = $.process.spawn({ argv: [bridge, 'stream', '--level-hz', String(LEVEL_HZ)] })
110 const lines = lineSplitter()
111 const end: BridgeEnd = { isReady: false, isBroken: false, reason: undefined }
112 try {
113 stream: for await (const { stream, text } of child) {
114 if (stream !== 'stdout') continue
115 for (const line of lines(text)) {
116 const event = parseEvent(line)
117 if (event === undefined) continue
118 if ((event as { protocol?: unknown }).protocol !== BRIDGE_PROTOCOL) {
119 // A SlapMac newer than this mod: its lines can't be trusted to mean the same.
120 end.isBroken = true
121 break stream
122 }
123 if (event.type === 'error') {
124 end.reason = event.message
125 continue
126 }
127 if (event.type === 'ready') end.isReady = true
128 await handle($, event)
129 }
130 }
131 } catch {
132 // It couldn't start: moved or deleted since it was found, so look for it again.
133 if (!end.isReady) live.bridge = undefined
134 }
135 live.isSoundLeader = false
136
137 return end
138}
139
140// Keeps the helper, or SlapMac's bridge, running for the session; it dies with the module.
141async function watch($: EngineInterface): Promise<void> {
142 let failures = 0
143 let bridgeFailures = 0
144 let hasFixedMode = false
145 await checkSlapMac($)
146 for (;;) {
147 if (isBridged() && live.bridge !== undefined) {
148 const end = await runBridge($, live.bridge)
149 bridgeFailures = end.isReady ? 0 : bridgeFailures + 1
150 if (end.isBroken || bridgeFailures >= MAX_BRIDGE_FAILURES) live.isBridgeBroken = true
151 // Back to the mod's own helper and sounds until SlapMac listens again.
152 await setLink($, end.reason === undefined || live.isBridgeBroken ? NO_SLAPMAC : { mode: 'idle', reason: end.reason })
153 if (live.isBridgeBroken) void checkSlapMac($)
154 continue
155 }
156 const startedAt = await $.clock.now()
157 const ended = await runHelper($)
158 if (ended === 'switched') continue
159 if (ended === 'unsupported') return
160 if (ended === 'unstartable') {
161 // An install can drop the executable bit; put it back once.
162 if (!hasFixedMode) {
163 hasFixedMode = true
164 await $.process.run(['/bin/chmod', '+x', `${$.plugin.root}/${HELPER}`]).catch(() => undefined)
165 continue
166 }
167 await setSensor($, {
168 status: 'stopped',
169 reason: "Couldn't start the motion helper. slapmod needs an Apple Silicon MacBook.",
170 })
171 return
172 }
173 if ((await $.clock.now()) - startedAt > STEADY_RUN_MS) failures = 0
174 const delay = RESTART_DELAYS_MS[failures]
175 failures += 1
176 if (delay === undefined) {
177 await setSensor($, { status: 'stopped', reason: 'The motion helper keeps stopping.' })
178 return
179 }
180 await wait($, delay)
181 }
182}
183
184/** Everything a load does once the session is up, a reload's included. */
185export async function startSession($: EngineInterface): Promise<void> {
186 live.isPaneOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
187 live.sessionId = await $.session.id().catch(() => '')
188 const stored = Number((await $.store.get(TOTAL_KEY)) ?? 0)
189 await raiseTotal($, stored)
190 await findConfigKeys($)
191 await reconcileDial($)
192 void watch($)
193 void prepareVoices($)
194 $.clock.every(SLAPMAC_CHECK_MS, () => void checkSlapMac($))
195 // Settings a reload cut short are finished now; this may reload again.
196 const refusal = await flushSettings($)
197 if (refusal !== undefined) $.ui.toast(`slapmod: ${refusal}`)
198}
199hooks/pane.tsx 190 lines1// The pane: the live meter, and the settings page that tunes it.
2
3import type { EngineInterface, RenderInput } from 'claude-code'
4
5import {
6 ACTIONS,
7 GESTURE_FIELDS,
8 MAX_LEVEL,
9 SOUNDS,
10 actionText,
11 dial as dialCells,
12 levelToThreshold,
13 meter,
14 plural,
15 slapMacNote,
16 sparkline,
17 strengthOf,
18 thresholdOf,
19} from './format'
20import type { Gesture } from './format'
21import {
22 PANE_PADDING,
23 readDial,
24 readJolt,
25 readLast,
26 readLevels,
27 readSensor,
28 readSlapMac,
29 readSlaps,
30 readTotal,
31 readView,
32 setView,
33 settings,
34} from './state'
35import { nudgeDial, saveSetting, saveText, testSound } from './actions'
36
37const METER_LABEL_WIDTH = 8
38const MIN_WIDTH = 8
39const GESTURE_LABELS: Record<Gesture, string> = { single: 'Slap', double: 'Double slap' }
40const TEXT_HINTS: Record<'command' | 'prompt', string> = {
41 command: '/compact',
42 prompt: 'Step back and try a different approach.',
43}
44
45export async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
46 return (await readView($)) === 'settings' ? renderSettings($, e) : renderMeter($, e)
47}
48
49async function renderMeter($: EngineInterface, e: RenderInput<'Pane'>) {
50 const { Box, Button, Text } = $.ui.resolve(e)
51 const state = await readSensor($)
52 const history = await readLevels($)
53 const count = await readSlaps($)
54 const all = await readTotal($)
55 const latest = await readLast($)
56 const link = await readSlapMac($)
57 const note = slapMacNote(link)
58 const offset = await readJolt($)
59 const width = Math.max(MIN_WIDTH, e.props.bodyColumns - offset - PANE_PADDING)
60 const level = history.at(-1) ?? 0
61 const threshold = thresholdOf(settings, link)
62
63 return (
64 <Box flexDirection="column" paddingLeft={offset}>
65 {state.status === 'starting' && <Text dimColor>Waking the motion sensor...</Text>}
66 {state.status === 'stopped' && <Text color="warning">{state.reason}</Text>}
67 {state.status === 'ready' && (
68 <Text color="claude">{history.length > 0 ? sparkline(history, width) : 'Listening...'}</Text>
69 )}
70 {state.status === 'ready' && (
71 <Text>
72 {meter(level, width - METER_LABEL_WIDTH, threshold)} {level.toFixed(2)} g
73 </Text>
74 )}
75 <Box gap={1}>
76 <Text bold>{plural(count, 'slap', 'slaps')} this session</Text>
77 <Text dimColor>({all} all time)</Text>
78 </Box>
79 {latest !== null && (
80 <Text dimColor>
81 Last one: {strengthOf(latest.strength)}, {latest.g.toFixed(2)} g
82 </Text>
83 )}
84 <Text dimColor>A slap {actionText(settings.onSlap, settings.slapText)}</Text>
85 <Text dimColor>A double slap {actionText(settings.onDoubleSlap, settings.doubleSlapText)}</Text>
86 {note !== undefined && <Text dimColor>{note}</Text>}
87 <Box marginTop={1} gap={1}>
88 <Button key="settings" label="Settings" onPress={() => void setView($, 'settings')} />
89 <Button key="test" label="Test sound" onPress={() => void testSound($)} />
90 </Box>
91 </Box>
92 )
93}
94
95async function renderSettings($: EngineInterface, e: RenderInput<'Pane'>) {
96 const { Box, Button, Text } = $.ui.resolve(e)
97 // The mobile app draws no text field yet.
98 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
99 const draft = await readDial($)
100 const history = await readLevels($)
101 const link = await readSlapMac($)
102 const level = draft ?? settings.level
103 const threshold = link.mode === 'linked' ? link.threshold : levelToThreshold(level)
104 const g = history.at(-1) ?? 0
105 const width = Math.max(MIN_WIDTH, e.props.bodyColumns - PANE_PADDING)
106
107 function gestureRows(which: Gesture) {
108 const fields = GESTURE_FIELDS[which]
109 const action = settings[fields.action]
110 const text = settings[fields.text]
111 const takesText = action === 'command' || action === 'prompt'
112
113 return (
114 <Box flexDirection="column" marginTop={1}>
115 <Text bold>
116 {GESTURE_LABELS[which]}: {actionText(action, text)}
117 </Text>
118 <Box flexWrap="wrap" columnGap={1}>
119 {ACTIONS.map(one => (
120 <Button
121 key={`${which}-${one}`}
122 label={one}
123 variant={one === action ? 'primary' : 'secondary'}
124 onPress={() => void saveSetting($, fields.action, one)}
125 />
126 ))}
127 </Box>
128 {takesText && Input !== undefined && (
129 <Input
130 key={fields.text}
131 label={action === 'command' ? 'Command' : 'Prompt'}
132 placeholder={text === '' ? TEXT_HINTS[action] : text}
133 submitLabel="Save"
134 onSubmit={value => void saveText($, which, value)}
135 />
136 )}
137 {takesText && Input === undefined && <Text dimColor>Change the text in /config.</Text>}
138 </Box>
139 )
140 }
141
142 return (
143 <Box flexDirection="column" paddingLeft={PANE_PADDING}>
144 <Text bold>Sensitivity</Text>
145 {link.mode === 'linked' ? (
146 <Text dimColor>Set in SlapMac while it's listening ({threshold.toFixed(3)} g).</Text>
147 ) : (
148 <Box gap={1}>
149 <Button key="sensitivity-down" label="−" dimColor={level <= 1} onPress={() => void nudgeDial($, -1)} />
150 <Text>
151 {dialCells(level)} {level}
152 </Text>
153 <Button key="sensitivity-up" label="+" dimColor={level >= MAX_LEVEL} onPress={() => void nudgeDial($, 1)} />
154 <Text dimColor>{threshold.toFixed(3)} g</Text>
155 </Box>
156 )}
157 <Text color="claude">
158 {meter(g, width - METER_LABEL_WIDTH, threshold)} {g.toFixed(2)} g
159 </Text>
160 <Text dimColor>Tap the desk: a shake past the ┃ counts as a slap.</Text>
161 {gestureRows('single')}
162 {gestureRows('double')}
163 <Box flexDirection="column" marginTop={1}>
164 {link.mode === 'linked' ? (
165 <>
166 <Text bold>Sound: SlapMac's {link.voices} pack</Text>
167 <Text dimColor>Pick packs in SlapMac. Without SlapMac: {settings.sound}</Text>
168 </>
169 ) : (
170 <Text bold>Sound: {settings.sound}</Text>
171 )}
172 <Box columnGap={1}>
173 {SOUNDS.map(one => (
174 <Button
175 key={`sound-${one}`}
176 label={one}
177 variant={one === settings.sound ? 'primary' : 'secondary'}
178 onPress={() => void saveSetting($, 'sound', one)}
179 />
180 ))}
181 </Box>
182 </Box>
183 <Box marginTop={1} gap={1}>
184 <Button key="test" label="Test sound" onPress={() => void testSound($)} />
185 <Button key="back" label="Back" onPress={() => void setView($, 'meter')} />
186 </Box>
187 </Box>
188 )
189}
190types/index.d.ts 47 lines1/** Where the motion helper stands. */
2export type Sensor =
3 | { status: 'starting' }
4 | { status: 'ready'; model: string }
5 | { status: 'stopped'; reason: string }
6
7/** The latest slap: its peak in g and its strength from 0 to 1. */
8export type LastSlap = { g: number; strength: number }
9
10/**
11 * Where SlapMac stands. none: not installed or not running. legacy: an older
12 * SlapMac without the bridge runs, plays its own sounds, and the mod stays
13 * quiet. idle: SlapMac runs but won't take slaps (paused, Slap Mode off, not
14 * activated), so the mod plays. linked: SlapMac detects the slaps and plays
15 * `voices`, its selected pack, and `threshold` is its smallest slap in g.
16 */
17export type SlapMacLink =
18 | { mode: 'none' }
19 | { mode: 'legacy' }
20 | { mode: 'idle'; reason: string }
21 | { mode: 'linked'; voices: string; threshold: number }
22
23/** Slaps Claude has not heard about yet. */
24export type PendingSlaps = { count: number; hardest: number; didStop: boolean }
25
26declare module 'claude-code' {
27 interface PluginState {
28 slapmod: {
29 sensor: Sensor
30 levels: number[]
31 slaps: number
32 total: number
33 last: LastSlap | null
34 /** Whether SlapMac detects and voices the slaps, or this mod does. */
35 slapMac: SlapMacLink
36 jolt: number
37 /** Which page the pane shows. */
38 view: 'meter' | 'settings'
39 /** The sensitivity the dial shows before it is saved; null once it is. */
40 dial: number | null
41 /** The main loop's running turn, which a slap can stop. */
42 runningTurn: string | null
43 pending: PendingSlaps
44 }
45 }
46}
47