Ctrl+G turns a rambling, dictated prompt into a clean, ordered one, in place, on your own Claude Code plan.

Press Ctrl+G and the rambling prompt you just dictated becomes the one you meant to type, in place, inside Claude Code, on the plan you already have. Ctrl+G again puts your original back.
You talk faster than you type, so you dictate. Then Claude Code gets "uh no wait, not the handler, the session middleware" and takes every word literally. unramble rewrites the draft before you send it: fillers and false starts gone, the correction applied, separate asks numbered, standing rules under Constraints. Same meaning: nothing added, nothing you said lost. It never answers your question or does the task; it only tidies the prompt you are about to send.
In a terminal (Node 18+), npx unramble installs the plugin and binds Ctrl+G in one step. Or, inside Claude Code:
/plugin install unramble --marketplace yadava5/unramble/unramble setup. This binds Ctrl+G in your keybindings.json, keeps a backup of that file, and never overwrites a key you bound yourself.| Key | Does |
|---|---|
| Ctrl+G | rewrite the draft (the band above the box says refining…) |
| Ctrl+G on the rewrite | put your original back, exactly |
| Ctrl+G while it runs | cancel; your draft stays |
1. A dictated request with a correction and a constraint. A real rewrite (Sonnet on a Claude Code plan, 3.3 s). Said:
okay so um the login page is broken again I think, like when you hit submit it just spins forever. can you look at the auth handler, uh no wait, not the handler, the session middleware, that's what changed yesterday. and then once it works add a test for it so this doesn't happen again. oh and don't touch the database migrations, we're kind of mid deploy
Sent to Claude Code after Ctrl+G:
The login page is broken again, I think: when you hit submit, it spins forever.
1. Look at the session middleware, which is what changed yesterday.
2. Once it works, add a test for it so this doesn't happen again.
Constraints:
- Don't touch the database migrations; we're mid deploy.
2. A request with a pasted error log. Say "so the build crashes with this", paste the stack trace, then say "can you fix it". The log lines are swapped for a placeholder before the model sees the draft, and the rewrite gets them back byte for byte: the model only reorganises your words around the log.
3. A path you corrected on the spot. Say "move the helpers into src/utils, no wait, src/lib". The rewrite may drop src/utils, because you corrected it, and must keep src/lib. If a rewrite ever drops a path, --flag, number, URL or @ref you meant to keep, it is refused and your draft stays as it was, with a toast saying which value went missing.
4. Changing your mind. Press Ctrl+G on the rewrite and your exact original comes back, fillers and all.
prompt/system.md, to the model through Claude Code's own session ($.model.complete), on your Claude Code plan. The plugin has no network code, no API key and no server of its own; nothing else leaves your machine.keybindings.json in your Claude Code config folder ($CLAUDE_CONFIG_DIR, else ~/.claude), only when you run /unramble setup: it adds the two Ctrl+G bindings and keeps your original file beside it as keybindings.json.unramble-backup. It is a settings file, and that is the only file the plugin writes.prompt/system.md; keybindings.json (to know whether Ctrl+G is bound); the environment variables CLAUDE_CONFIG_DIR, HOME and USERPROFILE to find that folder. As you edit, it sees the prompt box's text only to count words for the band and to remember what a paste chip stands for, and forgets both when you send. It never reads your conversation, history or project files.session.start: registers /unramble and checks whether Ctrl+G is bound; if not, shows one toast saying to run /unramble setup.command.run (/unramble only): runs setup or status. It sees no other command.prompt.edit: notes whether the box holds a draft and what a paste chip stands for, so the band and the rewrite see your words. It changes nothing. A prompt recalled from history, or a box you cleared, reaches the band at your next key.prompt.submit: forgets the draft once you send it. It never changes or blocks a prompt.ui.render (above the prompt): draws the ⌃G refine band./unramble./config (the unramble rows), or claude plugin configure unramble:
| Setting | Default | |
|---|---|---|
| Model | sonnet | sonnet (fast, light on your plan) or opus (the most faithful) |
| Names you often say | empty | comma-separated names of your tools and projects, so a dictation slip is spelled right |
/unramble status shows the model and whether Ctrl+G is bound.
/unramble setup, then start a new session.⌃G refine band above the box. The band appears once the draft has three words. In a short window with a tall status line Claude Code may not draw it; Ctrl+G still works.[Pasted text #N]. Claude Code folds long pastes into a chip that plugins cannot read, so that text is kept as is.It works in Claude Code's terminal interface (a terminal, or the one inside VS Code or Cursor), on Claude Code 2.1.308 and later. The VS Code extension's chat panel and the desktop app are not supported.
hooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteResult, Register } from 'claude-code'
3
4import { ACTION, isBound, parse, plan, serialize } from './keybindings.js'
5import {
6 check,
7 expandSpoken,
8 mask,
9 MIN_WORDS,
10 newChip,
11 skipReason,
12 systemPrompt,
13 transcript,
14 wordCount,
15} from './refine.ts'
16
17// Ctrl+G rewrites the prompt box in place: the draft goes to a model with the rules in
18// prompt/system.md and comes back organised, with the same meaning. Ctrl+G on an untouched
19// rewrite puts the original back; Ctrl+G while one runs cancels it. Nothing leaves the prompt
20// box unless the person presses the key, and nothing replaces the draft unless it passes the
21// checks in refine.ts.
22
23const NAME = 'unramble'
24const hasDraft = atom({ plugin: 'unramble', key: 'hasDraft' } as const, false)
25const isBusy = atom({ plugin: 'unramble', key: 'isBusy' } as const, false)
26const isRefined = atom({ plugin: 'unramble', key: 'isRefined' } as const, false)
27const isKeyBound = atom({ plugin: 'unramble', key: 'isKeyBound' } as const, true)
28const original = atom({ plugin: 'unramble', key: 'original' } as const, '')
29const rewrite = atom({ plugin: 'unramble', key: 'rewrite' } as const, '')
30// What each paste chip in the box stands for: the box holds only the chip, the edit carried the text.
31const pastes = atom({ plugin: 'unramble', key: 'pastes' } as const, {} as Record<string, string>)
32
33type Settings = { model: string; vocabulary: string }
34
35const settingsOf = (options: Readonly<Record<string, unknown>>): Settings => ({
36 model: options.model === 'opus' ? 'opus' : 'sonnet',
37 vocabulary: typeof options.vocabulary === 'string' ? options.vocabulary : '',
38})
39
40const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')
41
42/** keybindings.json beside Claude Code's config; null unless that folder is an absolute path. */
43async function keybindingsPath($: EngineInterface): Promise<string | null> {
44 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
45 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
46 const config = dir ? dir.replace(/[\\/]+$/, '') : home ? `${home}/.claude` : null
47 return config && isAbsolute(config) ? `${config}/keybindings.json` : null
48}
49
50const NO_CONFIG = 'Could not find Claude Code’s config folder (no absolute CLAUDE_CONFIG_DIR or home folder), so nothing was changed.'
51
52async function readKeybindings($: EngineInterface): Promise<{ path: string; text: string | null } | null> {
53 const path = await keybindingsPath($)
54 if (!path) return null
55 return { path, text: (await $.fs.exists(path)) ? await $.fs.read(path) : null }
56}
57
58async function setup($: EngineInterface): Promise<string> {
59 const found = await readKeybindings($)
60 if (!found) return NO_CONFIG
61 const { path, text } = found
62 let current
63 try {
64 current = parse(text)
65 } catch (err) {
66 return `Left ${path} alone: it could not be read as JSON (${String(err).slice(0, 80)}).\nAdd this to it by hand: Global "ctrl+g": "${ACTION}", Chat "ctrl+g": null.`
67 }
68 const next = plan(current)
69 if (next.kind === 'ready') {
70 await update($, isKeyBound, () => true)
71 return 'Ctrl+G is already bound to unramble. Nothing changed.'
72 }
73 if (next.kind === 'conflict') {
74 return `Changed nothing: ${next.why}.\nTo use Ctrl+G for unramble, remove that binding from ${path} and run /unramble setup again.`
75 }
76 // the first backup is the person's own file; running setup again never replaces it
77 const backup = `${path}.unramble-backup`
78 if (text !== null && !(await $.fs.exists(backup))) await $.fs.write(backup, text)
79 await $.fs.write(path, serialize(next.file))
80 await update($, isKeyBound, () => true)
81 return [
82 `Updated ${path}${text !== null ? ` (backup: ${backup})` : ''}:`,
83 ...next.changes.map(c => ` - ${c}`),
84 'Start a new Claude Code session for Ctrl+G to take effect.',
85 ].join('\n')
86}
87
88async function status($: EngineInterface, s: Settings): Promise<string> {
89 const found = await readKeybindings($)
90 const path = found?.path ?? 'keybindings.json'
91 let bound = false
92 try {
93 bound = found ? isBound(parse(found.text)) : false
94 } catch {}
95 const names = s.vocabulary.split(/[,\n]/).filter(n => n.trim()).length
96 return [
97 "Runs on: your Claude Code plan (the session's own login)",
98 `Model: ${s.model}`,
99 `Vocabulary: ${names ? `${names} name${names === 1 ? '' : 's'}` : 'none'}`,
100 `Ctrl+G: ${bound ? 'bound' : `not bound in ${path}; run /unramble setup`}`,
101 'Change the model or vocabulary in /config (unramble rows), or with: claude plugin configure unramble',
102 ].join('\n')
103}
104
105let inFlight: AbortController | null = null
106
107let rulesText = ''
108
109/** The rewrite rules, read once from the plugin's own prompt/system.md. */
110async function rules($: EngineInterface): Promise<string> {
111 rulesText ||= await $.fs.read(`${$.plugin.root}/prompt/system.md`)
112 return rulesText
113}
114
115// The model is reached through Claude Code's own client: the session's login, billed to the
116// person's plan exactly like their prompts, with no key and no network code in the plugin.
117// Models are asked for by family (`sonnet`, `opus`), so the newest of each is used without an
118// update.
119
120const TIMEOUT_MS = 45_000
121const MAX_TOKENS = 8_000
122
123type Outcome = { ok: true; text: string; via: string } | { ok: false; why: string }
124
125function explain(r: Exclude<ModelCompleteResult, { isAnswered: true }>): string {
126 if (r.reason === 'aborted') return 'cancelled or timed out'
127 if (r.reason === 'empty-reply') return 'the model sent back nothing'
128 if (r.error === 'billing_error') return 'the account has no credit left for this call'
129 if (r.error === 'authentication_failed') return 'Claude Code is not signed in'
130 if (r.status === 429) return 'usage limit reached (429)'
131 if (r.status === null) return 'no connection to the model'
132 return `the model call failed (${r.status})`
133}
134
135async function viaPlan($: EngineInterface, system: string, prompt: string, model: string, signal: AbortSignal): Promise<Outcome> {
136 // The rules are the same on every call, so they are cached: a second rewrite within the
137 // cache window reads them instead of paying for them again.
138 const r = await $.model.complete(
139 { model, system: [{ text: system, cache: true }], prompt, maxTokens: MAX_TOKENS, timeoutMs: TIMEOUT_MS },
140 { signal },
141 )
142 return r.isAnswered ? { ok: true, text: r.text, via: `${model} on your plan` } : { ok: false, why: explain(r) }
143}
144
145async function refine($: EngineInterface, s: Settings) {
146 if (await read($, isBusy)) {
147 inFlight?.abort() // Ctrl+G while it runs: cancel, keep the draft
148 return
149 }
150 if (await read($, isRefined)) {
151 await $.prompt.fill({ text: await read($, original), mode: 'replace' })
152 await update($, rewrite, () => '')
153 await update($, isRefined, () => false)
154 $.ui.toast('your original is back')
155 return
156 }
157 const draft = (await $.prompt.read()).text
158 const spoken = expandSpoken(draft, await read($, pastes)).trim()
159 const skip = skipReason(spoken)
160 if (skip) {
161 if (wordCount(spoken) >= MIN_WORDS) $.ui.toast(skip)
162 return
163 }
164
165 const controller = new AbortController()
166 inFlight = controller
167 await update($, isBusy, () => true) // the band reads "refining…" meanwhile
168 try {
169 const startedAt = await $.clock.now()
170 const { masked, blocks } = mask(spoken)
171 const system = systemPrompt(await rules($), s.vocabulary)
172 const out = await viaPlan($, system, transcript(masked), s.model, controller.signal)
173 if (controller.signal.aborted) {
174 $.ui.toast('cancelled — your text is unchanged')
175 return
176 }
177 if (!out.ok) {
178 $.ui.toast(`${out.why} — your text is unchanged`)
179 return
180 }
181 const checked = check(spoken, out.text, blocks)
182 if (!checked.ok) {
183 $.ui.toast(`kept your text — ${checked.why}`)
184 return
185 }
186 // Typed or spoke more while the rewrite ran: the person's text wins.
187 if ((await $.prompt.read()).text !== draft) {
188 $.ui.toast('you edited while it ran — kept your text')
189 return
190 }
191 await update($, original, () => draft)
192 await update($, rewrite, () => checked.text)
193 await $.prompt.fill({ text: checked.text, mode: 'replace' })
194 await update($, isRefined, () => true)
195 const secs = ((await $.clock.now()) - startedAt) / 1000
196 $.ui.toast(`done in ${secs.toFixed(1)}s with ${out.via} · ⌃G again restores your original`)
197 } catch (err) {
198 $.ui.toast(`failed (${String(err).slice(0, 80)}) — your text is unchanged`)
199 } finally {
200 if (inFlight === controller) inFlight = null
201 await update($, isBusy, () => false)
202 }
203}
204
205async function track($: EngineInterface, text: string) {
206 await update($, hasDraft, () => wordCount(text) >= MIN_WORDS)
207 const last = await read($, rewrite)
208 await update($, isRefined, () => last !== '' && text.trim() === last.trim())
209}
210
211export const register: Register = (on, options) => {
212 const s = settingsOf(options)
213
214 on('session.start', async ($, e, next) => {
215 await $.command.register({
216 name: NAME,
217 description: 'Ctrl+G prompt cleanup: `setup` binds the key, `status` shows the model and key binding',
218 argumentHint: 'setup | status',
219 })
220 let bound = false
221 try {
222 const found = await readKeybindings($)
223 bound = found ? isBound(parse(found.text)) : false
224 } catch {}
225 await update($, isKeyBound, () => bound)
226 // installed from /plugin or a link, nothing has bound the key yet: say the one step left
227 if (!bound) $.ui.toast('installed — run /unramble setup once to bind Ctrl+G')
228 return next(e)
229 })
230
231 on('command.run', { command: NAME }, async ($, e) => {
232 const arg = e.args.trim().toLowerCase()
233 if (arg === 'setup') return { text: await setup($) }
234 if (arg === '' || arg === 'status') return { text: await status($, s) }
235 return { text: `Unknown argument "${e.args.trim()}". Use /unramble setup or /unramble status.` }
236 }).catch(() => ({ text: 'that command failed; nothing was changed. Try /unramble status.' }))
237
238 on('prompt.edit', async ($, e, next) => {
239 const box = await next(e)
240 // Claude Code 2.1.308 raises no edit for a paste it folds into a chip, so this only sees
241 // pastes it keeps inline; a chip it never saw is left as a chip (expandSpoken).
242 const chip = e.key ? null : newChip(e.text, box.text)
243 if (chip) await update($, pastes, m => ({ ...m, [chip]: e.inputText }))
244 await track($, box.text)
245 return box
246 })
247
248 // A sent prompt clears the band and the stored rewrite, so the next draft is never compared
249 // with a stale one. If this ever throws, the prompt still goes.
250 on('prompt.submit', async ($, e, next) => {
251 await update($, hasDraft, () => false)
252 await update($, rewrite, () => '')
253 await update($, original, () => '')
254 await update($, isRefined, () => false)
255 await update($, pastes, () => ({}))
256 return next(e)
257 }).catch(($, e, next) => next(e))
258
259 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260 const busy = await read($, isBusy)
261 const refined = await read($, isRefined)
262 if (e.props.hasSurvey || !(busy || refined || (await read($, hasDraft)))) return next(e)
263 const bound = await read($, isKeyBound)
264 const { Box, Button, Text } = $.ui.resolve(e)
265 const key = bound ? '⌃G ' : ''
266 const label = busy ? `refining… ${key ? '⌃G cancels' : ''}`.trim() : refined ? `${key}original` : `${key}refine`
267 return (
268 <Box>
269 <Button key="unramble" plain action={ACTION} label="unramble" onPress={() => refine($, s)}>
270 <Text dimColor>{label}</Text>
271 </Button>
272 {bound ? null : <Text dimColor> · /unramble setup binds ⌃G</Text>}
273 </Box>
274 )
275 })
276}
277hooks/keybindings.js 129 lines1// @ts-check
2// Ctrl+G reaches a plugin only through a band Button naming an engine keybinding action that
3// no engine handler holds while the prompt is focused, and only when that chord is bound in the
4// Global context. So setup binds Global ctrl+g to app:cycleDiffBase (handled only while the diff
5// panel is open, where it keeps working) and unbinds Chat ctrl+g (by default the external
6// editor, which stays on ctrl+x ctrl+e). A chord the person bound to something else is theirs:
7// setup reports the conflict and changes nothing.
8//
9// Plain JavaScript on purpose: the plugin imports it, and so does the npx installer, which runs
10// from a package cache where Node will not strip TypeScript.
11
12export const ACTION = 'app:cycleDiffBase'
13export const CHORD = 'ctrl+g'
14const EDITOR = 'chat:externalEditor'
15const EDITOR_CHORD = 'ctrl+x ctrl+e'
16
17/** @typedef {Record<string, string | null>} Bindings */
18/** @typedef {{ context: string, bindings: Bindings }} Block */
19/** @typedef {{ bindings: Block[], [key: string]: unknown }} KeybindingsFile */
20/**
21 * @typedef {{ kind: 'ready' }
22 * | { kind: 'change', file: KeybindingsFile, changes: string[] }
23 * | { kind: 'conflict', why: string }} SetupPlan
24 */
25
26/** @type {KeybindingsFile} */
27const SKELETON = { bindings: [] }
28
29/** @param {KeybindingsFile} file @returns {KeybindingsFile} */
30const clone = file => JSON.parse(JSON.stringify(file))
31
32/**
33 * Parses keybindings.json text; an absent file is an empty one. Throws on anything else.
34 * @param {string | null} text
35 * @returns {KeybindingsFile}
36 */
37export function parse(text) {
38 if (text === null || !text.trim()) return clone(SKELETON)
39 const data = JSON.parse(text)
40 if (!data || typeof data !== 'object' || !Array.isArray(data.bindings)) {
41 throw new Error('keybindings.json has no "bindings" list')
42 }
43 return data
44}
45
46/** @param {KeybindingsFile} file @param {string} context @returns {Block | undefined} */
47const blockOf = (file, context) =>
48 file.bindings.find(b => b && b.context === context && b.bindings && typeof b.bindings === 'object')
49
50/**
51 * Whether Ctrl+G already reaches unramble.
52 * @param {KeybindingsFile} file
53 */
54export function isBound(file) {
55 return blockOf(file, 'Global')?.bindings[CHORD] === ACTION && blockOf(file, 'Chat')?.bindings[CHORD] === null
56}
57
58/**
59 * What setup would do to this file, without doing it.
60 * @param {KeybindingsFile} current
61 * @returns {SetupPlan}
62 */
63export function plan(current) {
64 if (isBound(current)) return { kind: 'ready' }
65 const globalNow = blockOf(current, 'Global')?.bindings[CHORD]
66 if (globalNow !== undefined && globalNow !== ACTION) {
67 return { kind: 'conflict', why: `ctrl+g is bound to "${globalNow}" in your Global keybindings` }
68 }
69 const chatNow = blockOf(current, 'Chat')?.bindings[CHORD]
70 if (chatNow !== undefined && chatNow !== null && chatNow !== EDITOR) {
71 return { kind: 'conflict', why: `ctrl+g is bound to "${chatNow}" in your Chat keybindings` }
72 }
73
74 const file = clone(current)
75 /** @type {string[]} */
76 const changes = []
77 /** @param {string} context @returns {Block} */
78 const ensure = context => {
79 let block = blockOf(file, context)
80 if (!block) {
81 block = { context, bindings: {} }
82 file.bindings.push(block)
83 }
84 return block
85 }
86 const global = ensure('Global')
87 if (global.bindings[CHORD] !== ACTION) {
88 global.bindings[CHORD] = ACTION
89 changes.push(`Global: ctrl+g → ${ACTION} (lets the unramble band take Ctrl+G)`)
90 }
91 const chat = ensure('Chat')
92 if (chat.bindings[CHORD] !== null) {
93 chat.bindings[CHORD] = null
94 changes.push('Chat: ctrl+g → unbound (it opened the external editor)')
95 }
96 if (!Object.values(chat.bindings).includes(EDITOR) && chat.bindings[EDITOR_CHORD] === undefined) {
97 chat.bindings[EDITOR_CHORD] = EDITOR
98 changes.push(`Chat: ${EDITOR_CHORD} → ${EDITOR} (the external editor keeps a key)`)
99 }
100 return { kind: 'change', file, changes }
101}
102
103/**
104 * Takes back exactly what setup added, and nothing the person set themselves: Global ctrl+g
105 * only while it still names unramble's action, Chat ctrl+g only while it is still unbound.
106 * The ctrl+x ctrl+e editor key stays; it is Claude Code's own action.
107 * @param {KeybindingsFile} current
108 * @returns {{ file: KeybindingsFile, changes: string[] }}
109 */
110export function unplan(current) {
111 const file = clone(current)
112 /** @type {string[]} */
113 const changes = []
114 const global = blockOf(file, 'Global')
115 if (global?.bindings[CHORD] === ACTION) {
116 delete global.bindings[CHORD]
117 changes.push('Global: ctrl+g removed')
118 }
119 const chat = blockOf(file, 'Chat')
120 if (chat && chat.bindings[CHORD] === null) {
121 delete chat.bindings[CHORD]
122 changes.push('Chat: ctrl+g back to its default (the external editor)')
123 }
124 return { file, changes }
125}
126
127/** @param {KeybindingsFile} file */
128export const serialize = file => `${JSON.stringify(file, null, 2)}\n`
129hooks/refine.ts 221 lines1// Pure text work around one rewrite: what to skip, what to hide from the model, and what a
2// rewrite must still contain before it may replace the draft. No `$`, so every rule here is
3// unit-tested directly.
4
5/** The band shows, and Ctrl+G acts, only from this many words. */
6export const MIN_WORDS = 3
7
8/** Past this the box holds a paste or a log, not a dictated prompt. */
9export const MAX_CHARS = 12_000
10
11export const wordCount = (text: string): number =>
12 text.trim().split(/\s+/).filter(Boolean).length
13
14/** Why a draft is left alone, or null when it may be rewritten. */
15export function skipReason(text: string): string | null {
16 const body = text.trim()
17 if (wordCount(body) < MIN_WORDS) return 'too short to rewrite'
18 if (body.startsWith('/') || body.startsWith('!')) return 'slash commands and shell escapes stay as typed'
19 if (body.length > MAX_CHARS) return 'that is paste-sized, not a dictated prompt'
20 return null
21}
22
23/**
24 * Machine values that must survive verbatim: paths, flags, numbers of two or more digits,
25 * URLs, @-references and Claude Code's paste and image placeholders.
26 */
27export function tokens(text: string): Set<string> {
28 const found = [
29 ...text.matchAll(/(?:~|\/)[\w.\-/]{3,}/g),
30 ...text.matchAll(/(?<!\w)--[a-z][\w-]+/g),
31 ...text.matchAll(/https?:\/\/\S+/g),
32 ...text.matchAll(/\b\d{2,}(?:[.,]\d+)?\b/g),
33 ...text.matchAll(/\[(?:Pasted text|Image) #\d+[^\]]*\]/g),
34 ...text.matchAll(/@\S+/g),
35 ].map(m => m[0].replace(/[.,)]+$/, ''))
36 return new Set(found.filter(Boolean))
37}
38
39const CUE = /\b(no|nope|nah|wait|actually|sorry|scratch that|i mean|not that|never ?mind|forget it)\b/i
40
41const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
42
43/**
44 * A value the speaker corrected right away ("src/utils, no wait, src/lib") may vanish from
45 * the rewrite: a correction cue follows some occurrence of it within about ten words.
46 */
47export function retracted(text: string, token: string): boolean {
48 for (const m of text.matchAll(new RegExp(escape(token), 'g'))) {
49 const end = (m.index ?? 0) + m[0].length
50 if (CUE.test(text.slice(end, end + 70))) return true
51 }
52 return false
53}
54
55/** Values of the draft that the rewrite lost, corrected-away ones excepted. */
56export function missingValues(draft: string, rewrite: string): string[] {
57 return [...tokens(draft)]
58 .filter(t => !rewrite.includes(t) && !retracted(draft, t))
59 .sort()
60}
61
62// Invisible and direction-changing characters, as code points: bidi controls, zero-width
63// characters, word joiners, BOM, and the tag block that can hide text from a reader.
64const HIDDEN: readonly (readonly [number, number])[] = [
65 [0x200b, 0x200f],
66 [0x202a, 0x202e],
67 [0x2060, 0x2064],
68 [0x2066, 0x206f],
69 [0xfeff, 0xfeff],
70 [0xe0000, 0xe007f],
71]
72const isHidden = (ch: string) => {
73 const cp = ch.codePointAt(0) ?? 0
74 return HIDDEN.some(([lo, hi]) => cp >= lo && cp <= hi)
75}
76
77/** Letters and digits only, with spoken punctuation dropped: "ayush dot dev" vouches for ayush.dev. */
78const skeleton = (s: string) =>
79 s
80 .toLowerCase()
81 .replace(/\b(?:dot|period|point|slash|backslash|dash|minus|hyphen|colon|tilde|underscore|pipe|at sign)\b/g, '')
82 .replace(/[^\p{L}\p{N}]/gu, '')
83
84// any scheme:// link, the two schemes that need no slashes, and a bare www. address
85const LINK = /\b[a-z][a-z\d+.-]*:\/\/\S+|\b(?:data|javascript):\S+|\bwww\.\S+/gi
86const COMMAND = /\b(?:curl|wget|sudo|chmod|chown|powershell|iex|invoke-expression|base64|netcat|eval|rm\s+-\w+)\b/gi
87const CHAIN = /&&|\|\||\$\(|\|\s*(?:sh|bash|zsh|python3?|node|iex)\b/g
88
89/**
90 * What a rewrite brought in that the speaker never said: a link, a download-or-run command,
91 * shell chaining, or invisible characters. Text pasted into the draft can carry instructions,
92 * and a rewrite that obeyed them would hand an agent with a shell something to run. Spoken
93 * forms count as said ("rm dash r f" for `rm -rf`, "h t t p s colon slash slash" for a link).
94 */
95export function addedValues(draft: string, rewrite: string): string[] {
96 const said = skeleton(draft)
97 const risky = [...rewrite.matchAll(LINK), ...rewrite.matchAll(COMMAND)]
98 .map(m => m[0].replace(/[.,;:)`'"]+$/, ''))
99 .filter(v => !said.includes(skeleton(v)))
100 const chains = [...rewrite.matchAll(CHAIN)].map(m => m[0]).filter(c => !draft.includes(c))
101 const hidden = [...rewrite]
102 .filter(c => isHidden(c) && !draft.includes(c))
103 .map(c => `U+${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}`)
104 return [...new Set([...risky, ...chains, ...hidden])].sort()
105}
106
107/** A line that looks pasted (log, code, config, table) rather than spoken. */
108export function isMachine(line: string): boolean {
109 // placeholders, URLs and @-references are guarded by tokens(); judge the rest of the line
110 const bare = line
111 .trim()
112 .replace(/\[(?:Pasted text|Image) #\d+[^\]]*\]|https?:\/\/\S+|@\S+/g, '')
113 .trim()
114 if (bare.length < 20) return false
115 const prose = [...bare].filter(c => /\p{L}/u.test(c) || c === ' ').length / bare.length
116 return prose < 0.8 || /^(?:[{(<#$>|`]|\w+\(|\d{4}-\d\d-\d\d)/.test(bare)
117}
118
119/**
120 * Swaps each run of pasted lines for a `[[paste N]]` marker, so the model never sees, slows on
121 * or alters them; unmask() puts them back byte for byte. Claude Code hands pastes over
122 * expanded, so a 30-line log would otherwise be rewritten too.
123 */
124export function mask(text: string): { masked: string; blocks: string[] } {
125 const lines = text.split('\n')
126 const line = (k: number) => lines[k] ?? ''
127 const out: string[] = []
128 const blocks: string[] = []
129 let i = 0
130 while (i < lines.length) {
131 if (!isMachine(line(i))) {
132 out.push(line(i))
133 i += 1
134 continue
135 }
136 let j = i
137 while (
138 j < lines.length &&
139 (isMachine(line(j)) || (!line(j).trim() && j + 1 < lines.length && isMachine(line(j + 1))))
140 ) {
141 j += 1
142 }
143 blocks.push(lines.slice(i, j).join('\n'))
144 out.push(`[[paste ${blocks.length}]]`)
145 i = j
146 }
147 return { masked: out.join('\n'), blocks }
148}
149
150/** Puts the pasted blocks back; null when a marker was lost or duplicated. */
151export function unmask(text: string, blocks: readonly string[]): string | null {
152 let out = text
153 for (const [n, block] of blocks.entries()) {
154 const marker = `[[paste ${n + 1}]]`
155 if (out.split(marker).length !== 2) return null
156 out = out.replace(marker, () => block)
157 }
158 return out
159}
160
161/**
162 * The rules' examples use <rewrite> tags, and now and then a model copies them with a note in
163 * front. Keep only what is inside the tags.
164 */
165export function unwrap(text: string): string {
166 const m = /<rewrite>\s*([\s\S]*?)\s*<\/rewrite>/.exec(text)
167 return (m ? (m[1] ?? '') : text).trim()
168}
169
170/** The rules, plus the names this user often says when they listed some. */
171export function systemPrompt(rules: string, vocabulary: string): string {
172 const names = vocabulary
173 .split(/[,\n]/)
174 .map(n => n.trim())
175 .filter(Boolean)
176 return names.length
177 ? `${rules.trimEnd()}\n\nNames this user often says: ${names.join(', ')}.`
178 : rules.trimEnd()
179}
180
181/** The draft as the model sees it; a closing tag inside the draft cannot end the transcript early. */
182export const transcript = (masked: string) =>
183 `<transcript>\n${masked.replace(/<(\/?transcript>)/gi, '‹$1')}\n</transcript>`
184
185/** Claude Code's chip for a multi-line paste, as the box holds it: `[Pasted text #2 +4 lines]`. */
186export const PASTE_CHIP = /\[Pasted text #\d+[^\]]*\]/g
187
188/** The chip a paste added to the box, when exactly one appeared. */
189export function newChip(before: string, after: string): string | null {
190 const had = new Set(before.match(PASTE_CHIP) ?? [])
191 const added = (after.match(PASTE_CHIP) ?? []).filter(c => !had.has(c))
192 return added.length === 1 ? (added[0] ?? null) : null
193}
194
195/**
196 * Dictation apps paste what they heard, so the box holds a chip where the words are. A chip
197 * whose content reads as speech is opened up for the rewrite; one holding a log, code or
198 * anything machine-like stays a chip, and the rewrite must keep it as it is.
199 */
200export function expandSpoken(draft: string, pastes: Readonly<Record<string, string>>): string {
201 return draft.replace(PASTE_CHIP, chip => {
202 const content = pastes[chip]
203 return content !== undefined && mask(content).blocks.length === 0 ? content : chip
204 })
205}
206
207export type Checked = { ok: true; text: string } | { ok: false; why: string }
208
209/** Everything a model's reply must pass before it replaces the draft. */
210export function check(draft: string, reply: string, blocks: readonly string[]): Checked {
211 const unwrapped = unwrap(reply)
212 if (!unwrapped) return { ok: false, why: 'no rewrite came back' }
213 const text = unmask(unwrapped, blocks)
214 if (text === null) return { ok: false, why: 'the rewrite lost or duplicated a pasted block' }
215 const missing = missingValues(draft.trim(), text)
216 if (missing.length) return { ok: false, why: `the rewrite dropped ${missing.slice(0, 4).join(', ')}` }
217 const added = addedValues(draft.trim(), text)
218 if (added.length) return { ok: false, why: `the rewrite added ${added.slice(0, 3).join(', ')}, which you never said` }
219 return { ok: true, text }
220}
221types/index.d.ts 16 lines1export type Flag = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 unramble: {
6 hasDraft: Flag
7 isBusy: Flag
8 isRefined: Flag
9 isKeyBound: Flag
10 original: string
11 rewrite: string
12 pastes: Record<string, string>
13 }
14 }
15}
16