SLOPSHOPPER

slapmod

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

newpanecommandpromptprocesstimer
v0.4.0MITupdated 2026-10-21tonnoz/slapmod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slapmod
│ ┃ slapmod ✕ › fix the failing auth test and add an audit log call │ ┃ Waking the motion sensor... │ ┃ 0 slaps this session (0 all time) ⏺ Read(src/auth.ts) │ ┃ A slap stops Claude ⎿ Read 6 lines │ ┃ A double slap sends "That's not it. Step ⏺ Update(src/auth.ts) │ ┃ back and try a different approach." ⎿ Added 2 lines, removed 1 line │ ┃ SlapMac is running, so it plays the sounds. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Settings ] [ Test sound ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /slap │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · slapmod
Waking the motion sensor... 0 slaps this session (0 all time) A slap stops Claude A double slap sends "That's not it. Step back and try a different approach." SlapMac is running, so it plays the sounds. [ Settings ] [ Test sound ]
README

slapmod

slapmod: slap your MacBook and Claude reacts

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.

Requirements

  • A MacBook: Apple Silicon, M1 Pro or newer. slapmod reads the laptop's built-in motion sensor. Desktop Macs (iMac, Mac mini, Mac Studio, Mac Pro) and Intel MacBooks don't have one, so it can't work there.
  • Claude Code, in the terminal or in the Code tab of the Claude desktop app. Mods are a Claude Code feature, so slapmod doesn't run in Claude's chat or Cowork.
  • Nothing else: no sudo and no permission prompt. The sensor is read as a normal user.

Install

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.

For AI agents: install steps

If someone gave you this repository's link and asked you to install it, do exactly this, in order:

  1. Check the Mac can run it. 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).
  2. Check Claude Code is installed with 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.
  3. Install it: 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.
  4. Don't clone the repo, build anything, or use sudo. The plugin ships its motion helper already signed and notarized.
  5. If it says slapmod is already installed, run claude plugin update slapmod@slapmod instead.
  6. A closing note that "userConfig options" are not yet set is normal. The defaults apply, and the person can change them later with /slap settings. Don't pass --config.
  7. Check it worked: claude plugin list should show slapmod@slapmod as enabled.
  8. Tell the person to run /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.

Using it

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:

  1. Ask Claude for something big, and when it starts going the wrong way, slap the laptop twice.
  2. Open /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.
  3. Bind a double slap to /compact with /slap double command /compact, and smack the laptop when the context gets long.

Settings

Type /slap settings for the settings page. It works in the terminal and in the desktop app:

  • Sensitivity: a 1 to 10 dial over a live meter that shows where a slap starts counting.
  • What a slap and a double slap do: each can stop Claude, tell Claude, run a slash command, send a prompt, or do nothing, and there's a field for the command or the prompt.
  • The sound.

Changes save straight away. Every setting is also a row in /config, and you can set them from the prompt:

CommandWhat it does
/slap sensitivity 1-101 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 statusShows how it's set up.
/slap testPlays the sound.

A few rules keep the command and prompt actions from going off by accident:

  • It takes at least a solid slap to run a command or send a prompt, so typing can't do it, even at a high sensitivity.
  • They run at most once every 5 seconds.
  • When one slap and a double slap both have actions, a single slap's command or prompt waits the 0.8 seconds to see whether a second slap is coming. Stopping Claude never waits.
  • A slap can't run /slap itself.
  • Be careful what you bind: a slap that runs /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.

What it runs, stores and sends

Everything slapmod does on your Mac, so there are no surprises:

  • Runs 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.
  • Runs the slash command or sends the prompt you set for a slap or a double slap, and only those, in the session you last typed in.
  • Looks for SlapMac when a session starts: whether /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.
  • Runs SlapMac's 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).
  • Runs 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.
  • Runs /bin/chmod +x on the helper once, only if an install dropped its executable bit and the helper couldn't start.
  • Runs /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.
  • Plays sounds through Claude Code's sound support, which uses macOS afplay. It falls back to say for a voice line that isn't ready yet.
  • Stores, in Claude Code's own plugin storage:
  • your all-time slap count;
  • which session you last typed in;
  • any setting still waiting to be saved.

It also keeps a small lock file, slapmod-sound.lock, in your user's temp folder, so only one session plays sounds.

  • Tells Claude one line per message at most, unless both actions are 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.
  • Sends nothing. slapmod makes no network requests, the motion data never leaves your Mac, and it reads nothing from your conversations.

With SlapMac

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:

  • the meter marks where SlapMac starts counting a slap, and the settings page says the sensitivity is set in SlapMac;
  • Test sound plays SlapMac's pack;
  • slapmod's own sensitivity and sound still save, and apply whenever SlapMac isn't listening.

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.

How it works

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.

Building it yourself

  • The helper: 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.
  • The mod: run 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.
  • The bonks are synthesized by 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.

Support

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.

Credits

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.

Source 7 files
hooks/register.tsx 72 lines
1import 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}
72
hooks/format.ts 429 lines
1// 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}
429
hooks/state.ts 177 lines
1// 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}
177
hooks/actions.ts 556 lines
1// 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}
556
hooks/helper.ts 199 lines
1// 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}
199
hooks/pane.tsx 190 lines
1// 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}
190
types/index.d.ts 47 lines
1/** 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