SLOPSHOPPER

ctrl-enter-newline

Makes Ctrl+Enter insert a newline in the Claude Code prompt, like the desktop app, by adding one binding to keybindings.json.

commandtoast
v0.1.1MITupdated 2026-10-18tjgazel/claude-ctrl-enter-newline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctrl-enter-newline
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ ctrl-enter-newline │ ⏺ Read(src/auth.ts) │ Ctrl+Enter now inserts a newline (added to │ ⎿ Read 6 lines │ /Users/dev/.claude/keybindings.json). │ ⏺ 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 › /ctrl-enter ⎿ ctrl-enter-newline: off: Ctrl+Enter keeps its default action (send now). ⎿ ctrl-enter-newline: If Ctrl+Enter still sends your message, your terminal does not report it apart from Enter (it needs the Kitty ⎿ ctrl-enter-newline: File: /Users/dev/.claude/keybindings.json ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ctrl-enter-newline

A Claude Code mod that makes Ctrl+Enter insert a newline in the prompt, as it does in the desktop app. Enter still sends the message.

By default Ctrl+Enter is bound to chat:sendNow and the newline key is Ctrl+J. This mod adds one binding to your keybindings.json:

{ "context": "Chat", "bindings": { "ctrl+enter": "chat:newline" } }

Claude Code reloads that file on its own, so the change shows up without a restart. chat:sendNow stays available on Ctrl+X Ctrl+S.

Install

At the prompt of a Claude Code terminal session:

/plugin install ctrl-enter-newline --marketplace tjgazel/claude-ctrl-enter-newline

Answer y to Add marketplace? and pick a scope. From a shell:

claude plugin install ctrl-enter-newline --marketplace tjgazel/claude-ctrl-enter-newline

The mod acts when a session starts. The first time it runs, a toast tells you what it changed.

What it does to your files

  • It merges the binding into keybindings.json and keeps everything else in the file. If the file does not exist it creates it.
  • Before it changes an existing file it saves a copy next to it, once: keybindings.json.ctrl-enter-newline.bak.
  • It never overrides you: if Ctrl+Enter already points to another action, or is set to null, it leaves the file alone and says so.
  • If the file is not valid JSON it leaves it alone.
  • The file is CLAUDE_CONFIG_DIR/keybindings.json when that variable is set, otherwise ~/.claude/keybindings.json (%USERPROFILE%\.claude on Windows). Tested on Windows and Linux; macOS takes the same code path (~/.claude) but is untested.

The /ctrl-enter command

CommandDoes
/ctrl-enter or /ctrl-enter statusShows whether the binding is on and which file it is in
/ctrl-enter offRemoves the ctrl+enter binding if it points to chat:newline (whoever added it), and stops the mod from touching the file again
/ctrl-enter onTurns it back on

/ctrl-enter off never removes a ctrl+enter binding that points to another action. Uninstalling the plugin does not run any code, so run /ctrl-enter off first if you want the binding removed, or delete the ctrl+enter line from keybindings.json by hand.

Your terminal has to cooperate

A terminal sees Ctrl+Enter as a different key from Enter only if it reports extended keys (for example through the Kitty keyboard protocol). Terminals that do not report them deliver Ctrl+Enter as a plain Enter, so Claude Code cannot tell them apart, and no mod or setting can change that.

To check, type a few words and press Ctrl+Enter. If you get a new line, you are set. If your message is sent, use Ctrl+J, which inserts a newline in every terminal, or switch to a terminal that reports extended keys.

Language

Messages are in English, and in Portuguese when the language setting of Claude Code is Portuguese.

Develop

claude --plugin-dir <path to this folder>
claude plugin validate .
claude plugin test .

The merge and removal logic is covered by hooks/register.test.ts.

License

MIT, see LICENSE.


Português

Mod do Claude Code que faz o Ctrl+Enter quebrar a linha no prompt, como no app desktop. O Enter continua enviando a mensagem.

Instalação

/plugin install ctrl-enter-newline --marketplace tjgazel/claude-ctrl-enter-newline

Responda y em Add marketplace? e escolha o escopo. O mod adiciona uma linha ao seu keybindings.json ("ctrl+enter": "chat:newline"), mesclando com o que já existe, guardando uma cópia do arquivo original e sem sobrescrever uma escolha sua. Use /ctrl-enter status para ver o estado e /ctrl-enter off para remover o atalho (só se ele apontar para chat:newline).

O terminal precisa informar o Ctrl+Enter como uma tecla diferente do Enter (protocolo de teclado Kitty ou similar). Se o Ctrl+Enter enviar a mensagem, use o Ctrl+J, que quebra a linha em qualquer terminal.

Source 1 files
hooks/register.ts 275 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3const KEY = 'ctrl+enter'
4const ACTION = 'chat:newline'
5const COMMAND = 'ctrl-enter'
6const SCHEMA = 'https://www.schemastore.org/claude-code-keybindings.json'
7const DOCS = 'https://code.claude.com/docs/en/keybindings'
8
9// English by default; Portuguese when the `language` setting asks for it
10export type Lang = 'en' | 'pt'
11
12const TEXT = {
13  en: {
14    added: (path: string) => `Ctrl+Enter now inserts a newline (added to ${path}).`,
15    conflict: 'Ctrl+Enter already has another action in your keybindings.json, so it was left alone.',
16    invalid: 'Your keybindings.json is not valid JSON, so it was left alone. Fix it and run /ctrl-enter on.',
17    failed: (why: string) => `Could not update keybindings.json: ${why}`,
18    noDir: 'Could not find the Claude Code config folder (set CLAUDE_CONFIG_DIR, HOME or USERPROFILE).',
19    on: 'on: Ctrl+Enter inserts a newline.',
20    off: 'off: Ctrl+Enter keeps its default action (send now).',
21    other: 'other: Ctrl+Enter is bound to another action in your file.',
22    removed: 'Removed the Ctrl+Enter binding. Ctrl+Enter is back to its default.',
23    disabled: 'Disabled: the mod will not touch keybindings.json. Run /ctrl-enter on to enable it again.',
24    hint:
25      'If Ctrl+Enter still sends your message, your terminal does not report it apart from Enter (it needs the Kitty keyboard protocol). Ctrl+J inserts a newline in any terminal.',
26    file: (path: string) => `File: ${path}`,
27  },
28  pt: {
29    added: (path: string) => `Ctrl+Enter agora quebra a linha (adicionado em ${path}).`,
30    conflict: 'O Ctrl+Enter já tem outra ação no seu keybindings.json, então não mexi.',
31    invalid: 'O seu keybindings.json não é um JSON válido, então não mexi. Corrija e rode /ctrl-enter on.',
32    failed: (why: string) => `Não consegui atualizar o keybindings.json: ${why}`,
33    noDir: 'Não encontrei a pasta de configuração do Claude Code (defina CLAUDE_CONFIG_DIR, HOME ou USERPROFILE).',
34    on: 'ligado: Ctrl+Enter quebra a linha.',
35    off: 'desligado: Ctrl+Enter mantém a ação padrão (enviar agora).',
36    other: 'outro: o Ctrl+Enter está ligado a outra ação no seu arquivo.',
37    removed: 'Removi o atalho do Ctrl+Enter. Ele voltou ao padrão.',
38    disabled: 'Desativado: o mod não vai mexer no keybindings.json. Rode /ctrl-enter on para ativar de novo.',
39    hint:
40      'Se o Ctrl+Enter ainda enviar a mensagem, o seu terminal não o distingue do Enter (precisa do protocolo de teclado Kitty). O Ctrl+J quebra a linha em qualquer terminal.',
41    file: (path: string) => `Arquivo: ${path}`,
42  },
43} as const
44
45type Block = { context?: unknown; bindings?: unknown }
46type Doc = Record<string, unknown> & { bindings: unknown[] }
47
48const isRecord = (v: unknown): v is Record<string, unknown> =>
49  typeof v === 'object' && v !== null && !Array.isArray(v)
50
51const parse = (text: string): Doc | undefined => {
52  try {
53    const value: unknown = JSON.parse(text)
54    return isRecord(value) && Array.isArray(value.bindings) ? (value as Doc) : undefined
55  } catch {
56    return undefined
57  }
58}
59
60const dump = (doc: unknown) => `${JSON.stringify(doc, null, 2)}\n`
61
62// Claude Code reads key names case-insensitively and accepts aliases
63export const isCtrlEnter = (key: string) => {
64  const parts = key
65    .toLowerCase()
66    .split('+')
67    .map(p => p.trim())
68    .map(p => (p === 'control' ? 'ctrl' : p === 'return' ? 'enter' : p))
69  return parts.length === 2 && parts.includes('ctrl') && parts.includes('enter')
70}
71
72const chatBlocks = (doc: Doc) =>
73  doc.bindings.filter(
74    (b): b is Block & { bindings: Record<string, unknown> } =>
75      isRecord(b) && b.context === 'Chat' && isRecord(b.bindings),
76  )
77
78// the key the file already binds (any spelling), with the action it points to
79const existing = (doc: Doc) => {
80  for (const block of chatBlocks(doc)) {
81    for (const [key, action] of Object.entries(block.bindings)) {
82      if (isCtrlEnter(key)) return { block, key, action }
83    }
84  }
85  return undefined
86}
87
88export const fresh = () =>
89  dump({
90    $schema: SCHEMA,
91    $docs: DOCS,
92    bindings: [{ context: 'Chat', bindings: { [KEY]: ACTION } }],
93  })
94
95export type Added = { text: string; result: 'added' | 'already' | 'conflict' | 'invalid' }
96
97// merges the binding into the file's text without touching anything else
98export const addBinding = (text: string | undefined): Added => {
99  if (text === undefined || text.trim() === '') return { text: fresh(), result: 'added' }
100  const doc = parse(text)
101  if (doc === undefined) return { text, result: 'invalid' }
102
103  const found = existing(doc)
104  if (found !== undefined) {
105    return { text, result: found.action === ACTION ? 'already' : 'conflict' }
106  }
107
108  const block = chatBlocks(doc)[0]
109  if (block === undefined) doc.bindings.push({ context: 'Chat', bindings: { [KEY]: ACTION } })
110  else block.bindings[KEY] = ACTION
111
112  return { text: dump(doc), result: 'added' }
113}
114
115export type Removed = { text: string; result: 'removed' | 'absent' | 'conflict' | 'invalid' }
116
117// takes out the binding, but only when it points to chat:newline
118export const removeBinding = (text: string | undefined): Removed => {
119  if (text === undefined || text.trim() === '') return { text: text ?? '', result: 'absent' }
120  const doc = parse(text)
121  if (doc === undefined) return { text, result: 'invalid' }
122
123  const found = existing(doc)
124  if (found === undefined) return { text, result: 'absent' }
125  if (found.action !== ACTION) return { text, result: 'conflict' }
126
127  delete found.block.bindings[found.key]
128  if (Object.keys(found.block.bindings).length === 0) {
129    doc.bindings = doc.bindings.filter(b => b !== found.block)
130  }
131
132  return { text: dump(doc), result: 'removed' }
133}
134
135export type State = 'on' | 'other' | 'off' | 'invalid'
136
137export const stateOf = (text: string | undefined): State => {
138  if (text === undefined || text.trim() === '') return 'off'
139  const doc = parse(text)
140  if (doc === undefined) return 'invalid'
141  const found = existing(doc)
142  return found === undefined ? 'off' : found.action === ACTION ? 'on' : 'other'
143}
144
145// whether a `language` setting (`portuguese`, `Português`, `pt-BR`) asks for Portuguese
146export const langOf = (language: unknown): Lang =>
147  typeof language === 'string' && /^(pt|port)/i.test(language.trim()) ? 'pt' : 'en'
148
149async function readLang($: EngineInterface): Promise<Lang> {
150  try {
151    return langOf((await $.settings.read()).language)
152  } catch {
153    return 'en'
154  }
155}
156
157// USERPROFILE before HOME: Git Bash sets HOME to /c/Users/..., which Node cannot open
158async function keybindingsPath($: EngineInterface): Promise<string | undefined> {
159  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
160  if (custom !== undefined && custom !== '') return `${custom.replace(/[\\/]+$/, '')}/keybindings.json`
161  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
162  return home === undefined || home === '' ? undefined : `${home.replace(/[\\/]+$/, '')}/.claude/keybindings.json`
163}
164
165async function readFile($: EngineInterface, path: string): Promise<string | undefined> {
166  return (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
167}
168
169// keeps the first version of a file this mod changes, once
170async function backup($: EngineInterface, path: string, text: string | undefined) {
171  const copy = `${path}.ctrl-enter-newline.bak`
172  if (text !== undefined && text.trim() !== '' && !(await $.fs.exists(copy))) {
173    await $.fs.write(copy, text)
174  }
175}
176
177type Outcome = { message: string; path?: string }
178
179async function enable($: EngineInterface, lang: Lang): Promise<Outcome> {
180  const t = TEXT[lang]
181  const path = await keybindingsPath($)
182  if (path === undefined) return { message: t.noDir }
183
184  try {
185    const text = await readFile($, path)
186    const change = addBinding(text)
187    if (change.result === 'added') {
188      await backup($, path, text)
189      await $.fs.write(path, change.text)
190      return { message: t.added(path), path }
191    }
192    if (change.result === 'conflict') return { message: t.conflict, path }
193    if (change.result === 'invalid') return { message: t.invalid, path }
194    return { message: t.on, path }
195  } catch (err) {
196    return { message: t.failed(String(err)), path }
197  }
198}
199
200async function disable($: EngineInterface, lang: Lang): Promise<Outcome> {
201  const t = TEXT[lang]
202  const path = await keybindingsPath($)
203  if (path === undefined) return { message: t.noDir }
204
205  try {
206    const text = await readFile($, path)
207    const change = removeBinding(text)
208    if (change.result === 'removed') {
209      await backup($, path, text)
210      await $.fs.write(path, change.text)
211      return { message: t.removed, path }
212    }
213    if (change.result === 'conflict') return { message: t.conflict, path }
214    if (change.result === 'invalid') return { message: t.invalid, path }
215    return { message: t.off, path }
216  } catch (err) {
217    return { message: t.failed(String(err)), path }
218  }
219}
220
221async function status($: EngineInterface, lang: Lang): Promise<string> {
222  const t = TEXT[lang]
223  const path = await keybindingsPath($)
224  if (path === undefined) return t.noDir
225  const state = stateOf(await readFile($, path).catch(() => undefined))
226  const disabled = (await $.store.get('disabled')) === true
227  const lines = [
228    state === 'on' ? t.on : state === 'other' ? t.other : state === 'invalid' ? t.invalid : t.off,
229    ...(disabled ? [t.disabled] : []),
230    t.hint,
231    t.file(path),
232  ]
233  return lines.join('\n')
234}
235
236export const register: Register = on => {
237  on('session.start', async ($, e, next) => {
238    await $.command.register({
239      name: COMMAND,
240      description: 'Ctrl+Enter newline: status, on or off',
241      argumentHint: '[status|on|off]',
242    })
243
244    if ((await $.store.get('disabled')) !== true) {
245      const lang = await readLang($)
246      const outcome = await enable($, lang)
247      // say it once per change: a stored note keeps a conflict from nagging every session
248      const note = (await $.store.get('note')) as string | undefined
249      if (outcome.message !== TEXT[lang].on && outcome.message !== note) {
250        $.ui.toast(outcome.message)
251        await $.store.set('note', outcome.message)
252      }
253    }
254
255    return next(e)
256  })
257
258  on('command.run', { command: COMMAND }, async ($, e, next) => {
259    const lang = await readLang($)
260    const arg = e.args.trim().toLowerCase()
261
262    if (arg === 'on') {
263      await $.store.delete('disabled')
264      await $.store.delete('note')
265      return { text: (await enable($, lang)).message }
266    }
267    if (arg === 'off') {
268      await $.store.set('disabled', true)
269      return { text: `${(await disable($, lang)).message}\n${TEXT[lang].disabled}` }
270    }
271
272    return { text: await status($, lang) }
273  })
274}
275