ccx-relay 1.9.0 → 2.3.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/bin/ccx-init.js +164 -70
- package/bin/ccx.js +73 -65
- package/package.json +1 -1
- package/src/gemini.js +4 -4
- package/src/input.js +26 -3
- package/src/popup.js +241 -0
package/bin/ccx-init.js
CHANGED
|
@@ -7,128 +7,222 @@ import { enhance, listModels, RateLimitError } from '../src/gemini.js';
|
|
|
7
7
|
|
|
8
8
|
const args = process.argv.slice(2);
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// ── ANSI ──────────────────────────────────────────────────────────────────────
|
|
11
|
+
const c = {
|
|
12
|
+
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
|
|
13
|
+
green: '\x1b[32m', red: '\x1b[31m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
14
|
+
};
|
|
15
|
+
const s = {
|
|
16
|
+
active: `${c.cyan}◆${c.reset}`, done: `${c.gray}◇${c.reset}`,
|
|
17
|
+
pipe: `${c.gray}│${c.reset}`, end: `${c.gray}└${c.reset}`,
|
|
18
|
+
ok: `${c.green}✓${c.reset}`, err: `${c.red}✗${c.reset}`,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
22
|
+
function banner() {
|
|
23
|
+
const title = ' ccx-relay · prompt relay setup ';
|
|
24
|
+
const bar = '─'.repeat(title.length);
|
|
25
|
+
console.log(`\n ╭${bar}╮`);
|
|
26
|
+
console.log(` │${title}│`);
|
|
27
|
+
console.log(` ╰${bar}╯\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Primitives ────────────────────────────────────────────────────────────────
|
|
31
|
+
const pipe = () => console.log(s.pipe);
|
|
32
|
+
const info = (m) => console.log(`${s.pipe} ${c.dim}${m}${c.reset}`);
|
|
33
|
+
const good = (m) => console.log(`${s.pipe} ${s.ok} ${m}`);
|
|
34
|
+
const bad = (m) => console.log(`${s.pipe} ${s.err} ${c.red}${m}${c.reset}`);
|
|
35
|
+
const head = (label) => console.log(`${s.active} ${c.bold}${label}${c.reset}`);
|
|
36
|
+
|
|
37
|
+
// ── Hidden input (●●● masking) ────────────────────────────────────────────────
|
|
38
|
+
async function promptHidden() {
|
|
11
39
|
return new Promise(resolve => {
|
|
12
|
-
process.stdout.write(
|
|
40
|
+
process.stdout.write(`${s.pipe} `);
|
|
13
41
|
const rl = createInterface({ input: process.stdin, output: null, terminal: false });
|
|
14
42
|
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
15
43
|
let input = '';
|
|
16
44
|
process.stdin.on('data', function onData(buf) {
|
|
17
45
|
for (let i = 0; i < buf.length; i++) {
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
46
|
+
const b = buf[i];
|
|
47
|
+
if (b === 0x0d || b === 0x0a) {
|
|
20
48
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
21
49
|
process.stdout.write('\n');
|
|
22
50
|
process.stdin.removeListener('data', onData);
|
|
23
51
|
rl.close();
|
|
24
52
|
resolve(input);
|
|
25
53
|
return;
|
|
26
|
-
} else if (
|
|
54
|
+
} else if (b === 0x7f || b === 0x08) {
|
|
27
55
|
if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
|
|
28
|
-
} else if (
|
|
56
|
+
} else if (b === 0x03) {
|
|
29
57
|
process.exit(0);
|
|
30
|
-
} else if (
|
|
31
|
-
input += String.fromCharCode(
|
|
32
|
-
process.stdout.write(
|
|
58
|
+
} else if (b >= 0x20) {
|
|
59
|
+
input += String.fromCharCode(b);
|
|
60
|
+
process.stdout.write(`${c.dim}●${c.reset}`);
|
|
33
61
|
}
|
|
34
62
|
}
|
|
35
63
|
});
|
|
36
64
|
});
|
|
37
65
|
}
|
|
38
66
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
// ── Plain prompt ──────────────────────────────────────────────────────────────
|
|
68
|
+
async function ask(question, defaultVal = '') {
|
|
69
|
+
return new Promise(resolve => {
|
|
70
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
|
+
rl.question(question, answer => {
|
|
72
|
+
rl.close();
|
|
73
|
+
resolve(answer.trim() || defaultVal);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
47
76
|
}
|
|
48
77
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
// ── Spinner ───────────────────────────────────────────────────────────────────
|
|
79
|
+
const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
80
|
+
function spin(msg) {
|
|
81
|
+
let f = 0;
|
|
82
|
+
process.stdout.write(`${s.pipe} `);
|
|
83
|
+
const id = setInterval(() => {
|
|
84
|
+
process.stdout.write(`\r${s.pipe} ${c.dim}${FRAMES[f++ % FRAMES.length]}${c.reset} ${msg}`);
|
|
85
|
+
}, 80);
|
|
86
|
+
return () => { clearInterval(id); process.stdout.write('\r\x1b[2K'); };
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
89
|
+
// ── Model metadata ────────────────────────────────────────────────────────────
|
|
90
|
+
const MODEL_META = {
|
|
91
|
+
'llama-3.1-8b-instant': { note: 'fastest', quota: '14,400/day' },
|
|
92
|
+
'llama-3.3-70b-versatile': { note: 'smarter', quota: ' 1,000/day' },
|
|
93
|
+
'gemma2-9b-it': { note: 'balanced', quota: '14,400/day' },
|
|
94
|
+
'llama3-8b-8192': { note: 'classic', quota: '14,400/day' },
|
|
95
|
+
'llama-3.1-70b-versatile': { note: 'large', quota: ' 1,000/day' },
|
|
96
|
+
'gemini-2.5-flash': { note: 'latest', quota: 'limited' },
|
|
97
|
+
'gemini-2.0-flash-lite': { note: 'fast', quota: 'limited' },
|
|
98
|
+
'gemini-1.5-flash': { note: 'stable', quota: '1,500/day' },
|
|
99
|
+
'gemini-2.5-pro': { note: 'powerful', quota: 'limited' },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function modelRow(m, i, isDefault) {
|
|
103
|
+
const meta = MODEL_META[m] || { note: '', quota: '' };
|
|
104
|
+
const num = `${i + 1}`.padStart(2);
|
|
105
|
+
const name = m.padEnd(34);
|
|
106
|
+
const note = meta.note.padEnd(10);
|
|
107
|
+
const tag = isDefault ? ` ${c.dim}← default${c.reset}` : '';
|
|
108
|
+
return `${s.pipe} ${c.dim}${num}${c.reset} ${name}${c.dim}${note} ${meta.quota}${c.reset}${tag}`;
|
|
67
109
|
}
|
|
68
110
|
|
|
111
|
+
// ── Wizard ────────────────────────────────────────────────────────────────────
|
|
69
112
|
async function runWizard() {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
113
|
+
banner();
|
|
114
|
+
|
|
115
|
+
// ── Key ──
|
|
116
|
+
head('API key');
|
|
117
|
+
info('Groq (gsk_...) → 14,400 req/day free ← recommended');
|
|
118
|
+
info('Gemini (AIza...) → limited free tier');
|
|
119
|
+
pipe();
|
|
120
|
+
|
|
121
|
+
const key = await promptHidden();
|
|
122
|
+
if (!key) { bad('No key entered.'); process.exit(1); }
|
|
123
|
+
const provider = key.startsWith('gsk_') ? 'Groq' : 'Gemini';
|
|
124
|
+
pipe();
|
|
125
|
+
|
|
126
|
+
// ── Validate ──
|
|
127
|
+
head(`Validating ${provider} key`);
|
|
128
|
+
const stopValidate = spin('Connecting...');
|
|
75
129
|
const testModel = key.startsWith('gsk_') ? 'llama-3.1-8b-instant' : 'gemini-2.5-flash';
|
|
76
|
-
process.stdout.write(' Testing key... ');
|
|
77
130
|
try {
|
|
78
131
|
await enhance('hello', { geminiApiKey: key, geminiModel: testModel, timeoutSeconds: 10 });
|
|
79
|
-
|
|
132
|
+
stopValidate();
|
|
133
|
+
good(`${provider} key valid`);
|
|
80
134
|
} catch (err) {
|
|
81
135
|
if (err instanceof RateLimitError) {
|
|
82
|
-
|
|
136
|
+
stopValidate();
|
|
137
|
+
good(`${provider} key valid ${c.dim}(quota hit — authenticated)${c.reset}`);
|
|
83
138
|
} else {
|
|
84
|
-
|
|
139
|
+
stopValidate();
|
|
140
|
+
bad(`Invalid key — ${err.message}`);
|
|
141
|
+
console.log(`${s.end} Run ${c.bold}ccx-init${c.reset} again with a valid key.\n`);
|
|
85
142
|
process.exit(1);
|
|
86
143
|
}
|
|
87
144
|
}
|
|
145
|
+
pipe();
|
|
88
146
|
|
|
89
|
-
//
|
|
147
|
+
// ── Models ──
|
|
148
|
+
head('Model');
|
|
149
|
+
const stopFetch = spin('Fetching available models...');
|
|
90
150
|
let models;
|
|
91
|
-
process.stdout.write(' Fetching available models... ');
|
|
92
151
|
try {
|
|
93
152
|
const all = await listModels(key);
|
|
94
|
-
models = all.slice(0,
|
|
95
|
-
if (models.length
|
|
96
|
-
|
|
153
|
+
models = all.slice(0, 8);
|
|
154
|
+
if (!models.length) throw new Error('empty');
|
|
155
|
+
stopFetch();
|
|
97
156
|
} catch (_) {
|
|
157
|
+
stopFetch();
|
|
98
158
|
models = key.startsWith('gsk_')
|
|
99
|
-
? ['llama-3.1-8b-instant', 'llama-3.3-70b-versatile', 'gemma2-9b-it']
|
|
100
|
-
: ['gemini-2.5-flash', 'gemini-1.5-flash', 'gemini-2.5-pro'];
|
|
101
|
-
console.log('(using defaults)');
|
|
159
|
+
? ['llama-3.1-8b-instant', 'llama-3.3-70b-versatile', 'gemma2-9b-it', 'llama3-8b-8192']
|
|
160
|
+
: ['gemini-2.5-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-2.5-pro'];
|
|
102
161
|
}
|
|
103
162
|
|
|
104
|
-
|
|
163
|
+
models.forEach((m, i) => console.log(modelRow(m, i, i === 0)));
|
|
164
|
+
pipe();
|
|
105
165
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
models.
|
|
109
|
-
console.log(` ${i + 1}) ${m}${i === 0 ? ' (default)' : ''}`);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
const modelChoice = await prompt(rl, ' Choice [1]: ');
|
|
113
|
-
const idx = parseInt(modelChoice.trim(), 10);
|
|
114
|
-
const modelIndex = (!idx || idx < 1 || idx > models.length) ? 0 : idx - 1;
|
|
166
|
+
const modelInput = await ask(`${s.pipe} Choice ${c.dim}[1]${c.reset}: `, '1');
|
|
167
|
+
const mIdx = parseInt(modelInput, 10);
|
|
168
|
+
const modelIndex = (!mIdx || mIdx < 1 || mIdx > models.length) ? 0 : mIdx - 1;
|
|
115
169
|
const model = models[modelIndex];
|
|
170
|
+
good(model);
|
|
171
|
+
pipe();
|
|
172
|
+
|
|
173
|
+
// ── Marker ──
|
|
174
|
+
head('Trigger marker');
|
|
175
|
+
info('Type at end of your prompt + Enter to enhance (Alt+M also works anywhere)');
|
|
176
|
+
const marker = await ask(`${s.pipe} Default ${c.dim}[;;]${c.reset}: `, ';;');
|
|
177
|
+
pipe();
|
|
178
|
+
|
|
179
|
+
// ── Timeout ──
|
|
180
|
+
head('Request timeout');
|
|
181
|
+
info('Seconds before giving up and restoring original text');
|
|
182
|
+
const timeoutInput = await ask(`${s.pipe} Default ${c.dim}[8]${c.reset}: `, '8');
|
|
183
|
+
const timeoutSeconds = parseInt(timeoutInput, 10) || 8;
|
|
184
|
+
pipe();
|
|
185
|
+
|
|
186
|
+
// ── Save ──
|
|
187
|
+
save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
|
|
188
|
+
const path = configPath();
|
|
189
|
+
console.log(`${s.end} ${s.ok} ${c.bold}${c.green}Config saved${c.reset}`);
|
|
190
|
+
console.log(` ${c.dim}${path}${c.reset}`);
|
|
191
|
+
console.log(`\n Run ${c.bold}ccx claude${c.reset} to start\n`);
|
|
192
|
+
}
|
|
116
193
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
194
|
+
// ── Sub-commands ──────────────────────────────────────────────────────────────
|
|
195
|
+
function maskKey(key) {
|
|
196
|
+
if (!key) return '(not set)';
|
|
197
|
+
return key.slice(0, 6) + '****...';
|
|
198
|
+
}
|
|
122
199
|
|
|
123
|
-
|
|
200
|
+
function showConfig() {
|
|
201
|
+
const cfg = load();
|
|
202
|
+
const path = configPath();
|
|
203
|
+
const provider = cfg.geminiApiKey?.startsWith('gsk_') ? 'Groq' : 'Gemini';
|
|
204
|
+
banner();
|
|
205
|
+
console.log(`${s.pipe} ${c.dim}Path${c.reset} ${path}`);
|
|
206
|
+
console.log(`${s.pipe} ${c.dim}Provider${c.reset} ${provider}`);
|
|
207
|
+
console.log(`${s.pipe} ${c.dim}Key${c.reset} ${maskKey(cfg.geminiApiKey)}`);
|
|
208
|
+
console.log(`${s.pipe} ${c.dim}Model${c.reset} ${cfg.geminiModel}`);
|
|
209
|
+
console.log(`${s.pipe} ${c.dim}Marker${c.reset} ${cfg.marker}`);
|
|
210
|
+
console.log(`${s.end} ${c.dim}Timeout${c.reset} ${cfg.timeoutSeconds}s\n`);
|
|
211
|
+
}
|
|
124
212
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
213
|
+
function resetConfig() {
|
|
214
|
+
const path = configPath();
|
|
215
|
+
if (existsSync(path)) {
|
|
216
|
+
rmSync(path);
|
|
217
|
+
console.log(`${s.ok} Config reset. Run ${c.bold}ccx-init${c.reset} to set up again.`);
|
|
218
|
+
} else {
|
|
219
|
+
console.log('No config file found — nothing to reset.');
|
|
220
|
+
}
|
|
128
221
|
}
|
|
129
222
|
|
|
223
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
130
224
|
if (args.length === 0) {
|
|
131
|
-
runWizard();
|
|
225
|
+
runWizard().catch(err => { console.error(err.message); process.exit(1); });
|
|
132
226
|
} else if (args[0] === '--show') {
|
|
133
227
|
showConfig();
|
|
134
228
|
} else if (args[0] === '--reset') {
|
package/bin/ccx.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
import { load } from '../src/config.js';
|
|
5
5
|
import { enhance, RateLimitError, AuthError, ModelError, TimeoutError, NetworkError } from '../src/gemini.js';
|
|
6
|
-
import { start as spinnerStart, stop as spinnerStop
|
|
7
|
-
import {
|
|
6
|
+
import { start as spinnerStart, stop as spinnerStop } from '../src/ui.js';
|
|
7
|
+
import { openPopup } from '../src/popup.js';
|
|
8
8
|
|
|
9
9
|
const argv = process.argv.slice(2);
|
|
10
10
|
|
|
11
|
-
// Shell tab-completion
|
|
11
|
+
// ── Shell tab-completion ───────────────────────────────────────────────────────
|
|
12
12
|
if (argv[0] === 'completion') {
|
|
13
13
|
const shell = argv[1] || 'bash';
|
|
14
14
|
const scripts = {
|
|
@@ -42,13 +42,13 @@ if (argv[0] === 'completion') {
|
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
44
|
process.stdout.write(scripts[shell] + '\n');
|
|
45
|
-
process.stdout.write('
|
|
46
|
-
if (shell === '
|
|
47
|
-
else if (shell === 'zsh') process.stdout.write('# eval "$(ccx completion zsh)"\n');
|
|
45
|
+
if (shell === 'bash') process.stdout.write('# eval "$(ccx completion bash)"\n');
|
|
46
|
+
else if (shell === 'zsh') process.stdout.write('# eval "$(ccx completion zsh)"\n');
|
|
48
47
|
else if (shell === 'powershell') process.stdout.write('# Invoke-Expression (ccx completion powershell)\n');
|
|
49
48
|
process.exit(0);
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
// ── Load node-pty ──────────────────────────────────────────────────────────────
|
|
52
52
|
let pty;
|
|
53
53
|
try {
|
|
54
54
|
pty = (await import('node-pty')).default;
|
|
@@ -62,23 +62,21 @@ try {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const config = load();
|
|
65
|
-
|
|
66
65
|
if (!config.geminiApiKey) {
|
|
67
66
|
process.stderr.write('[ccx] No API key found. Run `ccx-init` to set up.\n');
|
|
68
67
|
process.exit(1);
|
|
69
68
|
}
|
|
70
|
-
|
|
71
69
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
72
|
-
process.stderr.write('[ccx]
|
|
70
|
+
process.stderr.write('[ccx] Must be run in an interactive terminal.\n');
|
|
73
71
|
process.exit(1);
|
|
74
72
|
}
|
|
75
73
|
|
|
74
|
+
// ── Spawn PTY ──────────────────────────────────────────────────────────────────
|
|
76
75
|
const targetCmd = argv[0] || 'claude';
|
|
77
76
|
const targetArgs = argv.slice(1);
|
|
78
|
-
|
|
79
|
-
const isWindows
|
|
80
|
-
const
|
|
81
|
-
const shellArgs = isWindows ? ['/d', '/s', '/c', [targetCmd, ...targetArgs].join(' ')] : targetArgs;
|
|
77
|
+
const isWindows = process.platform === 'win32';
|
|
78
|
+
const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
|
|
79
|
+
const shellArgs = isWindows ? ['/d', '/s', '/c', [targetCmd, ...targetArgs].join(' ')] : targetArgs;
|
|
82
80
|
|
|
83
81
|
const ptyProcess = pty.spawn(shellFile, shellArgs, {
|
|
84
82
|
name: 'xterm-color',
|
|
@@ -88,7 +86,7 @@ const ptyProcess = pty.spawn(shellFile, shellArgs, {
|
|
|
88
86
|
env: process.env,
|
|
89
87
|
});
|
|
90
88
|
|
|
91
|
-
//
|
|
89
|
+
// ── Context ring buffer ────────────────────────────────────────────────────────
|
|
92
90
|
const CONTEXT_MAX = 20;
|
|
93
91
|
const contextLines = [];
|
|
94
92
|
|
|
@@ -101,9 +99,9 @@ function stripAnsi(s) {
|
|
|
101
99
|
|
|
102
100
|
ptyProcess.onData(data => {
|
|
103
101
|
process.stdout.write(data);
|
|
104
|
-
for (const
|
|
105
|
-
if (
|
|
106
|
-
contextLines.push(
|
|
102
|
+
for (const ln of stripAnsi(data).split('\n')) {
|
|
103
|
+
if (ln.trim()) {
|
|
104
|
+
contextLines.push(ln.trim());
|
|
107
105
|
if (contextLines.length > CONTEXT_MAX) contextLines.shift();
|
|
108
106
|
}
|
|
109
107
|
}
|
|
@@ -113,59 +111,69 @@ process.stdout.on('resize', () => {
|
|
|
113
111
|
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
114
112
|
});
|
|
115
113
|
|
|
114
|
+
// ── Alt+M detection ────────────────────────────────────────────────────────────
|
|
115
|
+
// Returns true if this chunk is an Alt+M keypress (standard or win32-input-mode).
|
|
116
|
+
function isAltM(chunk) {
|
|
117
|
+
// Standard: ESC m (0x1b 0x6d)
|
|
118
|
+
if (chunk.length >= 2 && chunk[0] === 0x1b && chunk[1] === 0x6d) return true;
|
|
119
|
+
// Win32 input mode: ESC [ vk;sc;uc;kd;cs;rc _ where vk=77 (M), kd=1 (keydown), Alt bit in cs
|
|
120
|
+
if (chunk.length >= 2 && chunk[0] === 0x1b && chunk[1] === 0x5b) {
|
|
121
|
+
const m = chunk.toString('ascii').match(/^\x1b\[(\d+);\d+;\d+;(\d+);(\d+);\d+_/);
|
|
122
|
+
if (m) {
|
|
123
|
+
const vk = +m[1], kd = +m[2], cs = +m[3];
|
|
124
|
+
if (vk === 77 && kd === 1 && (cs & 0x0003) && !(cs & 0x000c)) return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Enhancement flow ───────────────────────────────────────────────────────────
|
|
131
|
+
let busy = false;
|
|
132
|
+
|
|
133
|
+
async function handleEnhance() {
|
|
134
|
+
busy = true;
|
|
135
|
+
|
|
136
|
+
const text = await openPopup();
|
|
137
|
+
if (!text) { busy = false; return; }
|
|
138
|
+
|
|
139
|
+
spinnerStart();
|
|
140
|
+
let improved;
|
|
141
|
+
try {
|
|
142
|
+
const context = contextLines.length > 0 ? contextLines.join('\n') : null;
|
|
143
|
+
improved = await enhance(text, config, context);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
let msg = 'Enhancement failed';
|
|
146
|
+
if (err instanceof RateLimitError) msg = 'Quota exceeded';
|
|
147
|
+
else if (err instanceof AuthError) msg = 'Invalid key — run ccx-init';
|
|
148
|
+
else if (err instanceof ModelError) msg = 'Model not found — run ccx-init';
|
|
149
|
+
else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s`;
|
|
150
|
+
else if (err instanceof NetworkError) msg = 'No connection';
|
|
151
|
+
await spinnerStop('error', msg);
|
|
152
|
+
busy = false;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await spinnerStop('success', 'Enhanced');
|
|
157
|
+
|
|
158
|
+
// Clear any existing text in Claude Code's input line, then inject enhanced text
|
|
159
|
+
ptyProcess.write('\x15'); // Ctrl+U = kill line (clears Claude Code's current input)
|
|
160
|
+
// Small pause so Claude Code processes the Ctrl+U before we type the new text
|
|
161
|
+
await new Promise(r => setTimeout(r, 30));
|
|
162
|
+
ptyProcess.write(improved);
|
|
163
|
+
busy = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Stdin routing ──────────────────────────────────────────────────────────────
|
|
116
167
|
process.stdin.setRawMode(true);
|
|
117
168
|
process.stdin.resume();
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
|
|
124
|
-
handler.setBusy(true);
|
|
125
|
-
spinnerStart();
|
|
126
|
-
let improved;
|
|
127
|
-
try {
|
|
128
|
-
const context = contextLines.length > 0 ? contextLines.join('\n') : null;
|
|
129
|
-
improved = await enhance(toSend, config, context);
|
|
130
|
-
} catch (err) {
|
|
131
|
-
let msg = 'Enhancement failed';
|
|
132
|
-
if (err instanceof RateLimitError) msg = 'Quota exceeded';
|
|
133
|
-
else if (err instanceof AuthError) msg = 'Invalid key — run ccx-init';
|
|
134
|
-
else if (err instanceof ModelError) msg = 'Model not found — run ccx-init';
|
|
135
|
-
else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
|
|
136
|
-
else if (err instanceof NetworkError) msg = 'No connection — original restored';
|
|
137
|
-
await spinnerStop('error', msg);
|
|
138
|
-
// Move cursor to end of line then erase, restore original
|
|
139
|
-
const charsAfterCursor = line.length - cursor;
|
|
140
|
-
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
141
|
-
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
142
|
-
ptyProcess.write(toSend);
|
|
143
|
-
handler.setBusy(false);
|
|
144
|
-
handler.setLine(toSend);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
await spinnerStop('success', 'Enhanced');
|
|
148
|
-
// Move cursor to end of line then erase, write improved
|
|
149
|
-
const charsAfterCursor = line.length - cursor;
|
|
150
|
-
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
151
|
-
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
152
|
-
ptyProcess.write(improved);
|
|
153
|
-
handler.setBusy(false);
|
|
154
|
-
handler.setLine(improved);
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
onSubmit: (_line) => {},
|
|
158
|
-
|
|
159
|
-
onPassthrough: (chunk) => ptyProcess.write(chunk),
|
|
160
|
-
|
|
161
|
-
onCtrlC: () => {
|
|
162
|
-
spinnerClear();
|
|
163
|
-
handler.reset();
|
|
164
|
-
},
|
|
170
|
+
process.stdin.on('data', chunk => {
|
|
171
|
+
if (busy) return; // swallow input during enhancement
|
|
172
|
+
if (isAltM(chunk)) { handleEnhance(); return; }
|
|
173
|
+
ptyProcess.write(chunk);
|
|
165
174
|
});
|
|
166
175
|
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────────
|
|
169
177
|
function cleanupAndExit(code) {
|
|
170
178
|
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {}
|
|
171
179
|
process.exit(code == null ? 0 : code);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "ccx-relay",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.3.0",
|
|
5
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
6
|
"main": "bin/ccx.js",
|
|
7
7
|
"bin": {
|
package/src/gemini.js
CHANGED
|
@@ -7,11 +7,11 @@ export class NetworkError extends Error {}
|
|
|
7
7
|
const SYSTEM_PROMPT =
|
|
8
8
|
'Rewrite the following text to be grammatically correct and clearer, ' +
|
|
9
9
|
'while preserving its original intent and meaning exactly. ' +
|
|
10
|
-
'This is
|
|
11
|
-
'
|
|
10
|
+
'This is text typed into a terminal prompt — it may be one line or multiple lines. ' +
|
|
11
|
+
'Respond with EXACTLY ONE rewritten version preserving the same line structure and count. ' +
|
|
12
12
|
'Do not offer multiple options or alternatives. Do not add headings, bullet ' +
|
|
13
13
|
'points, markdown formatting, quotes, explanations, or any commentary. ' +
|
|
14
|
-
'Output must contain nothing but the rewritten
|
|
14
|
+
'Output must contain nothing but the rewritten text itself.';
|
|
15
15
|
|
|
16
16
|
function isGroq(apiKey) { return apiKey.startsWith('gsk_'); }
|
|
17
17
|
|
|
@@ -78,7 +78,7 @@ async function enhanceGroq(text, config, context) {
|
|
|
78
78
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
79
79
|
{ role: 'user', content: userContent },
|
|
80
80
|
],
|
|
81
|
-
max_tokens:
|
|
81
|
+
max_tokens: 2048,
|
|
82
82
|
}),
|
|
83
83
|
});
|
|
84
84
|
|
package/src/input.js
CHANGED
|
@@ -25,11 +25,13 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
25
25
|
let lineBuffer = '';
|
|
26
26
|
let cursor = 0;
|
|
27
27
|
let busy = false;
|
|
28
|
+
let inPaste = false;
|
|
28
29
|
|
|
29
30
|
function reset() {
|
|
30
31
|
lineBuffer = '';
|
|
31
32
|
cursor = 0;
|
|
32
33
|
busy = false;
|
|
34
|
+
inPaste = false;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function setBusy(b) {
|
|
@@ -55,7 +57,11 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
55
57
|
if (seq !== null) {
|
|
56
58
|
if (seq.kd === 1) {
|
|
57
59
|
if (seq.vk === 13) {
|
|
58
|
-
if (
|
|
60
|
+
if (seq.cs & 0x0010) {
|
|
61
|
+
// Shift+Enter: append newline, accumulate multi-line buffer
|
|
62
|
+
lineBuffer += '\n';
|
|
63
|
+
cursor = lineBuffer.length;
|
|
64
|
+
} else if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
59
65
|
onEnhance(lineBuffer, cursor);
|
|
60
66
|
return;
|
|
61
67
|
} else {
|
|
@@ -97,7 +103,7 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
97
103
|
continue;
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
// 3. Standard CSI sequences (arrows, Home, End, etc.)
|
|
106
|
+
// 3. Standard CSI sequences (arrows, Home, End, bracketed paste, etc.)
|
|
101
107
|
const csi = parseCsiSeq(chunk, i);
|
|
102
108
|
if (csi !== null) {
|
|
103
109
|
if (csi.final === 'D' && !csi.params) cursor = Math.max(0, cursor - 1);
|
|
@@ -106,6 +112,8 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
106
112
|
else if (csi.final === 'F' && !csi.params) cursor = lineBuffer.length;
|
|
107
113
|
else if (csi.final === '~' && csi.params === '1') cursor = 0;
|
|
108
114
|
else if (csi.final === '~' && csi.params === '4') cursor = lineBuffer.length;
|
|
115
|
+
else if (csi.final === '~' && csi.params === '200') inPaste = true;
|
|
116
|
+
else if (csi.final === '~' && csi.params === '201') inPaste = false;
|
|
109
117
|
onPassthrough(chunk.slice(i, i + csi.consumed));
|
|
110
118
|
i += csi.consumed;
|
|
111
119
|
continue;
|
|
@@ -115,7 +123,13 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
115
123
|
onPassthrough(chunk.slice(i));
|
|
116
124
|
return;
|
|
117
125
|
} else if (byte === 0x0d) {
|
|
118
|
-
if (
|
|
126
|
+
if (inPaste) {
|
|
127
|
+
// Inside bracketed paste: CR = newline, accumulate
|
|
128
|
+
lineBuffer += '\n';
|
|
129
|
+
cursor = lineBuffer.length;
|
|
130
|
+
i++;
|
|
131
|
+
if (i < chunk.length && chunk[i] === 0x0a) i++; // skip CRLF's LF
|
|
132
|
+
} else if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
119
133
|
onEnhance(lineBuffer, cursor);
|
|
120
134
|
return;
|
|
121
135
|
} else {
|
|
@@ -123,6 +137,15 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
123
137
|
onPassthrough(Buffer.from([byte]));
|
|
124
138
|
lineBuffer = '';
|
|
125
139
|
cursor = 0;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
} else if (byte === 0x0a) {
|
|
143
|
+
if (inPaste) {
|
|
144
|
+
// LF inside paste (after CR already handled, or standalone LF)
|
|
145
|
+
lineBuffer += '\n';
|
|
146
|
+
cursor = lineBuffer.length;
|
|
147
|
+
} else {
|
|
148
|
+
onPassthrough(Buffer.from([byte]));
|
|
126
149
|
}
|
|
127
150
|
i++;
|
|
128
151
|
} else if (byte === 0x03) {
|
package/src/popup.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Self-contained popup for clean multi-line prompt input.
|
|
2
|
+
// Owns its own raw-mode data listener — no shadow buffer, no drift.
|
|
3
|
+
|
|
4
|
+
const c = {
|
|
5
|
+
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
|
|
6
|
+
cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// ── Win32 / CSI parsers (inlined to keep popup self-contained) ────────────────
|
|
10
|
+
function parseW32(buf, i) {
|
|
11
|
+
if (buf[i + 1] !== 0x5b) return null;
|
|
12
|
+
let j = i + 2;
|
|
13
|
+
while (j < buf.length && ((buf[j] >= 0x30 && buf[j] <= 0x39) || buf[j] === 0x3b)) j++;
|
|
14
|
+
if (j >= buf.length || buf[j] !== 0x5f || j === i + 2) return null;
|
|
15
|
+
const parts = buf.slice(i + 2, j).toString('ascii').split(';').map(Number);
|
|
16
|
+
if (parts.length !== 6 || parts.some(Number.isNaN)) return null;
|
|
17
|
+
const [vk, sc, uc, kd, cs] = parts;
|
|
18
|
+
return { consumed: j + 1 - i, vk, kd, cs };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseCsi(buf, i) {
|
|
22
|
+
if (buf[i + 1] !== 0x5b) return null;
|
|
23
|
+
let j = i + 2;
|
|
24
|
+
while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f) j++;
|
|
25
|
+
if (j >= buf.length || buf[j] < 0x40 || buf[j] > 0x7e) return null;
|
|
26
|
+
return { consumed: j + 1 - i, params: buf.slice(i + 2, j).toString('ascii'), final: String.fromCharCode(buf[j]) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Render ────────────────────────────────────────────────────────────────────
|
|
30
|
+
function render(lines, cursorLine, cursorCol) {
|
|
31
|
+
const cols = Math.max((process.stdout.columns || 80) - 2, 20);
|
|
32
|
+
const rows = process.stdout.rows || 24;
|
|
33
|
+
const MAX_VISIBLE = 8;
|
|
34
|
+
const visLines = lines.length;
|
|
35
|
+
const popupH = Math.min(visLines, MAX_VISIBLE) + 2; // header + content rows + footer hint
|
|
36
|
+
const startRow = Math.max(1, rows - popupH);
|
|
37
|
+
|
|
38
|
+
// Show last MAX_VISIBLE lines
|
|
39
|
+
const offset = Math.max(0, visLines - MAX_VISIBLE);
|
|
40
|
+
const visible = lines.slice(offset);
|
|
41
|
+
const relLine = cursorLine - offset; // cursor row within visible slice
|
|
42
|
+
|
|
43
|
+
const hint = `${c.dim}Shift+Enter: new line · Ctrl+C / Esc: cancel${c.reset}`;
|
|
44
|
+
const bar = '─'.repeat(cols);
|
|
45
|
+
|
|
46
|
+
let out = '\x1b[s'; // save cursor
|
|
47
|
+
|
|
48
|
+
// Header
|
|
49
|
+
out += `\x1b[${startRow};1H\x1b[2K`;
|
|
50
|
+
out += `${c.cyan}◆${c.reset} ${c.bold}Enhance${c.reset} ${hint}`;
|
|
51
|
+
|
|
52
|
+
// Separator
|
|
53
|
+
out += `\x1b[${startRow + 1};1H\x1b[2K${c.gray}${bar}${c.reset}`;
|
|
54
|
+
|
|
55
|
+
// Content lines
|
|
56
|
+
for (let r = 0; r < visible.length; r++) {
|
|
57
|
+
out += `\x1b[${startRow + 2 + r};1H\x1b[2K`;
|
|
58
|
+
out += `${c.gray}▶${c.reset} ${visible[r]}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Position cursor
|
|
62
|
+
const curRow = startRow + 2 + relLine;
|
|
63
|
+
const curCol = 3 + cursorCol; // 3 = "▶ " prefix width
|
|
64
|
+
out += `\x1b[${curRow};${curCol}H`;
|
|
65
|
+
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearArea(lines) {
|
|
70
|
+
const rows = process.stdout.rows || 24;
|
|
71
|
+
const MAX_V = 8;
|
|
72
|
+
const popupH = Math.min(lines.length, MAX_V) + 2;
|
|
73
|
+
const start = Math.max(1, rows - popupH);
|
|
74
|
+
let out = '';
|
|
75
|
+
for (let r = start; r <= rows; r++) out += `\x1b[${r};1H\x1b[2K`;
|
|
76
|
+
out += '\x1b[u'; // restore original cursor
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
81
|
+
// Returns: string (the composed text) or null (cancelled).
|
|
82
|
+
export function openPopup() {
|
|
83
|
+
return new Promise(resolve => {
|
|
84
|
+
// State
|
|
85
|
+
const lines = ['']; // array of line strings
|
|
86
|
+
let curLine = 0; // which line cursor is on
|
|
87
|
+
let curCol = 0; // column within that line
|
|
88
|
+
let inPaste = false;
|
|
89
|
+
|
|
90
|
+
function line() { return lines[curLine]; }
|
|
91
|
+
function redraw() { process.stdout.write(render(lines, curLine, curCol)); }
|
|
92
|
+
|
|
93
|
+
function done(result) {
|
|
94
|
+
process.stdout.write(clearArea(lines));
|
|
95
|
+
process.stdin.removeListener('data', onData);
|
|
96
|
+
resolve(result && result.trim() ? result : null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function insertChar(ch) {
|
|
100
|
+
lines[curLine] = line().slice(0, curCol) + ch + line().slice(curCol);
|
|
101
|
+
curCol++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function backspace() {
|
|
105
|
+
if (curCol > 0) {
|
|
106
|
+
lines[curLine] = line().slice(0, curCol - 1) + line().slice(curCol);
|
|
107
|
+
curCol--;
|
|
108
|
+
} else if (curLine > 0) {
|
|
109
|
+
// Merge with previous line
|
|
110
|
+
const prev = lines[curLine - 1];
|
|
111
|
+
curCol = prev.length;
|
|
112
|
+
lines[curLine - 1] = prev + lines[curLine];
|
|
113
|
+
lines.splice(curLine, 1);
|
|
114
|
+
curLine--;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function newLine() {
|
|
119
|
+
// Split current line at cursor
|
|
120
|
+
const before = line().slice(0, curCol);
|
|
121
|
+
const after = line().slice(curCol);
|
|
122
|
+
lines[curLine] = before;
|
|
123
|
+
lines.splice(curLine + 1, 0, after);
|
|
124
|
+
curLine++;
|
|
125
|
+
curCol = 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
redraw();
|
|
129
|
+
|
|
130
|
+
function onData(buf) {
|
|
131
|
+
let i = 0;
|
|
132
|
+
while (i < buf.length) {
|
|
133
|
+
const byte = buf[i];
|
|
134
|
+
|
|
135
|
+
// ── ESC sequences ──
|
|
136
|
+
if (byte === 0x1b) {
|
|
137
|
+
// Win32 input mode
|
|
138
|
+
const w = parseW32(buf, i);
|
|
139
|
+
if (w && w.kd === 1) {
|
|
140
|
+
i += w.consumed;
|
|
141
|
+
switch (w.vk) {
|
|
142
|
+
case 13: // Enter / Shift+Enter
|
|
143
|
+
if (w.cs & 0x0010) { newLine(); redraw(); }
|
|
144
|
+
else { done(lines.join('\n')); return; }
|
|
145
|
+
break;
|
|
146
|
+
case 8: // Backspace
|
|
147
|
+
backspace(); redraw(); break;
|
|
148
|
+
case 37: // Left
|
|
149
|
+
if (curCol > 0) { curCol--; redraw(); }
|
|
150
|
+
break;
|
|
151
|
+
case 39: // Right
|
|
152
|
+
if (curCol < line().length) { curCol++; redraw(); }
|
|
153
|
+
break;
|
|
154
|
+
case 36: // Home
|
|
155
|
+
curCol = 0; redraw(); break;
|
|
156
|
+
case 35: // End
|
|
157
|
+
curCol = line().length; redraw(); break;
|
|
158
|
+
case 27: // Esc
|
|
159
|
+
done(null); return;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// CSI sequences
|
|
165
|
+
const csi = parseCsi(buf, i);
|
|
166
|
+
if (csi) {
|
|
167
|
+
i += csi.consumed;
|
|
168
|
+
if (csi.final === 'D' && !csi.params) { if (curCol > 0) { curCol--; redraw(); } }
|
|
169
|
+
else if (csi.final === 'C' && !csi.params) { if (curCol < line().length) { curCol++; redraw(); } }
|
|
170
|
+
else if (csi.final === 'H' && !csi.params) { curCol = 0; redraw(); }
|
|
171
|
+
else if (csi.final === 'F' && !csi.params) { curCol = line().length; redraw(); }
|
|
172
|
+
else if (csi.final === '~' && csi.params === '200') { inPaste = true; }
|
|
173
|
+
else if (csi.final === '~' && csi.params === '201') { inPaste = false; }
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ESC alone → cancel
|
|
178
|
+
if (i + 1 >= buf.length || buf[i + 1] < 0x20) { done(null); return; }
|
|
179
|
+
i += 2; // skip unknown ESC+byte
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── CR ──
|
|
184
|
+
if (byte === 0x0d) {
|
|
185
|
+
i++;
|
|
186
|
+
if (inPaste) {
|
|
187
|
+
newLine();
|
|
188
|
+
if (i < buf.length && buf[i] === 0x0a) i++; // skip CRLF's LF
|
|
189
|
+
redraw();
|
|
190
|
+
} else {
|
|
191
|
+
done(lines.join('\n')); return;
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── LF ──
|
|
197
|
+
if (byte === 0x0a) {
|
|
198
|
+
i++;
|
|
199
|
+
if (inPaste) { newLine(); redraw(); }
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Ctrl+C ──
|
|
204
|
+
if (byte === 0x03) { done(null); return; }
|
|
205
|
+
|
|
206
|
+
// ── Ctrl+U (clear line) ──
|
|
207
|
+
if (byte === 0x15) {
|
|
208
|
+
lines[curLine] = line().slice(curCol);
|
|
209
|
+
curCol = 0;
|
|
210
|
+
i++; redraw(); continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Ctrl+W (delete word) ──
|
|
214
|
+
if (byte === 0x17) {
|
|
215
|
+
let s = line().slice(0, curCol);
|
|
216
|
+
s = s.replace(/\S+\s*$/, '');
|
|
217
|
+
lines[curLine] = s + line().slice(curCol);
|
|
218
|
+
curCol = s.length;
|
|
219
|
+
i++; redraw(); continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Ctrl+A / Ctrl+E ──
|
|
223
|
+
if (byte === 0x01) { curCol = 0; i++; redraw(); continue; }
|
|
224
|
+
if (byte === 0x05) { curCol = line().length; i++; redraw(); continue; }
|
|
225
|
+
|
|
226
|
+
// ── Backspace ──
|
|
227
|
+
if (byte === 0x7f || byte === 0x08) { backspace(); i++; redraw(); continue; }
|
|
228
|
+
|
|
229
|
+
// ── Printable ──
|
|
230
|
+
if (byte >= 0x20) {
|
|
231
|
+
insertChar(String.fromCharCode(byte));
|
|
232
|
+
i++; redraw(); continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
i++; // skip unknown control byte
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
process.stdin.on('data', onData);
|
|
240
|
+
});
|
|
241
|
+
}
|