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.

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.
/horn adds Ctrl+X Ctrl+X to your own ~/.claude/keybindings.json. That chord then sends a waiting message now.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.
/horn.Nothing goes anywhere. Here is each thing, plainly.
What it sends, and where.
USERPROFILE, or HOME, to find your home folder.~/.claude/keybindings.json when a session starts and when you run /horn./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.
What it puts in the prompts it submits.
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.
See every Rostech mod, live, at rostech.app.
hooks/register.ts 132 lines1import 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