SLOPSHOPPER

Steering Wheel

Steer Claude mid-turn with one chord. /steering-wheel sets Ctrl+X Ctrl+X to send a waiting message right away, and the hint under the prompt says so.

spinnercommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · steering-wheel
› 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 › /steering-wheel ⎿ steering-wheel: 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

Steering Wheel

Steer Claude mid-turn with one chord.

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

What it does

  • /steering-wheel 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

Versions 0.1.1 and before used F5. Claude Code does not take function keys, so F5 did nothing. Version 0.1.0 used Alt+Enter, which Windows Terminal keeps for full screen. Ctrl+X Ctrl+X works in any terminal.

If an older version left F5 in your file, or switched Ctrl+X Ctrl+S off, /steering-wheel 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 Steering Wheel writes there only when you run /steering-wheel.
  • Esc stays the stop button.

MIT licensed. Made by Rostech.

More mods

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

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