ccx-relay 1.5.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/.env.example +12 -0
- package/README.md +171 -0
- package/bin/ccx-init.js +149 -0
- package/bin/ccx.js +110 -0
- package/package.json +43 -0
- package/src/config.js +99 -0
- package/src/gemini.js +80 -0
- package/src/input.js +128 -0
- package/src/ui.js +65 -0
package/.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Google Gemini API key (required).
|
|
2
|
+
# Get one at https://aistudio.google.com/apikey
|
|
3
|
+
GEMINI_API_KEY=
|
|
4
|
+
|
|
5
|
+
# Gemini model to use for prompt rewriting (optional).
|
|
6
|
+
# Default: gemini-3.5-flash
|
|
7
|
+
GEMINI_MODEL=gemini-3.5-flash
|
|
8
|
+
|
|
9
|
+
# Trailing marker that triggers "enhance current line" when you press Enter
|
|
10
|
+
# (optional). Type your prompt, append this marker, then press Enter once to
|
|
11
|
+
# enhance (not submit) - press Enter again to actually submit. Default: ;;
|
|
12
|
+
CCX_MARKER=;;
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# ccx
|
|
2
|
+
|
|
3
|
+
A transparent PTY wrapper for [Claude Code](https://docs.anthropic.com/claude/docs/claude-code) (or any interactive
|
|
4
|
+
CLI) that lets you refine your prompt with Gemini AI before you submit it — without leaving your normal terminal
|
|
5
|
+
session.
|
|
6
|
+
|
|
7
|
+
Run `ccx claude` instead of `claude`. Everything looks and behaves like a normal Claude Code session. When you want a
|
|
8
|
+
rough line cleaned up, append a trailing marker (default `;;`) and press Enter once — `ccx` sends what you typed
|
|
9
|
+
(minus the marker) to Gemini, gets back a grammatically corrected / clearer rewrite of the same intent, and swaps it
|
|
10
|
+
in-place on the line, as if you'd typed the better version yourself, without submitting it yet. Review it, then press
|
|
11
|
+
Enter again as usual to actually submit.
|
|
12
|
+
|
|
13
|
+
This marker-based trigger (rather than a keyboard shortcut) is deliberate: a hotkey has to survive three independent
|
|
14
|
+
layers of keybinding claims — the OS/global-hotkey tools, the terminal app (VS Code and its extensions), and the
|
|
15
|
+
wrapped CLI's own input handling — and on a sufficiently customized machine, every `Ctrl`/`Alt` combination worth
|
|
16
|
+
trying can already be spoken for by one of them. Plain typed text has no such competition.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
- **Node.js 18+** (needed for the built-in `fetch` used to call the Gemini API).
|
|
21
|
+
- **A Gemini API key** — get one at https://aistudio.google.com/apikey. As of mid-2026, Google issues new keys as
|
|
22
|
+
"Auth keys" starting with `AQ.` (the older `AIza`-prefixed "Standard" keys are being phased out through
|
|
23
|
+
September 2026). `ccx` sends the key via the `x-goog-api-key` header, which works for both key types.
|
|
24
|
+
- **Windows only:** `node-pty` is a native module and needs to be compiled during `npm install`. Make sure you have:
|
|
25
|
+
- Visual Studio Build Tools with the "Desktop development with C++" workload, and
|
|
26
|
+
- Python 3.x on your PATH.
|
|
27
|
+
|
|
28
|
+
If `npm install` fails while building `node-pty`, install the above and run `npm rebuild node-pty`.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g ccx-relay
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
Run the one-time setup wizard:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ccx init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This will prompt for your Gemini API key (get one at https://aistudio.google.com/apikey), validate it, let you pick a model, and save config to your user directory (`%APPDATA%\ccx\config.json` on Windows, `~/.config/ccx/config.json` on macOS/Linux). Config survives npm upgrades.
|
|
45
|
+
|
|
46
|
+
Other init commands:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ccx init --show # print current config (key masked)
|
|
50
|
+
ccx init --reset # wipe config and start over
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Environment variable override
|
|
54
|
+
|
|
55
|
+
You can skip `ccx init` and set env vars directly (useful for CI):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export GEMINI_API_KEY=AIza...
|
|
59
|
+
export GEMINI_MODEL=gemini-2.5-flash # optional
|
|
60
|
+
export CCX_MARKER=;; # optional
|
|
61
|
+
export CCX_TIMEOUT=8 # optional, seconds
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
ccx claude # launch Claude Code behind the ccx relay
|
|
68
|
+
ccx claude --resume # any extra args are passed straight through
|
|
69
|
+
ccx bash # or any other command — ccx just wraps argv
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
While the wrapped process is running:
|
|
73
|
+
|
|
74
|
+
- Type normally — every keystroke is forwarded to the child process exactly as if `ccx` weren't there.
|
|
75
|
+
- **Trigger with marker:** type `;;` at the end of your line, then press **Enter**. ccx enhances the line and replaces it in place — this first Enter does *not* submit.
|
|
76
|
+
- **Trigger with shortcut:** press **Alt+M** at any point on a non-empty line to enhance immediately without a marker.
|
|
77
|
+
- After enhancement, a Braille spinner animates while Gemini rewrites your prompt, then shows `✓ Enhanced` on success or `✗ error message` on failure.
|
|
78
|
+
- Review the rewritten line, then press **Enter** to submit it to the child process.
|
|
79
|
+
- A plain line with no trigger submits immediately on Enter, exactly as if `ccx` weren't there.
|
|
80
|
+
- Arrow keys, Ctrl+C, and other control sequences pass through untouched.
|
|
81
|
+
|
|
82
|
+
### Changing the marker
|
|
83
|
+
|
|
84
|
+
Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes needed.
|
|
85
|
+
|
|
86
|
+
## Testing it locally
|
|
87
|
+
|
|
88
|
+
1. `npm install`
|
|
89
|
+
2. `npm run test` — runs `node --check` against the CLI entry point to catch syntax errors.
|
|
90
|
+
3. `npm link`
|
|
91
|
+
4. Set up `.env` with a real `GEMINI_API_KEY`.
|
|
92
|
+
5. Sanity-check the relay mechanics with a safe command first, before pointing it at `claude`:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
ccx cmd # Windows Command Prompt
|
|
96
|
+
ccx powershell # or PowerShell
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
You should see a completely normal shell. Type a sentence with a typo, e.g. `pls fix teh bug in server.js;;`,
|
|
100
|
+
then press **Enter**. You should see `[ccx] enhancing...` flash briefly, then the line rewritten in place
|
|
101
|
+
(e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter again to run it as a normal
|
|
102
|
+
shell command.
|
|
103
|
+
|
|
104
|
+
6. Once the mechanics check out, run it against the real target:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
ccx claude
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Known limitations
|
|
111
|
+
|
|
112
|
+
- **Works best for single-line prompts typed straight through.** The line buffer is a plain JS string built up as
|
|
113
|
+
you type; it does not model cursor position.
|
|
114
|
+
- **Heavy mid-line editing via arrow keys is not fully tracked.** Escape sequences (arrow keys, Home/End, etc.) are
|
|
115
|
+
detected and forwarded to the child untouched so navigation still *works* visually, but `ccx`'s internal buffer
|
|
116
|
+
does not move its own "cursor" — it only appends on printable input and pops on backspace. If you arrow back into
|
|
117
|
+
the middle of a line and start editing there, the buffer sent to Gemini (and the backspace count used to erase the
|
|
118
|
+
line) can drift out of sync with what's actually on screen. Worth testing carefully before relying on it for
|
|
119
|
+
heavily-edited multi-line pastes.
|
|
120
|
+
- **Byte-wise, not codepoint-wise.** Input is processed one byte at a time; multi-byte UTF-8 characters (accents,
|
|
121
|
+
emoji, non-Latin scripts) may not round-trip through the buffer correctly, though they are still forwarded to the
|
|
122
|
+
child untouched when you're not invoking the hotkey.
|
|
123
|
+
- **Status line display is best-effort.** `ccx` uses ANSI save/restore-cursor sequences to show and clear the
|
|
124
|
+
`[ccx] enhancing...` message on its own line. This works in standard ANSI/VT terminals (including the VS Code
|
|
125
|
+
integrated terminal) but isn't guaranteed on every terminal emulator.
|
|
126
|
+
- **The marker itself is reserved.** If you genuinely need a prompt to end with `;;` literally, either add a
|
|
127
|
+
trailing space after it or change `CCX_MARKER` to something you don't otherwise type.
|
|
128
|
+
|
|
129
|
+
## Windows-specific notes
|
|
130
|
+
|
|
131
|
+
- **`.cmd`/`.bat` shims:** `node-pty` spawns processes via Windows' `CreateProcess`, which cannot directly execute
|
|
132
|
+
`.cmd`/`.bat` files (this is how `claude` and many other npm-installed global CLIs are shimmed on Windows). `ccx`
|
|
133
|
+
works around this by routing the target command through `cmd.exe /c` on Windows. You shouldn't need to do anything
|
|
134
|
+
differently — `ccx claude` just works — but if you add your own wrapped commands, keep in mind they're run via
|
|
135
|
+
`cmd.exe`.
|
|
136
|
+
- **ConPTY vs. WSL:** on native Windows, `node-pty` uses ConPTY (Windows' native pseudoconsole API). This is
|
|
137
|
+
different from running inside WSL, where `node-pty` behaves like it does on Linux/macOS (real PTYs). Resize
|
|
138
|
+
events, signal delivery, and raw-mode behavior can differ subtly between the two — if you run `ccx` inside a WSL
|
|
139
|
+
terminal instead of a native Windows one, treat it as a separate environment to re-test.
|
|
140
|
+
- **"win32-input-mode" can replace plain keys with escape sequences.** Some CLIs (Claude Code included) enable a
|
|
141
|
+
ConPTY/Windows Terminal feature called win32-input-mode on startup, for full keyboard fidelity (e.g. to
|
|
142
|
+
distinguish Enter from Shift+Enter). Once enabled, the *real* host terminal stops sending some keys as plain
|
|
143
|
+
bytes and instead encodes them as `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` (virtual-key code, scan code, Unicode char, key
|
|
144
|
+
down/up flag, control-key state, repeat count) — this was observed for both Enter (`Vk=13`) and Backspace
|
|
145
|
+
(`Vk=8`) while wrapping `claude`, arriving as a pair of sequences (key-down then key-up) in a single stdin read.
|
|
146
|
+
`ccx` decodes this format specifically to recognize Enter and Backspace; if you see other keys behaving oddly
|
|
147
|
+
when wrapping a different CLI on Windows, they may need the same treatment — check `parseWin32InputSeq` in
|
|
148
|
+
`bin/ccx.js`.
|
|
149
|
+
- **Ctrl+C:** in raw mode with ConPTY's virtual-terminal input enabled, Ctrl+C arrives as byte `0x03` through stdin
|
|
150
|
+
(like POSIX) rather than as a Windows console control event, so it's forwarded to the child like any other
|
|
151
|
+
keystroke rather than killing `ccx` itself.
|
|
152
|
+
|
|
153
|
+
## Publishing
|
|
154
|
+
|
|
155
|
+
The npm package is named `ccx-relay` (`ccx` itself is already taken on npm). The command you run stays `ccx` — that
|
|
156
|
+
comes from the `bin` field, not the package name — so nothing about day-to-day usage changes.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm login
|
|
160
|
+
npm publish
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For subsequent releases:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm version patch # or minor / major
|
|
167
|
+
npm publish
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`npm version patch` bumps the version in `package.json`, creates a git commit and tag (if this is a git repo), which
|
|
171
|
+
you can then push along with the new publish.
|
package/bin/ccx-init.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { rmSync, existsSync } from 'node:fs';
|
|
5
|
+
import { load, save, configPath } from '../src/config.js';
|
|
6
|
+
import { enhance } from '../src/gemini.js';
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
// Hidden input helper for API key
|
|
11
|
+
async function promptHidden(question) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
process.stdout.write(question);
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: null, terminal: false });
|
|
15
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
16
|
+
let input = '';
|
|
17
|
+
process.stdin.on('data', function onData(buf) {
|
|
18
|
+
for (let i = 0; i < buf.length; i++) {
|
|
19
|
+
const byte = buf[i];
|
|
20
|
+
if (byte === 0x0d || byte === 0x0a) {
|
|
21
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
22
|
+
process.stdout.write('\n');
|
|
23
|
+
process.stdin.removeListener('data', onData);
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve(input);
|
|
26
|
+
return;
|
|
27
|
+
} else if (byte === 0x7f || byte === 0x08) {
|
|
28
|
+
if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
|
|
29
|
+
} else if (byte === 0x03) {
|
|
30
|
+
process.exit(0);
|
|
31
|
+
} else if (byte >= 0x20) {
|
|
32
|
+
input += String.fromCharCode(byte);
|
|
33
|
+
process.stdout.write('*');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Regular prompt helper
|
|
41
|
+
async function prompt(rl, question) {
|
|
42
|
+
return new Promise(resolve => rl.question(question, resolve));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Mask API key: show first 6 chars + ****...
|
|
46
|
+
function maskKey(key) {
|
|
47
|
+
if (!key) return '(not set)';
|
|
48
|
+
if (key.length <= 6) return key + '****...';
|
|
49
|
+
return key.slice(0, 6) + '****...';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Show current config
|
|
53
|
+
function showConfig() {
|
|
54
|
+
const config = load();
|
|
55
|
+
const path = configPath();
|
|
56
|
+
|
|
57
|
+
console.log(`Config: ${path}\n`);
|
|
58
|
+
console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
|
|
59
|
+
console.log(` geminiModel: ${config.geminiModel}`);
|
|
60
|
+
console.log(` marker: ${config.marker}`);
|
|
61
|
+
console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Reset config
|
|
65
|
+
function resetConfig() {
|
|
66
|
+
const path = configPath();
|
|
67
|
+
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
rmSync(path);
|
|
70
|
+
console.log('Config reset. Run `ccx init` to set up again.');
|
|
71
|
+
} else {
|
|
72
|
+
console.log('No config file found — nothing to reset.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Interactive wizard
|
|
77
|
+
async function runWizard() {
|
|
78
|
+
console.log('\n Welcome to ccx setup\n');
|
|
79
|
+
|
|
80
|
+
// Step 1: Prompt for API key
|
|
81
|
+
const key = await promptHidden(' API key: ');
|
|
82
|
+
if (!key) {
|
|
83
|
+
console.log('No key entered. Aborting.');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Step 2: Test the key
|
|
88
|
+
process.stdout.write(' Testing key... ');
|
|
89
|
+
try {
|
|
90
|
+
await enhance('hello', { geminiApiKey: key, geminiModel: 'gemini-2.5-flash', timeoutSeconds: 10 });
|
|
91
|
+
console.log('\x1b[32m✓ Valid\x1b[0m');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Step 3: Model picker
|
|
98
|
+
const models = [
|
|
99
|
+
'gemini-2.5-flash',
|
|
100
|
+
'gemini-1.5-flash',
|
|
101
|
+
'gemini-2.5-pro'
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const rl = createInterface({
|
|
105
|
+
input: process.stdin,
|
|
106
|
+
output: process.stdout,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(' Model:');
|
|
111
|
+
console.log(' 1) gemini-2.5-flash (default)');
|
|
112
|
+
console.log(' 2) gemini-1.5-flash');
|
|
113
|
+
console.log(' 3) gemini-2.5-pro');
|
|
114
|
+
|
|
115
|
+
const modelChoice = await prompt(rl, ' Choice [1]: ');
|
|
116
|
+
const modelIndex = modelChoice.trim() === '' || modelChoice.trim() === '1' ? 0 :
|
|
117
|
+
modelChoice.trim() === '2' ? 1 :
|
|
118
|
+
modelChoice.trim() === '3' ? 2 : 0;
|
|
119
|
+
const model = models[modelIndex];
|
|
120
|
+
|
|
121
|
+
// Step 4: Trigger marker
|
|
122
|
+
const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
|
|
123
|
+
const marker = markerAnswer.trim() || ';;';
|
|
124
|
+
|
|
125
|
+
// Step 5: Timeout seconds
|
|
126
|
+
const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
|
|
127
|
+
const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
|
|
128
|
+
|
|
129
|
+
rl.close();
|
|
130
|
+
|
|
131
|
+
// Step 6: Save config
|
|
132
|
+
save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
|
|
133
|
+
|
|
134
|
+
// Step 7: Success message
|
|
135
|
+
console.log(`\n Config saved to ${configPath()}`);
|
|
136
|
+
console.log(' Run `ccx claude` to start.\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Main dispatch
|
|
140
|
+
if (args.length === 0) {
|
|
141
|
+
runWizard();
|
|
142
|
+
} else if (args[0] === '--show') {
|
|
143
|
+
showConfig();
|
|
144
|
+
} else if (args[0] === '--reset') {
|
|
145
|
+
resetConfig();
|
|
146
|
+
} else {
|
|
147
|
+
console.error(`Unknown command: ${args[0]}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
package/bin/ccx.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
import { load } from '../src/config.js';
|
|
5
|
+
import { enhance, RateLimitError, AuthError, ModelError, TimeoutError, NetworkError } from '../src/gemini.js';
|
|
6
|
+
import { start as spinnerStart, stop as spinnerStop, clear as spinnerClear } from '../src/ui.js';
|
|
7
|
+
import { createInputHandler } from '../src/input.js';
|
|
8
|
+
|
|
9
|
+
let pty;
|
|
10
|
+
try {
|
|
11
|
+
pty = (await import('node-pty')).default;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
process.stderr.write(
|
|
14
|
+
'[ccx] Failed to load node-pty. On Windows install "Desktop development with C++"\n' +
|
|
15
|
+
'workload (Visual Studio Build Tools) and Python, then run: npm rebuild node-pty\n' +
|
|
16
|
+
`Original error: ${err.message}\n`
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const config = load();
|
|
22
|
+
|
|
23
|
+
if (!config.geminiApiKey) {
|
|
24
|
+
process.stderr.write('[ccx] No API key found. Run `ccx init` to set up.\n');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
29
|
+
process.stderr.write('[ccx] ccx must be run in an interactive terminal.\n');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const argv = process.argv.slice(2);
|
|
34
|
+
const targetCmd = argv[0] || 'claude';
|
|
35
|
+
const targetArgs= argv.slice(1);
|
|
36
|
+
|
|
37
|
+
const isWindows = process.platform === 'win32';
|
|
38
|
+
const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
|
|
39
|
+
const shellArgs = isWindows ? ['/d', '/s', '/c', [targetCmd, ...targetArgs].join(' ')] : targetArgs;
|
|
40
|
+
|
|
41
|
+
const ptyProcess = pty.spawn(shellFile, shellArgs, {
|
|
42
|
+
name: 'xterm-color',
|
|
43
|
+
cols: process.stdout.columns || 80,
|
|
44
|
+
rows: process.stdout.rows || 24,
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
env: process.env,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
ptyProcess.onData(data => process.stdout.write(data));
|
|
50
|
+
|
|
51
|
+
process.stdout.on('resize', () => {
|
|
52
|
+
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
process.stdin.setRawMode(true);
|
|
56
|
+
process.stdin.resume();
|
|
57
|
+
|
|
58
|
+
const handler = createInputHandler({
|
|
59
|
+
marker: config.marker,
|
|
60
|
+
|
|
61
|
+
onEnhance: async (line) => {
|
|
62
|
+
// line may include trailing marker (;;) for the ;; trigger — strip it
|
|
63
|
+
const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
|
|
64
|
+
handler.setBusy(true);
|
|
65
|
+
spinnerStart();
|
|
66
|
+
let improved;
|
|
67
|
+
try {
|
|
68
|
+
improved = await enhance(toSend, config);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
let msg = 'Enhancement failed';
|
|
71
|
+
if (err instanceof RateLimitError) msg = 'Quota exceeded';
|
|
72
|
+
else if (err instanceof AuthError) msg = 'Invalid key — run ccx init';
|
|
73
|
+
else if (err instanceof ModelError) msg = 'Model not found — run ccx init';
|
|
74
|
+
else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
|
|
75
|
+
else if (err instanceof NetworkError) msg = 'No connection — original restored';
|
|
76
|
+
await spinnerStop('error', msg);
|
|
77
|
+
// Erase full echoed text (including marker if present), restore clean original
|
|
78
|
+
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
79
|
+
ptyProcess.write(toSend);
|
|
80
|
+
handler.setBusy(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await spinnerStop('success', 'Enhanced');
|
|
84
|
+
// Erase full echoed text (including marker), write improved
|
|
85
|
+
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
86
|
+
ptyProcess.write(improved);
|
|
87
|
+
handler.setBusy(false);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
onSubmit: (_line) => {
|
|
91
|
+
// Enter already forwarded by input.js passthrough
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
onPassthrough: (chunk) => ptyProcess.write(chunk),
|
|
95
|
+
|
|
96
|
+
onCtrlC: () => {
|
|
97
|
+
spinnerClear();
|
|
98
|
+
handler.reset();
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
process.stdin.on('data', chunk => handler.processChunk(chunk));
|
|
103
|
+
|
|
104
|
+
function cleanupAndExit(code) {
|
|
105
|
+
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {}
|
|
106
|
+
process.exit(code == null ? 0 : code);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ptyProcess.onExit(({ exitCode }) => cleanupAndExit(exitCode));
|
|
110
|
+
process.on('SIGINT', () => {});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "ccx-relay",
|
|
4
|
+
"version": "1.5.0",
|
|
5
|
+
"description": "Transparent PTY wrapper for Claude Code (or any CLI) that lets you refine your prompt with Gemini AI before submitting it, without leaving your terminal session.",
|
|
6
|
+
"main": "bin/ccx.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ccx": "bin/ccx.js",
|
|
9
|
+
"ccx-init": "bin/ccx-init.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
".env.example"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/**/*.test.js",
|
|
19
|
+
"prepublishOnly": "npm test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cli",
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"gemini",
|
|
26
|
+
"pty",
|
|
27
|
+
"terminal",
|
|
28
|
+
"ai",
|
|
29
|
+
"prompt-engineering"
|
|
30
|
+
],
|
|
31
|
+
"author": "Your Name <you@example.com>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/unhiredcoder/ccx-relay.git"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"node-pty": "^1.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const defaults = {
|
|
6
|
+
geminiApiKey: null,
|
|
7
|
+
geminiModel: 'gemini-2.5-flash',
|
|
8
|
+
marker: ';;',
|
|
9
|
+
timeoutSeconds: 8,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns the path to the config.json file.
|
|
14
|
+
* Windows: %APPDATA%/ccx/config.json
|
|
15
|
+
* macOS/Linux: ~/.config/ccx/config.json
|
|
16
|
+
*/
|
|
17
|
+
export function configPath() {
|
|
18
|
+
let configDir;
|
|
19
|
+
|
|
20
|
+
if (process.platform === 'win32') {
|
|
21
|
+
configDir = path.join(
|
|
22
|
+
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
23
|
+
'ccx'
|
|
24
|
+
);
|
|
25
|
+
} else {
|
|
26
|
+
configDir = path.join(
|
|
27
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
28
|
+
'ccx'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return path.join(configDir, 'config.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Loads user configuration from multiple sources with priority:
|
|
37
|
+
* 1. Environment variables
|
|
38
|
+
* 2. config.json file (if exists)
|
|
39
|
+
* 3. Hardcoded defaults
|
|
40
|
+
*/
|
|
41
|
+
export function load() {
|
|
42
|
+
const result = { ...defaults };
|
|
43
|
+
|
|
44
|
+
// Layer 2: Read from config.json if it exists
|
|
45
|
+
try {
|
|
46
|
+
const configFilePath = configPath();
|
|
47
|
+
if (fs.existsSync(configFilePath)) {
|
|
48
|
+
const fileContent = fs.readFileSync(configFilePath, 'utf8');
|
|
49
|
+
const fileConfig = JSON.parse(fileContent);
|
|
50
|
+
Object.assign(result, fileConfig);
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// If file doesn't exist or fails to parse, use defaults silently
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Layer 1: Environment variables override everything
|
|
57
|
+
if (process.env.GEMINI_API_KEY) {
|
|
58
|
+
result.geminiApiKey = process.env.GEMINI_API_KEY;
|
|
59
|
+
}
|
|
60
|
+
if (process.env.GEMINI_MODEL) {
|
|
61
|
+
result.geminiModel = process.env.GEMINI_MODEL;
|
|
62
|
+
}
|
|
63
|
+
const rawMarker = (process.env.CCX_MARKER !== undefined ? process.env.CCX_MARKER : null) || result.marker || defaults.marker;
|
|
64
|
+
result.marker = rawMarker.length > 0 ? rawMarker : defaults.marker;
|
|
65
|
+
|
|
66
|
+
const rawTimeout = Number(process.env.CCX_TIMEOUT || result.timeoutSeconds || defaults.timeoutSeconds);
|
|
67
|
+
result.timeoutSeconds = (isFinite(rawTimeout) && rawTimeout > 0) ? rawTimeout : defaults.timeoutSeconds;
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Saves a partial config patch to disk.
|
|
74
|
+
* Creates directory if needed and merges with existing config.
|
|
75
|
+
*/
|
|
76
|
+
export function save(patch) {
|
|
77
|
+
const configFilePath = configPath();
|
|
78
|
+
const configDir = path.dirname(configFilePath);
|
|
79
|
+
|
|
80
|
+
// Create directory if it doesn't exist
|
|
81
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
// Read existing config or use empty object if file doesn't exist
|
|
84
|
+
let existing = {};
|
|
85
|
+
try {
|
|
86
|
+
if (fs.existsSync(configFilePath)) {
|
|
87
|
+
const fileContent = fs.readFileSync(configFilePath, 'utf8');
|
|
88
|
+
existing = JSON.parse(fileContent);
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// If file doesn't exist or fails to parse, start with empty object
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Deep merge patch into existing
|
|
95
|
+
const merged = { ...existing, ...patch };
|
|
96
|
+
|
|
97
|
+
// Write pretty-printed JSON with 2-space indent
|
|
98
|
+
fs.writeFileSync(configFilePath, JSON.stringify(merged, null, 2) + '\n');
|
|
99
|
+
}
|
package/src/gemini.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export class RateLimitError extends Error {}
|
|
2
|
+
export class AuthError extends Error {}
|
|
3
|
+
export class ModelError extends Error {}
|
|
4
|
+
export class TimeoutError extends Error {}
|
|
5
|
+
export class NetworkError extends Error {}
|
|
6
|
+
|
|
7
|
+
const PROMPT_PREFIX =
|
|
8
|
+
'Rewrite the following text to be grammatically correct and clearer, ' +
|
|
9
|
+
'while preserving its original intent and meaning exactly. ' +
|
|
10
|
+
'This is a single line typed into a terminal prompt, not a chat message - ' +
|
|
11
|
+
'respond with EXACTLY ONE rewritten version as a single plain-text line. ' +
|
|
12
|
+
'Do not offer multiple options or alternatives. Do not add headings, bullet ' +
|
|
13
|
+
'points, markdown formatting, quotes, explanations, or any commentary. ' +
|
|
14
|
+
'Output must contain nothing but the rewritten line itself.\n\nText:\n';
|
|
15
|
+
|
|
16
|
+
export async function enhance(text, config) {
|
|
17
|
+
const { geminiApiKey, geminiModel, timeoutSeconds } = config;
|
|
18
|
+
|
|
19
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
let timeoutId;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
|
|
25
|
+
|
|
26
|
+
const response = await fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
'x-goog-api-key': geminiApiKey,
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
contents: [{ parts: [{ text: PROMPT_PREFIX + text }] }],
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Handle bad HTTP status codes
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const body = await response.text();
|
|
41
|
+
if (response.status === 429) {
|
|
42
|
+
throw new RateLimitError('Quota exceeded');
|
|
43
|
+
} else if (response.status === 401 || response.status === 403) {
|
|
44
|
+
throw new AuthError('Invalid key — run ccx init');
|
|
45
|
+
} else if (response.status === 404) {
|
|
46
|
+
throw new ModelError('Model not found — run ccx init');
|
|
47
|
+
} else {
|
|
48
|
+
const truncated = body.substring(0, 200);
|
|
49
|
+
throw new NetworkError(`API error ${response.status}: ${truncated}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Parse response
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
const result = data.candidates[0].content.parts.map(p => p.text || '').join('').trim();
|
|
56
|
+
|
|
57
|
+
if (!result) {
|
|
58
|
+
throw new NetworkError('Gemini returned empty response');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Handle fetch errors
|
|
64
|
+
if (err.name === 'AbortError') {
|
|
65
|
+
throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Re-throw custom errors
|
|
69
|
+
if (err instanceof RateLimitError || err instanceof AuthError ||
|
|
70
|
+
err instanceof ModelError || err instanceof NetworkError ||
|
|
71
|
+
err instanceof TimeoutError) {
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Wrap other errors as NetworkError
|
|
76
|
+
throw new NetworkError('No connection');
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timeoutId);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/input.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/input.js — pure stdin parser with Alt+M and ;; triggers
|
|
3
|
+
* No fetch, no stderr writes, no process.exit
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses ESC [ {digits/semicolons} _ sequences (win32-input-mode)
|
|
8
|
+
* Returns { consumed, vk, sc, uc, kd, cs, rc } or null
|
|
9
|
+
* @param {Buffer} chunk
|
|
10
|
+
* @param {number} i - index of ESC byte
|
|
11
|
+
*/
|
|
12
|
+
function parseWin32InputSeq(chunk, i) {
|
|
13
|
+
if (chunk[i + 1] !== 0x5b) return null;
|
|
14
|
+
let j = i + 2;
|
|
15
|
+
const start = j;
|
|
16
|
+
while (j < chunk.length && ((chunk[j] >= 0x30 && chunk[j] <= 0x39) || chunk[j] === 0x3b)) j++;
|
|
17
|
+
if (j >= chunk.length || chunk[j] !== 0x5f || j === start) return null;
|
|
18
|
+
const parts = chunk.slice(start, j).toString('ascii').split(';').map(Number);
|
|
19
|
+
if (parts.length !== 6 || parts.some(Number.isNaN)) return null;
|
|
20
|
+
const [vk, sc, uc, kd, cs, rc] = parts;
|
|
21
|
+
return { consumed: j + 1 - i, vk, sc, uc, kd, cs, rc };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ marker: string, onEnhance: Function, onSubmit: Function, onPassthrough: Function, onCtrlC: Function }} opts
|
|
26
|
+
* @returns {{ processChunk(chunk: Buffer): void, setBusy(b: boolean): void, reset(): void }}
|
|
27
|
+
*/
|
|
28
|
+
export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough, onCtrlC }) {
|
|
29
|
+
let lineBuffer = '';
|
|
30
|
+
let busy = false;
|
|
31
|
+
|
|
32
|
+
function reset() {
|
|
33
|
+
lineBuffer = '';
|
|
34
|
+
busy = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setBusy(b) {
|
|
38
|
+
busy = b;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function processChunk(chunk) {
|
|
42
|
+
// When busy, forward everything as-is
|
|
43
|
+
if (busy) {
|
|
44
|
+
onPassthrough(chunk);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < chunk.length) {
|
|
50
|
+
const byte = chunk[i];
|
|
51
|
+
|
|
52
|
+
if (byte === 0x1b) {
|
|
53
|
+
// Try win32-input-mode sequence first
|
|
54
|
+
const seq = parseWin32InputSeq(chunk, i);
|
|
55
|
+
if (seq !== null) {
|
|
56
|
+
if (seq.kd === 1) {
|
|
57
|
+
if (seq.vk === 13) {
|
|
58
|
+
// Win32 Enter
|
|
59
|
+
if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
60
|
+
onEnhance(lineBuffer);
|
|
61
|
+
return;
|
|
62
|
+
} else {
|
|
63
|
+
onSubmit(lineBuffer);
|
|
64
|
+
lineBuffer = '';
|
|
65
|
+
}
|
|
66
|
+
} else if (seq.vk === 8) {
|
|
67
|
+
// Win32 Backspace
|
|
68
|
+
if (lineBuffer.length > 0) lineBuffer = lineBuffer.slice(0, -1);
|
|
69
|
+
} else if (seq.vk === 77 && (seq.cs & (0x0001 | 0x0002)) && !(seq.cs & (0x0004 | 0x0008))) {
|
|
70
|
+
// Win32 Alt+M
|
|
71
|
+
if (lineBuffer.length > 0) {
|
|
72
|
+
onEnhance(lineBuffer);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
onPassthrough(chunk.slice(i, i + seq.consumed));
|
|
78
|
+
i += seq.consumed;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// No win32 sequence — check for standard Alt+M (ESC followed by 0x6d)
|
|
83
|
+
if (i + 1 < chunk.length && chunk[i + 1] === 0x6d) {
|
|
84
|
+
if (lineBuffer.length > 0) {
|
|
85
|
+
onEnhance(lineBuffer);
|
|
86
|
+
return;
|
|
87
|
+
} else {
|
|
88
|
+
onPassthrough(chunk.slice(i, i + 2));
|
|
89
|
+
i += 2;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
// Forward rest untouched
|
|
93
|
+
onPassthrough(chunk.slice(i));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
} else if (byte === 0x0d) {
|
|
97
|
+
// Enter
|
|
98
|
+
if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
99
|
+
onEnhance(lineBuffer);
|
|
100
|
+
return;
|
|
101
|
+
} else {
|
|
102
|
+
onSubmit(lineBuffer);
|
|
103
|
+
onPassthrough(Buffer.from([byte]));
|
|
104
|
+
lineBuffer = '';
|
|
105
|
+
}
|
|
106
|
+
i++;
|
|
107
|
+
} else if (byte === 0x03) {
|
|
108
|
+
// Ctrl+C
|
|
109
|
+
onCtrlC();
|
|
110
|
+
onPassthrough(Buffer.from([byte]));
|
|
111
|
+
lineBuffer = '';
|
|
112
|
+
i++;
|
|
113
|
+
} else if (byte === 0x7f || byte === 0x08) {
|
|
114
|
+
// Backspace
|
|
115
|
+
lineBuffer = lineBuffer.slice(0, -1);
|
|
116
|
+
onPassthrough(Buffer.from([byte]));
|
|
117
|
+
i++;
|
|
118
|
+
} else {
|
|
119
|
+
// Printable char
|
|
120
|
+
lineBuffer += String.fromCharCode(byte);
|
|
121
|
+
onPassthrough(Buffer.from([byte]));
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { processChunk, setBusy, reset };
|
|
128
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
2
|
+
const FRAME_INTERVAL = 80; // ms
|
|
3
|
+
|
|
4
|
+
let spinnerInterval = null;
|
|
5
|
+
let currentFrame = 0;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Draws the status line on stderr with cursor save/restore
|
|
9
|
+
*/
|
|
10
|
+
function drawStatusLine(content) {
|
|
11
|
+
const ansi = `\x1b[s\r\n\x1b[2K${content}\x1b[u`;
|
|
12
|
+
process.stderr.write(ansi);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Starts animating the Braille spinner on stderr
|
|
17
|
+
*/
|
|
18
|
+
export function start() {
|
|
19
|
+
// Reset frame counter and stop any existing interval
|
|
20
|
+
currentFrame = 0;
|
|
21
|
+
if (spinnerInterval) {
|
|
22
|
+
clearInterval(spinnerInterval);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
spinnerInterval = setInterval(() => {
|
|
26
|
+
const frame = BRAILLE_FRAMES[currentFrame % BRAILLE_FRAMES.length];
|
|
27
|
+
drawStatusLine(`${frame} Enhancing prompt...`);
|
|
28
|
+
currentFrame++;
|
|
29
|
+
}, FRAME_INTERVAL);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stops the spinner and shows a final status message
|
|
34
|
+
*/
|
|
35
|
+
export async function stop(state, message) {
|
|
36
|
+
// Clear the spinner interval
|
|
37
|
+
if (spinnerInterval) {
|
|
38
|
+
clearInterval(spinnerInterval);
|
|
39
|
+
spinnerInterval = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Draw final state based on success/error
|
|
43
|
+
const symbol = state === 'success' ? '✓' : '✗';
|
|
44
|
+
const colorCode = state === 'success' ? '32' : '31';
|
|
45
|
+
const statusLine = `\x1b[${colorCode}m${symbol} ${message}\x1b[0m`;
|
|
46
|
+
|
|
47
|
+
drawStatusLine(statusLine);
|
|
48
|
+
|
|
49
|
+
// Wait 400ms
|
|
50
|
+
await new Promise(r => setTimeout(r, 400));
|
|
51
|
+
|
|
52
|
+
// Clear the status line
|
|
53
|
+
drawStatusLine('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Immediately clears the status line and stops spinner
|
|
58
|
+
*/
|
|
59
|
+
export function clear() {
|
|
60
|
+
if (spinnerInterval) {
|
|
61
|
+
clearInterval(spinnerInterval);
|
|
62
|
+
spinnerInterval = null;
|
|
63
|
+
}
|
|
64
|
+
drawStatusLine('');
|
|
65
|
+
}
|