SLOPSHOPPER

unramble

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

newbandcommandtoastpromptmodel
v2.0.1MITupdated 2026-10-23yadava5/unramble/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unramble
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ unramble │ ⏺ Read(src/auth.ts) │ installed — run /unramble setup once to │ ⎿ Read 6 lines │ bind Ctrl+G │ ⏺ 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 › /unramble ⎿ unramble: Runs on: your Claude Code plan (the session's own login) ⎿ unramble: Model: sonnet ⎿ unramble: Vocabulary: none ⎿ unramble: Ctrl+G: not bound in /Users/dev/.claude/keybindings.json; run /unramble setup ⎿ unramble: Change the model or vocabulary in /config (unramble rows), or with: claude plugin configure unramble ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

unramble

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.

What it is for

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.

Install

In a terminal (Node 18+), npx unramble installs the plugin and binds Ctrl+G in one step. Or, inside Claude Code:

  1. /plugin install unramble --marketplace yadava5/unramble
  2. Then: /unramble setup. This binds Ctrl+G in your keybindings.json, keeps a backup of that file, and never overwrites a key you bound yourself.
  3. Start a new session, say or type a prompt of three words or more, and press Ctrl+G.
KeyDoes
Ctrl+Grewrite the draft (the band above the box says refining…)
Ctrl+G on the rewriteput your original back, exactly
Ctrl+G while it runscancel; your draft stays

Examples

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.

What it does on your machine

  • Sends: on Ctrl+G only, your draft, with pasted logs and code masked out, plus the rules in 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.
  • Writes
  • 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.
  • Your prompt box, only when you press Ctrl+G: the rewrite, or your original back.
  • Reads: its own 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.
  • Hooks
  • 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.
  • Runs: no commands and no processes. Adds one slash command, /unramble.
  • Refuses: a rewrite that adds a link, a shell command or shell chaining you never said. Text pasted into the box can carry instructions, and the prompt goes to an agent that can run commands. On 2,240 recorded rewrites this refused none.

Settings

/config (the unramble rows), or claude plugin configure unramble:

SettingDefault
Modelsonnetsonnet (fast, light on your plan) or opus (the most faithful)
Names you often sayemptycomma-separated names of your tools and projects, so a dictation slip is spelled right

Troubleshooting

/unramble status shows the model and whether Ctrl+G is bound.

  • Ctrl+G opens an editor instead. Ctrl+G is not bound yet: run /unramble setup, then start a new session.
  • No ⌃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.
  • A toast says it kept your text. The rewrite dropped or added a value (it names which), so your draft stayed. Press Ctrl+G again to retry, or send the draft as it is.
  • A long paste stays as [Pasted text #N]. Claude Code folds long pastes into a chip that plugins cannot read, so that text is kept as is.
  • A toast says the model call failed. It gives the reason: not signed in, usage limit reached, no connection, or a timeout. Your draft stays as it was.

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.

Source 4 files
hooks/register.tsx 277 lines
1import { 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}
277
hooks/keybindings.js 129 lines
1// @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`
129
hooks/refine.ts 221 lines
1// 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}
221
types/index.d.ts 16 lines
1export 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