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

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.
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.
keybindings.json and keeps everything else in the file. If the file does not exist it creates it.keybindings.json.ctrl-enter-newline.bak.Ctrl+Enter already points to another action, or is set to null, it leaves the file alone and says so.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./ctrl-enter command| Command | Does |
|---|---|
/ctrl-enter or /ctrl-enter status | Shows whether the binding is on and which file it is in |
/ctrl-enter off | Removes the ctrl+enter binding if it points to chat:newline (whoever added it), and stops the mod from touching the file again |
/ctrl-enter on | Turns 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.
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.
Messages are in English, and in Portuguese when the language setting of Claude Code is Portuguese.
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.
MIT, see LICENSE.
Mod do Claude Code que faz o Ctrl+Enter quebrar a linha no prompt, como no app desktop. O Enter continua enviando a mensagem.
/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.
hooks/register.ts 275 lines1import 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