SLOPSHOPPER

notify

Tells you when Claude needs you, finishes a long turn, nears a rate limit, finishes a background task or is still working: as a desktop notification (clicking…

newguardcommandtoastpromptmodel
v0.2.0no licenseupdated 2026-10-22whilestevego/claude-mods/notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notify
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ notify │ ⏺ Read(src/auth.ts) │ notify: run /notify test to check desktop │ ⎿ Read 6 lines │ notifications show up │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /notify ⎿ notify: /notify test send a desktop notification now, to check they show up ⎿ notify: /notify settings open your terminal's notification settings ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code: small plugins that add to its screen and behavior. Each folder is one mod. Turn on the ones you want (see Setup) and change their settings in /config, under the mod's name.

Each mod works on its own, and a few work better together. notify tells you when something happens; with say it can tell you out loud, with tldr its turn summaries reuse tldr's line instead of writing their own, and with ghostty it stays quiet while you're looking at the session. In Ghostty, clicking its notification brings you back to the session's tab.

The mods

context-bar

Shows how full Claude's context window is, as a colored bar above the prompt.

  • Point at the bar to see what's using the space (system prompt, messages, tools…).
  • /context-bar hides or shows it for this session.

rate-limits

Shows how much of your 5-hour and weekly usage limits you've used, as two small meters under the context bar. They turn yellow at 70% and red at 90%, and you get a warning inside Claude once a limit passes 90%. (notify also warns, as a desktop notification or out loud, at a threshold you set.)

  • Nothing to do: it appears on its own when you're on a subscription plan.

session-id

Shows this session's ID in the bottom-right corner of the screen.

  • Click it to copy the ID.
  • Ctrl-click it to copy the full claude --resume <id> command.

turn-receipt

Adds a receipt to the line that closes each turn ("✻ Baked for 42s"): how many tools ran, which files were edited, the tokens used and what the turn cost.

  • Nothing to do: it appears after every turn.

tldr

Adds a one-sentence summary under Claude's longer replies, written by Claude Haiku.

  • Nothing to do. TL;DR: long reply in /config sets how long a reply must be (600 characters by default).
  • With notify on, the same line is its summary when a long turn finishes, at no extra cost.

notify

Tells you when something happens in a session: as a desktop notification, out loud through say, or both. With the ghostty mod on, it only tells you when you're away from the session (another app or tab in front), so it never interrupts you while you're watching. Without it, nothing can tell whether you're looking, so it always tells you.

  • What it tells you about: Claude needs your permission or has a question, or a turn stopped on an API error (like a lost connection); a long turn finished, with a one-line summary of what it did; you're close to a usage limit; a background task finished; and every few minutes, how a long turn is going.
  • Where each one goes: each event has its own setting in /config under notify: both, banner (the desktop notification), voice (spoken by say) or off. By default the first three are both, and background tasks and check-ins are voice.
  • Clicking a notification in Ghostty brings you to the tab of the session that sent it. Each one is titled with the session's folder, so you can tell sessions apart.
  • Without say, voice alerts are skipped and the banners still go out.
CommandWhat it does
/notify testSends a desktop notification right away. macOS can't tell a mod whether your terminal may show notifications, so this is how to check. The first time notify starts, it reminds you once.
/notify settingsOpens the switch in System Settings: Ghostty's own entry in Ghostty, the Notifications list elsewhere.

say

Gives Claude a voice, through murmur. The notify mod decides what gets said and when; say is the voice it speaks in.

  • A soft chime plays before it speaks, so a voice never starts out of nowhere. Turn it off with Chime in /config.
  • Each open session gets its own voice: the best one no other session has, by Kokoro's grades (Heart, then Bella, Nicole, Emma…). Pick one fixed voice, or your own list, in /config under say.

Everything else is one command, /say:

CommandWhat it does
/sayNames this session's voice and plays a short sample of it.
/say pickLists every voice with its grade and switches this session to the one you pick, with a sample in it. The choice sticks to the conversation, so resuming it keeps the voice.
/say pick <name>Switches directly, like /say pick emma. Part of the name is enough.
/say pick randomSwitches to a random voice, other than the current one. Better-graded voices come up more often.
/say readReads Claude's last reply aloud.
/say tldrA spoken 2–3 sentence summary of the session so far.
/say hushMutes this session; run it again to unmute.
/say helpLists these.

ghostty

Ties Claude into the Ghostty terminal. All its settings start with "Ghostty" in /config.

  • Tab title: a 4-word summary of what the session is about, from your last 10 messages and Claude's latest reply, updated every few minutes as it goes. /title gets a new one right away. A resumed session gets its old title back.
  • Tab progress bar: shows while Claude works, and turns red when a command fails.
  • ❓ waiting on you: a ❓ in the tab title when Claude needs a permission or an answer. The notify mod sends the notification.
  • /workspace saves and reopens whole windows of sessions: see Workspaces.
  • /goto lists your open Claude sessions, the ones waiting on you first, and jumps to the one you pick. /goto next jumps straight to the one waiting longest; /goto <words> jumps to the one whose title matches.
  • /keybind add <keys> <text> makes a Ghostty key type something into Claude, like /keybind add super+ctrl+h /say hush for ⌘⌃H. /keybind lists them, and /keybind remove <keys> removes one. For ⌘⌃G to open /goto, run /keybind add super+ctrl+g /goto, or turn on Ghostty: install Claude keybinds in /config. The mod changes your Ghostty config only when you do one of these: it adds one line that loads its own claude-keybinds file.
Workspaces

A workspace is a Ghostty window of Claude sessions you can put away and bring back later. It remembers every tab and split: each Claude session with its conversation, and each shell with its folder. Reopening it brings back the whole window, with every conversation picking up where it left off.

CommandWhat it does
/workspace save [name]Saves this window. With no name, it keeps the name the window already had, or Claude Haiku picks one from the tabs.
/workspace close [name]Saves this window, then exits every Claude session in it and closes their tabs. Your current tab stays open at the shell, and shell tabs stay open. If another session is still working or waiting on you, it closes nothing and names them; /workspace close --force closes them anyway.
/workspace listShows your saved workspaces, newest first. Press a number or Enter to reopen one, or Tab to ✕ delete to remove it.
/workspace open <name>Reopens a saved workspace in a new window. Part of the name is enough.
  • Reopening puts each tab back in its folder. Claude tabs run claude --resume, so they continue their own conversation, with the tab title they had when you saved. A tab saved without a title gets a new one from its conversation. Shell tabs open at the prompt.
  • Put away a project for the day: /workspace close. Pick it up tomorrow: /workspace list, then press its number.
  • What doesn't come back: programs that were running (a dev server, a test watcher), and the exact split layout. Splits reopen side by side.
  • Where they're kept: ~/.claude/workspaces/, one JSON file per workspace. Saving never overwrites a different workspace that happens to have the same name: the new one gets a file of its own.

Setup

Install the mods you want, one line each, at the prompt of a Claude Code session:

/plugin install context-bar --marketplace whilestevego/claude-mods

Answer y to add the marketplace, then pick a scope. The mods are context-bar, rate-limits, session-id, turn-receipt, tldr, notify, say and ghostty.

From a clone, while working on them

  1. Clone this repo.
  2. List the mods you want in CLAUDE_CODE_PLUGIN_DIRS, separated by :, in the env block of ~/.claude/settings.json:
   "env": {
     "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/context-bar:/path/to/claude-mods/say"
   }
  1. Start a new Claude session.

To try one mod for a single session: claude --plugin-dir /path/to/claude-mods/tldr.

Requirements

  • ghostty needs Ghostty 1.3 or newer on macOS. Set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 in the same env block, so Claude Code doesn't overwrite the tab title.
  • say needs murmur installed, which runs on Apple Silicon Macs.
  • notify's desktop notifications need notifications allowed for your terminal, in System Settings → Notifications (Ghostty, say). /notify test checks; /notify settings opens the page.
  • session-id's pointing-hand cursor works in Ghostty, Kitty and foot.

The first time a mod controls Ghostty, macOS asks for permission. Allow it.

Development

Editing a mod's files reloads it in running sessions. A mod newly added to CLAUDE_CODE_PLUGIN_DIRS loads only in sessions started after that.

claude plugin validate context-bar   # check a mod the way Claude Code will load it
claude plugin test context-bar       # run its tests

Mods can call each other through nouns on $: say adds $.voice.say({ text }), which notify speaks through, and ghostty adds $.ghostty.isFocused(), which notify asks before alerting. Their types are in each mod's types/index.d.ts.

IDEAS.md has ideas for more mods.

Source 2 files
hooks/register.ts 271 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { channel, checkinLine, errorLine, firstSentence, prose, rateLimitLine, summaryPrompt, taskLine } from './lines'
4import type { Channel } from './lines'
5// The contracts this mod reads: tldr's line for a turn, say's voice, and ghostty's focus. A renamed key there fails
6// type-checking here.
7import type {} from '../../tldr/types'
8import type {} from '../../say/types'
9import type {} from '../../ghostty/types'
10
11type Config = {
12  when: string
13  needsYou: Channel
14  turnDone: Channel
15  rateLimit: Channel
16  backgroundTasks: Channel
17  checkins: Channel
18  summaryStyle: string
19  minTurnSeconds: number
20  checkinMinutes: number
21  rateLimitPercent: number
22}
23
24function config(o: PluginOptions): Config {
25  const num = (v: unknown, d: number) => (typeof v === 'number' && v > 0 ? v : d)
26  return {
27    when: o.when === 'always' ? 'always' : 'away',
28    needsYou: channel(o.needsYou, 'both'),
29    turnDone: channel(o.turnDone, 'both'),
30    rateLimit: channel(o.rateLimit, 'both'),
31    backgroundTasks: channel(o.backgroundTasks, 'voice'),
32    checkins: channel(o.checkins, 'voice'),
33    summaryStyle: o.summaryStyle === 'first-sentence' ? 'first-sentence' : 'haiku',
34    minTurnSeconds: num(o.minTurnSeconds, 20),
35    checkinMinutes: num(o.checkinMinutes, 5),
36    rateLimitPercent: num(o.rateLimitPercent, 90),
37  }
38}
39
40/** Rate-limit windows already warned about, as "kind@resetsAt": in $.store, so one warning covers every session. */
41const WARNED = 'warnedLimits'
42/** Set once the /notify test hint has been shown: in $.store, so it shows once, not once per session. */
43const HINTED = 'hintedTest'
44
45/** System Settings' Notifications page: Ghostty's own entry in Ghostty, the whole list elsewhere. */
46const SETTINGS = 'x-apple.systempreferences:com.apple.Notifications-Settings.extension'
47const GHOSTTY_ID = 'com.mitchellh.ghostty'
48
49const HELP = [
50  '/notify test      send a desktop notification now, to check they show up',
51  "/notify settings  open your terminal's notification settings",
52].join('\n')
53
54// The module's own session state; a reload starts it over.
55let cfg: Config
56/** Claude's own process: its session record says when a permission dialog is up. */
57let pid = ''
58let home = ''
59let inGhostty = false
60/** The session's folder name, heading its banners so you can tell sessions apart. */
61let folder = ''
62let lastRequest = ''
63let checkin: { cancel(): void } | undefined
64let turnStartedAt = 0
65/** Calls the permission check put to you, until they run or are refused. */
66const asking = new Set<string>()
67
68// The pid of Claude itself: the nearest ancestor named claude (the mods' own host process sits between it and this
69// shell, and changes when mods reload). No API names it.
70const FIND_CLAUDE = `p=$$
71while [ "$p" -gt 1 ]; do
72  c=$(ps -o comm= -p "$p"); [ "\${c##*/}" = claude ] && { echo "$p"; exit; }
73  p=$(ps -o ppid= -p "$p" | tr -d ' ')
74done`
75
76async function locate($: EngineInterface) {
77  inGhostty = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase() === 'ghostty'
78  home = (await $.env.get('HOME')) ?? ''
79  pid = (await $.process.run(['sh', '-c', FIND_CLAUDE])).stdout.trim()
80}
81
82// Quiet by default: alert only when this session isn't the one in front of you. The ghostty mod knows; without it
83// nothing can tell, so every alert goes out.
84async function isAway($: EngineInterface): Promise<boolean> {
85  if (cfg.when === 'always') return true
86  try {
87    return !(await $.ghostty.isFocused())
88  } catch {
89    return true
90  }
91}
92
93/**
94 * One alert, on the channels its setting names. The banner goes through Claude Code's notification channel; in
95 * Ghostty a click on it focuses the tab that raised it. The voice is the say mod's, when it's loaded.
96 */
97async function alert($: EngineInterface, to: Channel, text: string) {
98  if (to === 'off' || !text.trim() || !(await isAway($))) return
99  await Promise.all([to !== 'voice' && $.ui.notify(text, { title: title() }).catch(() => null), to !== 'banner' && speak($, text)])
100}
101
102/** Says `text` in this session's voice. Without the say mod there's no $.voice, and the line goes unsaid. */
103async function speak($: EngineInterface, text: string) {
104  try {
105    await $.voice.say({ text })
106  } catch {
107    // say isn't loaded, or murmur failed
108  }
109}
110
111/** Claude Code's record of this session (~/.claude/sessions/<pid>.json) says its permission dialog is up. */
112function asksPermission(record: string): boolean {
113  try {
114    const r = JSON.parse(record) as { status?: unknown; waitingFor?: unknown }
115    return r.status === 'waiting' && typeof r.waitingFor === 'string' && /permission/i.test(r.waitingFor)
116  } catch {
117    return false
118  }
119}
120
121/**
122 * A call that asks waits in Claude Code's dialog, or in auto mode its checker. Only the dialog marks the session
123 * record waiting, so watch it while the call is pending. Mods hear neither classic.PermissionRequest nor
124 * classic.Notification while a dialog is up (checked on 2.1.301), so this file is the only sign.
125 */
126async function watchForDialog($: EngineInterface, id: string, tool: string) {
127  if (!pid || !home) return
128  asking.add(id)
129  // ponytail: polls every half second, 10 minutes at most; an event for the dialog would replace it.
130  for (let i = 0; i < 1200; i++) {
131    await $.clock.sleep(500)
132    if (!asking.has(id)) return
133    if (asksPermission(await $.fs.read(`${home}/.claude/sessions/${pid}.json`).catch(() => ''))) {
134      return alert($, cfg.needsYou, `Claude needs your permission to use ${tool}.`)
135    }
136  }
137}
138
139/** The tldr mod's line for this turn, when tldr is enabled: one summary, shown and told. Empty when it has none. */
140async function tldrLine($: EngineInterface, turnId: string): Promise<string> {
141  const rows = await $.config.list().catch(() => [])
142  if (!rows.some(r => r.key.startsWith('tldr.'))) return ''
143  // tldr publishes after its own Haiku call (8 s at most); a short reply publishes an empty line at once.
144  for (let waited = 0; waited <= 10_000; waited += 250) {
145    const held = await $.state.get({ plugin: 'tldr', key: 'line' })
146    if (held.value?.turnId === turnId) return held.value.text ?? ''
147    await $.clock.sleep(250)
148  }
149  return ''
150}
151
152async function summarize($: EngineInterface, turnId: string, request: string, answer: string): Promise<string> {
153  if (cfg.summaryStyle === 'first-sentence') return firstSentence(answer)
154  const shared = await tldrLine($, turnId)
155  if (shared) return shared
156  const r = await $.model.complete({ model: 'haiku', prompt: summaryPrompt(request, answer), maxTokens: 60 })
157  const line = r.isAnswered ? prose(r.text).replace(/\s+/g, ' ').trim() : ''
158  return line && line.length <= 200 ? line : firstSentence(answer)
159}
160
161/**
162 * macOS can't tell a mod whether it shows the terminal's notifications (Notification Center's prefs are undocumented
163 * and lag behind the toggle), so the reliable check is to send one and look.
164 */
165async function testBanner($: EngineInterface): Promise<{ text: string }> {
166  const sent = await $.ui.notify('This is what notify alerts look like. Click it to come back to this tab.', { title: title() })
167  if (!sent.isSent) return { text: `Couldn't send a notification (${sent.reason}).` }
168  return { text: "🔔 Sent. Didn't see a banner? /notify settings opens the switch for it." }
169}
170
171async function openSettings($: EngineInterface): Promise<{ text: string }> {
172  const r = await $.process.run(['open', inGhostty ? `${SETTINGS}?id=${GHOSTTY_ID}` : SETTINGS])
173  if (r.exitCode !== 0) return { text: `Couldn't open System Settings: ${r.stderr.trim().slice(0, 120) || `exit ${r.exitCode}`}` }
174  return { text: `Opened System Settings. Turn on Allow notifications${inGhostty ? ' for Ghostty' : ''}, then /notify test.` }
175}
176
177const title = () => (folder ? `Claude Code · ${folder}` : 'Claude Code')
178const sendsBanners = () => [cfg.needsYou, cfg.turnDone, cfg.rateLimit, cfg.backgroundTasks, cfg.checkins].some(c => c === 'both' || c === 'banner')
179
180function stopCheckins() {
181  checkin?.cancel()
182  checkin = undefined
183}
184
185export const register: Register = (on, options) => {
186  cfg = config(options)
187  // Hooks that gate a prompt or a tool call pass it on untouched if they fail: an alert must never block work.
188
189  on('session.start', async ($, e, next) => {
190    folder = e.cwd.replace(/\/+$/, '').split('/').pop() ?? ''
191    await locate($)
192    await $.command.register({ name: 'notify', description: 'Desktop notifications: /notify test to check they show up, /notify settings to turn them on' })
193    if (sendsBanners() && (await $.store.get(HINTED)) !== true) {
194      await $.store.set(HINTED, true)
195      $.ui.toast('notify: run /notify test to check desktop notifications show up')
196    }
197    return next(e)
198  })
199
200  on('command.run', { command: 'notify' }, async ($, e) => {
201    switch (e.args.trim().toLowerCase()) {
202      case 'test': return testBanner($)
203      case 'settings': return openSettings($)
204      default: return { text: HELP }
205    }
206  })
207
208  // Every turn starts here: its request, and the check-in timer.
209  on('prompt.submit', async ($, e, next) => {
210    stopCheckins()
211    turnStartedAt = Date.now()
212    if (e.origin.kind === 'task-notification') void alert($, cfg.backgroundTasks, taskLine(e.text))
213    else lastRequest = e.text
214    if (cfg.checkins !== 'off') {
215      checkin = $.clock.every(cfg.checkinMinutes * 60_000, () => {
216        void alert($, cfg.checkins, checkinLine(Math.round((Date.now() - turnStartedAt) / 60000)))
217      })
218    }
219    return next(e)
220  }).catch(($, e, next) => next(e))
221
222  on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
223    void alert($, cfg.needsYou, 'Claude has a question for you.')
224    return next(e)
225  }).catch(($, e, next) => next(e))
226
227  on('tool.check', async ($, e, next) => {
228    const r = await next(e)
229    if (cfg.needsYou !== 'off' && r.decision === 'ask' && e.tool_use_id) void watchForDialog($, e.tool_use_id, e.tool).catch(() => {})
230    return r
231  }).catch(($, e, next) => next(e))
232
233  // A call that ran or was refused is no longer waiting on you.
234  on('tool.call', async ($, e, next) => {
235    try {
236      return await next(e)
237    } finally {
238      asking.delete((e as { tool_use_id?: string }).tool_use_id ?? '')
239    }
240  }).catch(($, e, next) => next(e))
241
242  on('turn.complete', async ($, e, next) => {
243    if (e.agentId) return next(e)
244    stopCheckins()
245    // An API error ends the turn (connection lost, the computer slept): say so, in place of a summary.
246    const error = errorLine(e.reason, e.answer)
247    if (error) {
248      void alert($, cfg.needsYou, error)
249    } else if (cfg.turnDone !== 'off' && e.reason === 'answer' && e.durationMs >= cfg.minTurnSeconds * 1000) {
250      void summarize($, e.turnId, lastRequest, e.answer).then(line => alert($, cfg.turnDone, line))
251    }
252    return next(e)
253  })
254
255  // The limits are your account's, not this session's: whichever session sees one cross first tells you.
256  // ponytail: two sessions measuring in the same instant can both alert; a lock if that ever happens.
257  on('session.measure', async ($, e, next) => {
258    const crossed = cfg.rateLimit !== 'off' ? e.rateLimits.filter(w => w.percentUsed >= cfg.rateLimitPercent) : []
259    if (crossed.length) {
260      const stored = await $.store.get(WARNED)
261      const warned = Array.isArray(stored) ? (stored as string[]) : []
262      const fresh = crossed.filter(w => !warned.includes(`${w.kind}@${w.resetsAt ?? ''}`))
263      if (fresh.length) {
264        await $.store.set(WARNED, [...warned, ...fresh.map(w => `${w.kind}@${w.resetsAt ?? ''}`)].slice(-20))
265        for (const w of fresh) void alert($, cfg.rateLimit, rateLimitLine(w.kind, w.percentUsed))
266      }
267    }
268    return next(e)
269  })
270}
271
hooks/lines.ts 73 lines
1// What each alert says: turning Claude's markdown and events into one line, for a banner or a voice.
2
3/** Markdown → plain spoken prose. Code blocks become `codeAs` (dropped when empty). */
4export function prose(markdown: string, codeAs = ''): string {
5  return markdown
6    .replace(/```[\s\S]*?```/g, codeAs ? ` ${codeAs} ` : ' ')
7    .replace(/`([^`\n]*)`/g, '$1')
8    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
9    .replace(/^\s*[-*+]\s+/gm, '')
10    .replace(/^\s*\|.*\|\s*$/gm, '')
11    .replace(/[*_#>|~]/g, '')
12    .replace(/[ \t]+/g, ' ')
13    .replace(/\n{2,}/g, '\n')
14    .trim()
15}
16
17/** The first sentence when it reads well spoken, else `fallback`. */
18export function firstSentence(markdown: string, fallback = 'Done.'): string {
19  const text = prose(markdown).replace(/\s+/g, ' ')
20  const first = text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text
21  return first.length >= 3 && first.length <= 160 ? first : fallback
22}
23
24/** The prompt that turns a finished turn into one spoken line. */
25export function summaryPrompt(request: string, answer: string): string {
26  return [
27    'A coding assistant just finished a task. In ONE short sentence of at most 15 words, meant to be',
28    'spoken aloud to the developer, say what was done and whether anything needs their attention.',
29    'Plain words only: no markdown, code, file paths or quotes.',
30    '',
31    `Request: ${request.slice(0, 1500)}`,
32    `Answer: ${answer.slice(0, 3000)}`,
33  ].join('\n')
34}
35
36/** A background task's notification text → what to say. */
37export function taskLine(text: string): string {
38  const summary = text.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
39  const status = text.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
40  const what = summary ? prose(summary).replace(/\s+/g, ' ').slice(0, 140) : ''
41  if (what) return `Background task: ${what}`
42  return status === 'failed' ? 'A background task failed.' : 'A background task finished.'
43}
44
45/**
46 * A turn an API error ended, as one line ("API Error: Connection lost mid-response. …" → "Claude hit an error:
47 * Connection lost mid-response."); empty for a turn that ended well. Claude Code writes the error as the turn's text.
48 */
49export function errorLine(reason: string, answer: string): string {
50  const said = answer.trim().match(/^API Error:\s*(.*)$/s)?.[1]
51  if (said === undefined && reason !== 'error') return ''
52  const first = said ? firstSentence(said, '') : ''
53  return first ? `Claude hit an error: ${first}` : 'Claude hit an error.'
54}
55
56/** The progress check-in during a long turn. */
57export function checkinLine(minutes: number): string {
58  return `Still working, ${minutes} minute${minutes === 1 ? '' : 's'} in.`
59}
60
61export function rateLimitLine(kind: string, percent: number): string {
62  const name = ({ five_hour: 'five hour', seven_day: 'weekly', spend_limit: 'spend' } as Record<string, string>)[kind] ?? kind
63  return `Heads up: you've used ${Math.round(percent)} percent of your ${name} limit.`
64}
65
66
67/** Where an alert goes: a banner (the terminal's desktop notification), the say mod's voice, both, or nowhere. */
68export type Channel = 'both' | 'banner' | 'voice' | 'off'
69
70export function channel(option: unknown, fallback: Channel): Channel {
71  return option === 'both' || option === 'banner' || option === 'voice' || option === 'off' ? option : fallback
72}
73