SLOPSHOPPER

Horn

One chord to send now: /horn sets Ctrl+X Ctrl+X to send a waiting message right away, like a tap on the horn, and the hint under the prompt says so.

spinnercommand
v0.2.1MITupdated 2026-10-11DJPalefaceSD/rostech-mods/plugins/horn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · horn
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /horn ⎿ horn: Ctrl+X Ctrl+X now sends a waiting message right away. Ctrl+X Ctrl+S still works too. (/Users/dev/.claude/keybi ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Horn

One chord to send now. Like a tap on the horn.

When Claude is working and you have typed your next message, Claude Code holds it until the turn ends. Horn lets you send it right away. Press Ctrl+X, then Ctrl+X again. Honk.

What it does

  • /horn adds Ctrl+X Ctrl+X to your own ~/.claude/keybindings.json. That chord then sends a waiting message now.
  • Everything else in that file stays as you had it. The default chord, Ctrl+X Ctrl+S, keeps working too.
  • The hint under the prompt then reads tap Ctrl+X Ctrl+X to send now. It reads the key from your keybindings file, so if you change the key yourself, the hint follows.

Why not F5

This mod used to be called Ignition, and it used F5. Claude Code does not take function keys, so F5 did nothing. Ctrl+X Ctrl+X works in any terminal.

If an older version left F5 in your file, or switched Ctrl+X Ctrl+S off, /horn takes those two lines back out. It touches nothing else.

Notes

  • A plugin cannot bind keys by itself. Your keys live in your own keybindings file, which is why Horn writes there only when you run /horn.
  • Esc stays the stop button.

What it runs and sends

Nothing goes anywhere. Here is each thing, plainly.

What it sends, and where.

  • Nothing leaves your machine.
  • It reads USERPROFILE, or HOME, to find your home folder.
  • It reads ~/.claude/keybindings.json when a session starts and when you run /horn.
  • It writes that one file only when you run /horn, and only if the chord is not set yet or an old line needs taking out. Nothing else is written.

Which programs it runs, and why.

  • None. It runs no programs.

What it puts in the prompts it submits.

  • It submits no prompts, and changes none of yours.

What each hook does.

  • session.start: reads your keybindings file to learn your send-now key, and adds the /horn command.
  • command.run for horn: adds Ctrl+X Ctrl+X to your keybindings file, takes out the two old lines named above, and says what it did.
  • ui.render for PromptHint: rewrites the hint under the prompt to name your send-now key.

MIT licensed. Made by Rostech.

More mods

See every Rostech mod, live, at rostech.app.

Source 1 files
hooks/register.ts 132 lines
1import type { Register } from 'claude-code'
2
3// 📯 HORN — one chord to send now. Renamed from Ignition, his words, 4 Oct 2026: "i want to
4// change ignition to 'Horn' because when I bash F5 its like im honking the horn".
5//
6// Why not F5 any more: Claude Code's keybindings only take letters, digits and the named
7// special keys (https://code.claude.com/docs/en/keybindings). F5 is not one, so the binding
8// was skipped without a word. His words: "F5 never works. Tapping ESC is the only thing that
9// works". He picked Ctrl+X Ctrl+X: "can we do ctrl x ctrl x" → "seems to work". It is not a
10// default chord in the Chat context, so binding it takes nothing away.
11//
12// A mod cannot bind keys. The key lives in each person's own ~/.claude/keybindings.json,
13// so /horn writes the chord there, merged. It never switches off a default. It also takes
14// back the two lines an older version could have left behind: F5 → send now, and the
15// default Ctrl+X Ctrl+S switched off. Every other line stays as the person had it.
16// The hint names whatever key the file says now, read off the file, never typed here.
17
18const ACTION = 'chat:sendNow'
19const KEY = 'ctrl+x ctrl+x'
20const DEFAULT_CHORD = 'ctrl+x ctrl+s'
21const NAME = 'horn'
22
23type Block = { context: string; bindings: Record<string, string | null> }
24type File = { $schema?: string; $docs?: string; bindings: Block[] }
25
26// A function key: "f5", "shift+f5". Claude Code does not take these.
27export function isFunctionKey(chord: string): boolean {
28  return chord.split(' ').some(stroke => /^f\d+$/i.test(stroke.split('+').pop() ?? ''))
29}
30
31// "ctrl+x ctrl+s" → "Ctrl+X Ctrl+S", "f5" → "F5", "alt+enter" → "Alt+Enter"
32export function pretty(chord: string): string {
33  const part = (p: string): string =>
34    p.length === 1 || /^f\d+$/.test(p) ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1)
35  return chord
36    .split(' ')
37    .map(stroke => stroke.split('+').map(part).join('+'))
38    .join(' ')
39}
40
41// The key that sends now: the first one the file binds to the action that can actually fire
42// (never a function key), else the default chord unless the file switched it off.
43export function sendKey(file: File | null): string | null {
44  let defaultOff = false
45  for (const block of file?.bindings ?? []) {
46    if (block.context !== 'Chat') continue
47    for (const [key, action] of Object.entries(block.bindings)) {
48      if (action === ACTION && !isFunctionKey(key)) return key
49      if (key === DEFAULT_CHORD && action === null) defaultOff = true
50    }
51  }
52  return defaultOff ? 'ctrl+enter' : DEFAULT_CHORD
53}
54
55// The lines an older version wrote that must come back out: F5 → send now, and the default
56// chord switched off. Only those two, only in the Chat block.
57export function leftovers(file: File | null): string[] {
58  const chat = file?.bindings?.find(b => b.context === 'Chat')
59  if (!chat) return []
60  const out: string[] = []
61  if (chat.bindings.f5 === ACTION) out.push('f5')
62  if (DEFAULT_CHORD in chat.bindings && chat.bindings[DEFAULT_CHORD] === null) out.push(DEFAULT_CHORD)
63  return out
64}
65
66// "… · ctrl+x ctrl+s to send now · …" → "… · tap Ctrl+X Ctrl+X to send now · …"
67export function rewrite(hint: string, key: string): string {
68  return hint.replace(/(?:[^\s·]+\s){0,2}to send now/i, `tap ${pretty(key)} to send now`)
69}
70
71// Adds the chord to the Chat block and takes the leftovers out. Merges: every other binding
72// stays as the person had it.
73export function withKey(file: File | null): File {
74  const out: File = file
75    ? { ...file, bindings: file.bindings.map(b => ({ ...b, bindings: { ...b.bindings } })) }
76    : {
77        $schema: 'https://www.schemastore.org/claude-code-keybindings.json',
78        $docs: 'https://code.claude.com/docs/en/keybindings',
79        bindings: [],
80      }
81  let chat = out.bindings.find(b => b.context === 'Chat')
82  if (!chat) {
83    chat = { context: 'Chat', bindings: {} }
84    out.bindings.push(chat)
85  }
86  for (const key of leftovers(out)) delete chat.bindings[key]
87  chat.bindings[KEY] = ACTION
88  return out
89}
90
91export function parse(text: string | null): File | null {
92  if (text === null) return null
93  try {
94    return JSON.parse(text) as File
95  } catch {
96    return null
97  }
98}
99
100export const register: Register = on => {
101  let key: string = DEFAULT_CHORD
102
103  on('session.start', async ($, e, next) => {
104    const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
105    const path = `${home}/.claude/keybindings.json`
106    const text = (await $.fs.exists(path)) ? await $.fs.read(path) : null
107    key = sendKey(parse(text)) ?? DEFAULT_CHORD
108    await $.command.register({ name: NAME, description: 'Set Ctrl+X Ctrl+X to send a waiting message now' })
109    return next(e)
110  })
111
112  on('command.run', { command: NAME }, async ($, e) => {
113    const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
114    const path = `${home}/.claude/keybindings.json`
115    const before = parse((await $.fs.exists(path)) ? await $.fs.read(path) : null)
116    const old = leftovers(before)
117    if (sendKey(before) === KEY && old.length === 0) {
118      key = KEY
119      return { text: `Ctrl+X Ctrl+X already sends now. Nothing changed. (${path})` }
120    }
121    await $.fs.write(path, JSON.stringify(withKey(before), null, 2) + '\n')
122    key = KEY
123    const fixed = old.length ? ` Took out what an older version left: ${old.map(pretty).join(', ')}.` : ''
124    return { text: `Ctrl+X Ctrl+X now sends a waiting message right away. Ctrl+X Ctrl+S still works too.${fixed} (${path})` }
125  })
126
127  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
128    if (!/send now/i.test(e.props.hint)) return next(e)
129    return next({ ...e, props: { ...e.props, hint: rewrite(e.props.hint, key) } })
130  })
131}
132