unramble 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -2
- package/cli/unramble.mjs +103 -0
- package/hooks/keybindings.js +132 -0
- package/package.json +34 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayush Yadav
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,206 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/wordmark-dark.svg">
|
|
4
|
+
<img src="assets/wordmark.svg" alt="unramble" width="360">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<p align="center"><b>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.</b></p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="#install">Install</a> ·
|
|
12
|
+
<a href="#what-it-does">What it does</a> ·
|
|
13
|
+
<a href="#plan-or-api-key">Plan or API key</a> ·
|
|
14
|
+
<a href="docs/BENCHMARKS.md">Benchmarks</a> ·
|
|
15
|
+
<a href="docs/HOW-IT-WORKS.md">How it works</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://github.com/yadava5/unramble/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/yadava5/unramble/actions/workflows/ci.yml/badge.svg"></a>
|
|
20
|
+
<a href="https://www.npmjs.com/package/unramble"><img alt="npm" src="https://img.shields.io/npm/v/unramble"></a>
|
|
21
|
+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
22
|
+
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-d97757">
|
|
23
|
+
<img alt="No API key needed" src="https://img.shields.io/badge/API%20key-not%20needed-2ea44f">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="assets/demo.gif" alt="A rough dictated prompt is typed into Claude Code, Ctrl+G is pressed, the band reads refining…, and three seconds later the box holds a clean numbered prompt" width="820">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
You talk faster than you type, so you dictate. Then Claude Code gets *"uh no wait, not the
|
|
31
|
+
handler, the session middleware"* and takes every word literally. unramble rewrites the draft
|
|
32
|
+
before you send it: fillers and false starts gone, the correction applied, separate asks
|
|
33
|
+
numbered, standing rules under *Constraints*. Same meaning — nothing added, nothing you said
|
|
34
|
+
lost. Ctrl+G again puts your original back.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Pick one. Both install the same plugin and bind Ctrl+G.
|
|
39
|
+
|
|
40
|
+
**A. In your terminal** (zsh, bash, PowerShell — not inside Claude Code; needs Node 18+):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx unramble
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**B. Inside Claude Code** — start `claude`, then type these at its `❯` prompt, one at a time:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
/plugin install unramble --marketplace yadava5/unramble
|
|
50
|
+
/unramble setup
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Answer `y` to add the marketplace and pick a scope (user is the default). `setup` binds Ctrl+G;
|
|
54
|
+
it keeps a backup of your `keybindings.json` and never overwrites a key you bound yourself.
|
|
55
|
+
|
|
56
|
+
Then quit Claude Code and start a new session (`claude`), say or type a prompt, and press
|
|
57
|
+
**Ctrl+G**. Built and tested on Claude Code **2.1.308**; see
|
|
58
|
+
[Where it is tested](#where-it-is-tested).
|
|
59
|
+
|
|
60
|
+
## What it does
|
|
61
|
+
|
|
62
|
+
The demo above is a real rewrite (Sonnet on a Claude Code plan, 3.3 s). What was said:
|
|
63
|
+
|
|
64
|
+
> okay so um the login page is broken again I think, like when you hit submit it just spins
|
|
65
|
+
> forever. can you look at the auth handler, uh no wait, not the handler, the session
|
|
66
|
+
> middleware, that's what changed yesterday. and then once it works add a test for it so this
|
|
67
|
+
> doesn't happen again. oh and don't touch the database migrations, we're kind of mid deploy
|
|
68
|
+
|
|
69
|
+
What Claude Code gets:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
The login page is broken again, I think: when you hit submit, it spins forever.
|
|
73
|
+
|
|
74
|
+
1. Look at the session middleware, which is what changed yesterday.
|
|
75
|
+
2. Once it works, add a test for it so this doesn't happen again.
|
|
76
|
+
|
|
77
|
+
Constraints:
|
|
78
|
+
- Don't touch the database migrations; we're mid deploy.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
(It is not perfect: "kind of" mid deploy lost its hedge. The [benchmarks](docs/BENCHMARKS.md)
|
|
82
|
+
count that as a minor defect.)
|
|
83
|
+
|
|
84
|
+
| Key | Does |
|
|
85
|
+
|---|---|
|
|
86
|
+
| **Ctrl+G** | rewrite the draft (the band above the box says `refining…`) |
|
|
87
|
+
| **Ctrl+G** on the rewrite | put your original back, exactly |
|
|
88
|
+
| **Ctrl+G** while it runs | cancel; your draft stays |
|
|
89
|
+
|
|
90
|
+
### What it will not do
|
|
91
|
+
|
|
92
|
+
- **Send anything you did not ask it to.** Nothing leaves the box until you press Ctrl+G.
|
|
93
|
+
- **Lose a machine value.** A rewrite that drops a path, `--flag`, number, URL, `@ref` or
|
|
94
|
+
`[Pasted text]` chip is refused and your draft stays — unless you corrected that value on
|
|
95
|
+
the spot ("src/utils, no wait, src/lib").
|
|
96
|
+
- **Touch pasted logs or code.** They are masked before the model sees the draft and put back
|
|
97
|
+
byte for byte.
|
|
98
|
+
- **Answer your question or do the task.** "Can you check X?" stays a question for Claude Code.
|
|
99
|
+
- **Overwrite you.** If you typed while it ran, your text wins.
|
|
100
|
+
|
|
101
|
+
## Plan or API key
|
|
102
|
+
|
|
103
|
+
| Engine | Pays with | Setup | Default |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| `plan` | your Claude Code subscription, like any prompt | none | ✓ |
|
|
106
|
+
| `api` | your own Anthropic API key, per rewrite | [a separate opt-in step](docs/API-KEY.md) | |
|
|
107
|
+
|
|
108
|
+
The plan engine calls the model through your Claude Code session's own login
|
|
109
|
+
(`$.model.complete`), so there is no key anywhere in the plugin. Each rewrite is one short
|
|
110
|
+
call: ~3k tokens of rules (cached after the first rewrite) plus your words.
|
|
111
|
+
|
|
112
|
+
The API engine exists for people who would rather pay a fraction of a cent per rewrite than
|
|
113
|
+
spend plan usage. It stays off until you switch it on, the key lives in Claude Code's secure
|
|
114
|
+
storage, and a failed API call never falls back to your plan. Read
|
|
115
|
+
[docs/API-KEY.md](docs/API-KEY.md) — and the ~100 lines of
|
|
116
|
+
[`hooks/engine.ts`](hooks/engine.ts) — before you paste a key in.
|
|
117
|
+
|
|
118
|
+
## Settings
|
|
119
|
+
|
|
120
|
+
`/config` (the unramble rows), or `claude plugin configure unramble`:
|
|
121
|
+
|
|
122
|
+
| Setting | Default | |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| Engine | `plan` | `plan` or `api` |
|
|
125
|
+
| Model | `sonnet` | `sonnet` (fast, light on your plan) or `opus` (the most faithful) |
|
|
126
|
+
| Names you often say | empty | comma-separated names of your tools, agents and projects, so a dictation slip is spelled right |
|
|
127
|
+
| Anthropic API key | empty | read only when Engine is `api` |
|
|
128
|
+
|
|
129
|
+
`/unramble status` shows what is live.
|
|
130
|
+
|
|
131
|
+
## How good is it
|
|
132
|
+
|
|
133
|
+
Measured on 39 of the hardest real dictated prompts, two runs each, with Claude Opus judging
|
|
134
|
+
every rewrite against a fixed rubric ([method and full tables](docs/BENCHMARKS.md)):
|
|
135
|
+
|
|
136
|
+
| Model | Rewrites with no meaning change | Median time |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| Sonnet (default) | 76/78 | 3.0 s |
|
|
139
|
+
| Opus | 78/78 | 3.2 s |
|
|
140
|
+
|
|
141
|
+
That set is every case that ever failed during tuning, so it is harder than everyday speech.
|
|
142
|
+
It is also one person's speech, in English, about software; the [eval kit](eval/README.md)
|
|
143
|
+
runs the same check on yours. Haiku was tried and dropped (9 major errors in 17 prompts).
|
|
144
|
+
|
|
145
|
+
## Where it is tested
|
|
146
|
+
|
|
147
|
+
Ctrl+G reaches the plugin as whatever bytes the terminal sends for it, and terminals send it
|
|
148
|
+
two ways, so both were tried with a real keypress:
|
|
149
|
+
|
|
150
|
+
| Where | Ctrl+G arrives as | Result |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| kitty, macOS | kitty keyboard protocol | rewrite, restore, cancel, `/unramble status` ✓ |
|
|
153
|
+
| Terminal.app, macOS | legacy `0x07` | rewrite and restore ✓ |
|
|
154
|
+
| Ghostty 1.3, macOS | not measured | rewrite and restore ✓ |
|
|
155
|
+
| VS Code terminal, macOS | not measured | rewrite and restore ✓ (Ctrl+G reaches Claude Code, not VS Code's *Go to Line*) |
|
|
156
|
+
| Cursor terminal, macOS | not measured | rewrite and restore ✓ |
|
|
157
|
+
| Linux (Debian, tmux) | legacy `0x07` | key reaches the plugin and the model call goes out ✓ |
|
|
158
|
+
| Windows, Linux, macOS (CI) | — | install, Ctrl+G binding, uninstall and all tests, every push |
|
|
159
|
+
|
|
160
|
+
Not yet tried by hand: iTerm2, Windows Terminal, and Kiro's terminal (Kiro is built on the same
|
|
161
|
+
editor as VS Code and Cursor). If one does not work, please
|
|
162
|
+
[open an issue](https://github.com/yadava5/unramble/issues/new/choose).
|
|
163
|
+
|
|
164
|
+
## Limits
|
|
165
|
+
|
|
166
|
+
- **Terminal UI only.** The Ctrl+G chord reaches plugins in Claude Code's terminal interface
|
|
167
|
+
(`claude` run in a terminal, including the one inside VS Code or Cursor). The VS Code
|
|
168
|
+
extension's own chat panel and the desktop app are not supported.
|
|
169
|
+
- **A short window can hide the band.** In an 80×24 window with a tall status line, Claude Code
|
|
170
|
+
may not draw the `⌃G refine` row; Ctrl+G still works.
|
|
171
|
+
- **Long pastes stay as chips.** When a dictation app pastes a long block, Claude Code folds it
|
|
172
|
+
into a `[Pasted text #N]` chip that plugins cannot read in 2.1.308, so that text is kept as
|
|
173
|
+
is. Claude Code's built-in voice input, macOS Dictation and typing all work fully.
|
|
174
|
+
- **Garbled speech stays garbled.** If the transcription is wrong in a way context cannot fix
|
|
175
|
+
("Apple ID" for a project name), the rewrite keeps it as heard rather than guess. Add the
|
|
176
|
+
name to *Names you often say*.
|
|
177
|
+
- **The plugin API is early access.** CI runs the latest Claude Code weekly to catch a release
|
|
178
|
+
that moves it.
|
|
179
|
+
|
|
180
|
+
## Uninstall
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
npx unramble uninstall
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
That removes the plugin and takes back only the two `ctrl+g` bindings setup added. Without
|
|
187
|
+
Node: `claude plugin uninstall unramble@unramble`, then delete those two lines from
|
|
188
|
+
`keybindings.json` yourself.
|
|
189
|
+
|
|
190
|
+
## Where it came from
|
|
191
|
+
|
|
192
|
+
I dictate long prompts into Claude Code and wanted them organised before the model reads
|
|
193
|
+
them. It started as a personal `$VISUAL` script on 2026-10-17 and went through eight rule
|
|
194
|
+
versions in two days, each measured before it shipped; the history is in the git log and
|
|
195
|
+
[docs/BENCHMARKS.md](docs/BENCHMARKS.md). This public version is a rebuild that runs on your
|
|
196
|
+
plan instead of an API key. I used Claude Code to build it.
|
|
197
|
+
|
|
198
|
+
## Contributing
|
|
199
|
+
|
|
200
|
+
Reports of a rewrite that changed your meaning are the most useful thing you can send; there
|
|
201
|
+
is an [issue template](https://github.com/yadava5/unramble/issues/new/choose) for it. See
|
|
202
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
[MIT](LICENSE) © 2026 Ayush Yadav
|
package/cli/unramble.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx unramble [install | uninstall | status] — sets the plugin up in one paste.
|
|
3
|
+
//
|
|
4
|
+
// install: claude plugin install unramble --marketplace yadava5/unramble, then binds Ctrl+G
|
|
5
|
+
// in keybindings.json (a backup is kept beside it).
|
|
6
|
+
// uninstall: removes the plugin and takes back only the keybindings install added.
|
|
7
|
+
// status: shows whether the plugin is installed and Ctrl+G is bound.
|
|
8
|
+
//
|
|
9
|
+
// No dependencies. It changes nothing but the plugin install and those two keybindings.
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { isBound, parse, plan, serialize, unplan } from '../hooks/keybindings.js'
|
|
17
|
+
|
|
18
|
+
const MARKETPLACE = process.env.UNRAMBLE_MARKETPLACE || 'yadava5/unramble'
|
|
19
|
+
const PLUGIN = 'unramble'
|
|
20
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
|
|
21
|
+
const keybindings = join(configDir, 'keybindings.json')
|
|
22
|
+
|
|
23
|
+
const say = (line = '') => process.stdout.write(`${line}\n`)
|
|
24
|
+
const fail = line => {
|
|
25
|
+
process.stderr.write(`unramble: ${line}\n`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runs `claude` with the person's own environment and terminal; true on success. */
|
|
30
|
+
function claude(args, { quiet = false } = {}) {
|
|
31
|
+
const r = spawnSync('claude', args, {
|
|
32
|
+
stdio: quiet ? 'pipe' : 'inherit',
|
|
33
|
+
shell: process.platform === 'win32',
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
})
|
|
36
|
+
if (r.error?.code === 'ENOENT') {
|
|
37
|
+
fail('Claude Code is not on your PATH. Install it first: https://code.claude.com/docs/en/setup')
|
|
38
|
+
}
|
|
39
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readKeys() {
|
|
43
|
+
const text = existsSync(keybindings) ? readFileSync(keybindings, 'utf8') : null
|
|
44
|
+
try {
|
|
45
|
+
return { text, file: parse(text) }
|
|
46
|
+
} catch (err) {
|
|
47
|
+
fail(`${keybindings} is not valid keybindings JSON (${err.message}); left it alone.`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bindKeys() {
|
|
52
|
+
const { text, file } = readKeys()
|
|
53
|
+
const next = plan(file)
|
|
54
|
+
if (next.kind === 'ready') return say('✓ Ctrl+G was already bound')
|
|
55
|
+
if (next.kind === 'conflict') {
|
|
56
|
+
say(`! Left keybindings alone: ${next.why}.`)
|
|
57
|
+
say(` Free that chord in ${keybindings}, then run /unramble setup in Claude Code.`)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (text !== null) copyFileSync(keybindings, `${keybindings}.unramble-backup`)
|
|
61
|
+
writeFileSync(keybindings, serialize(next.file))
|
|
62
|
+
say(`✓ Bound Ctrl+G in ${keybindings}${text !== null ? ' (backup: keybindings.json.unramble-backup)' : ''}`)
|
|
63
|
+
for (const c of next.changes) say(` ${c}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function install() {
|
|
67
|
+
say(`Installing unramble from ${MARKETPLACE}…`)
|
|
68
|
+
if (!claude(['plugin', 'install', PLUGIN, '--marketplace', MARKETPLACE]).ok) {
|
|
69
|
+
fail('the plugin install did not finish; nothing else was changed.')
|
|
70
|
+
}
|
|
71
|
+
bindKeys()
|
|
72
|
+
say()
|
|
73
|
+
say('Done. Start a new Claude Code session, speak or type a rough prompt, and press Ctrl+G.')
|
|
74
|
+
say('It runs on your Claude Code plan. Settings: /config (unramble rows) or /unramble status.')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function uninstall() {
|
|
78
|
+
const removed = claude(['plugin', 'uninstall', `${PLUGIN}@${PLUGIN}`]).ok
|
|
79
|
+
if (removed) claude(['plugin', 'marketplace', 'remove', PLUGIN], { quiet: true })
|
|
80
|
+
const { text, file } = readKeys()
|
|
81
|
+
const back = unplan(file)
|
|
82
|
+
if (back.changes.length) {
|
|
83
|
+
if (text !== null) copyFileSync(keybindings, `${keybindings}.unramble-backup`)
|
|
84
|
+
writeFileSync(keybindings, serialize(back.file))
|
|
85
|
+
say(`✓ Took back the Ctrl+G bindings in ${keybindings}`)
|
|
86
|
+
}
|
|
87
|
+
say(removed ? '✓ unramble is uninstalled' : '! The plugin was not installed (or the uninstall failed); see above.')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function status() {
|
|
91
|
+
const listed = claude(['plugin', 'list'], { quiet: true })
|
|
92
|
+
const installed = new RegExp(`\\b${PLUGIN}@`).test(listed.out)
|
|
93
|
+
say(`Plugin: ${installed ? 'installed' : 'not installed'}`)
|
|
94
|
+
say(`Ctrl+G: ${isBound(readKeys().file) ? 'bound' : 'not bound'} (${keybindings})`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const command = process.argv[2] ?? 'install'
|
|
98
|
+
const commands = { install, uninstall, status }
|
|
99
|
+
if (command === '--help' || command === '-h' || !(command in commands)) {
|
|
100
|
+
say('Usage: npx unramble [install | uninstall | status]')
|
|
101
|
+
process.exit(command in commands || command.startsWith('-') ? 0 : 1)
|
|
102
|
+
}
|
|
103
|
+
commands[command]()
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
12
|
+
export const ACTION = 'app:cycleDiffBase'
|
|
13
|
+
export const CHORD = 'ctrl+g'
|
|
14
|
+
const EDITOR = 'chat:externalEditor'
|
|
15
|
+
const 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} */
|
|
27
|
+
const SKELETON = {
|
|
28
|
+
$schema: 'https://www.schemastore.org/claude-code-keybindings.json',
|
|
29
|
+
$docs: 'https://code.claude.com/docs/en/keybindings',
|
|
30
|
+
bindings: [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {KeybindingsFile} file @returns {KeybindingsFile} */
|
|
34
|
+
const clone = file => JSON.parse(JSON.stringify(file))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses keybindings.json text; an absent file is an empty one. Throws on anything else.
|
|
38
|
+
* @param {string | null} text
|
|
39
|
+
* @returns {KeybindingsFile}
|
|
40
|
+
*/
|
|
41
|
+
export function parse(text) {
|
|
42
|
+
if (text === null || !text.trim()) return clone(SKELETON)
|
|
43
|
+
const data = JSON.parse(text)
|
|
44
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.bindings)) {
|
|
45
|
+
throw new Error('keybindings.json has no "bindings" list')
|
|
46
|
+
}
|
|
47
|
+
return data
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {KeybindingsFile} file @param {string} context @returns {Block | undefined} */
|
|
51
|
+
const blockOf = (file, context) =>
|
|
52
|
+
file.bindings.find(b => b && b.context === context && b.bindings && typeof b.bindings === 'object')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether Ctrl+G already reaches unramble.
|
|
56
|
+
* @param {KeybindingsFile} file
|
|
57
|
+
*/
|
|
58
|
+
export function isBound(file) {
|
|
59
|
+
return blockOf(file, 'Global')?.bindings[CHORD] === ACTION && blockOf(file, 'Chat')?.bindings[CHORD] === null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What setup would do to this file, without doing it.
|
|
64
|
+
* @param {KeybindingsFile} current
|
|
65
|
+
* @returns {SetupPlan}
|
|
66
|
+
*/
|
|
67
|
+
export function plan(current) {
|
|
68
|
+
if (isBound(current)) return { kind: 'ready' }
|
|
69
|
+
const globalNow = blockOf(current, 'Global')?.bindings[CHORD]
|
|
70
|
+
if (globalNow !== undefined && globalNow !== ACTION) {
|
|
71
|
+
return { kind: 'conflict', why: `ctrl+g is bound to "${globalNow}" in your Global keybindings` }
|
|
72
|
+
}
|
|
73
|
+
const chatNow = blockOf(current, 'Chat')?.bindings[CHORD]
|
|
74
|
+
if (chatNow !== undefined && chatNow !== null && chatNow !== EDITOR) {
|
|
75
|
+
return { kind: 'conflict', why: `ctrl+g is bound to "${chatNow}" in your Chat keybindings` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const file = clone(current)
|
|
79
|
+
/** @type {string[]} */
|
|
80
|
+
const changes = []
|
|
81
|
+
/** @param {string} context @returns {Block} */
|
|
82
|
+
const ensure = context => {
|
|
83
|
+
let block = blockOf(file, context)
|
|
84
|
+
if (!block) {
|
|
85
|
+
block = { context, bindings: {} }
|
|
86
|
+
file.bindings.push(block)
|
|
87
|
+
}
|
|
88
|
+
return block
|
|
89
|
+
}
|
|
90
|
+
const global = ensure('Global')
|
|
91
|
+
if (global.bindings[CHORD] !== ACTION) {
|
|
92
|
+
global.bindings[CHORD] = ACTION
|
|
93
|
+
changes.push(`Global: ctrl+g → ${ACTION} (lets the unramble band take Ctrl+G)`)
|
|
94
|
+
}
|
|
95
|
+
const chat = ensure('Chat')
|
|
96
|
+
if (chat.bindings[CHORD] !== null) {
|
|
97
|
+
chat.bindings[CHORD] = null
|
|
98
|
+
changes.push('Chat: ctrl+g → unbound (it opened the external editor)')
|
|
99
|
+
}
|
|
100
|
+
if (!Object.values(chat.bindings).includes(EDITOR) && chat.bindings[EDITOR_CHORD] === undefined) {
|
|
101
|
+
chat.bindings[EDITOR_CHORD] = EDITOR
|
|
102
|
+
changes.push(`Chat: ${EDITOR_CHORD} → ${EDITOR} (the external editor keeps a key)`)
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'change', file, changes }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Takes back exactly what setup added, and nothing the person set themselves: Global ctrl+g
|
|
109
|
+
* only while it still names unramble's action, Chat ctrl+g only while it is still unbound.
|
|
110
|
+
* The ctrl+x ctrl+e editor key stays; it is Claude Code's own action.
|
|
111
|
+
* @param {KeybindingsFile} current
|
|
112
|
+
* @returns {{ file: KeybindingsFile, changes: string[] }}
|
|
113
|
+
*/
|
|
114
|
+
export function unplan(current) {
|
|
115
|
+
const file = clone(current)
|
|
116
|
+
/** @type {string[]} */
|
|
117
|
+
const changes = []
|
|
118
|
+
const global = blockOf(file, 'Global')
|
|
119
|
+
if (global?.bindings[CHORD] === ACTION) {
|
|
120
|
+
delete global.bindings[CHORD]
|
|
121
|
+
changes.push('Global: ctrl+g removed')
|
|
122
|
+
}
|
|
123
|
+
const chat = blockOf(file, 'Chat')
|
|
124
|
+
if (chat && chat.bindings[CHORD] === null) {
|
|
125
|
+
delete chat.bindings[CHORD]
|
|
126
|
+
changes.push('Chat: ctrl+g back to its default (the external editor)')
|
|
127
|
+
}
|
|
128
|
+
return { file, changes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {KeybindingsFile} file */
|
|
132
|
+
export const serialize = file => `${JSON.stringify(file, null, 2)}\n`
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unramble",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Ctrl+G turns a rambling, dictated prompt into a clean, ordered one, in place, on your own Claude Code plan.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude-code-plugin",
|
|
8
|
+
"dictation",
|
|
9
|
+
"voice",
|
|
10
|
+
"prompt",
|
|
11
|
+
"rewrite"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/yadava5/unramble#readme",
|
|
14
|
+
"bugs": "https://github.com/yadava5/unramble/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/yadava5/unramble.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Ayush Yadav (https://github.com/yadava5)",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"unramble": "cli/unramble.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"cli/unramble.mjs",
|
|
27
|
+
"hooks/keybindings.js"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "claude plugin validate . && claude plugin test . && node --test cli/*.test.mjs",
|
|
34
|
+
"test:cli": "node --test cli/*.test.mjs"
|
|
35
|
+
}
|
|
36
|
+
}
|