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 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
- async function promptHidden(question) {
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(question);
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 byte = buf[i];
19
- if (byte === 0x0d || byte === 0x0a) {
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 (byte === 0x7f || byte === 0x08) {
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 (byte === 0x03) {
56
+ } else if (b === 0x03) {
29
57
  process.exit(0);
30
- } else if (byte >= 0x20) {
31
- input += String.fromCharCode(byte);
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
- async function prompt(rl, question) {
40
- return new Promise(resolve => rl.question(question, resolve));
41
- }
42
-
43
- function maskKey(key) {
44
- if (!key) return '(not set)';
45
- if (key.length <= 6) return key + '****...';
46
- return key.slice(0, 6) + '****...';
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
- function showConfig() {
50
- const config = load();
51
- const path = configPath();
52
- console.log(`Config: ${path}\n`);
53
- console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
54
- console.log(` geminiModel: ${config.geminiModel}`);
55
- console.log(` marker: ${config.marker}`);
56
- console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
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
- function resetConfig() {
60
- const path = configPath();
61
- if (existsSync(path)) {
62
- rmSync(path);
63
- console.log('Config reset. Run `ccx-init` to set up again.');
64
- } else {
65
- console.log('No config file found nothing to reset.');
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
- console.log('\n Welcome to ccx setup\n');
71
-
72
- const key = await promptHidden(' API key: ');
73
- if (!key) { console.log('No key entered. Aborting.'); process.exit(1); }
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
- console.log('\x1b[32m✓ Valid\x1b[0m');
132
+ stopValidate();
133
+ good(`${provider} key valid`);
80
134
  } catch (err) {
81
135
  if (err instanceof RateLimitError) {
82
- console.log('\x1b[32m✓ Valid\x1b[0m \x1b[2m(quota exceeded — key authenticated)\x1b[0m');
136
+ stopValidate();
137
+ good(`${provider} key valid ${c.dim}(quota hit — authenticated)${c.reset}`);
83
138
  } else {
84
- console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
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
- // Fetch available models, fall back to hardcoded list
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, 10);
95
- if (models.length === 0) throw new Error('empty');
96
- console.log(`\x1b[32m✓\x1b[0m ${models.length} found`);
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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
163
+ models.forEach((m, i) => console.log(modelRow(m, i, i === 0)));
164
+ pipe();
105
165
 
106
- console.log('');
107
- console.log(' Model:');
108
- models.forEach((m, i) => {
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
- const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
118
- const marker = markerAnswer.trim() || ';;';
119
-
120
- const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
121
- const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
194
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
195
+ function maskKey(key) {
196
+ if (!key) return '(not set)';
197
+ return key.slice(0, 6) + '****...';
198
+ }
122
199
 
123
- rl.close();
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
- save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
126
- console.log(`\n Config saved to ${configPath()}`);
127
- console.log(' Run `ccx claude` to start.\n');
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, clear as spinnerClear } from '../src/ui.js';
7
- import { createInputHandler } from '../src/input.js';
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 scripts
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('\n# To activate, add to your shell profile:\n');
46
- if (shell === 'bash') process.stdout.write('# eval "$(ccx completion bash)"\n');
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] ccx must be run in an interactive terminal.\n');
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 = process.platform === 'win32';
80
- const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
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
- // Ring buffer of recent PTY output lines for context-aware enhancement
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 line of stripAnsi(data).split('\n')) {
105
- if (line.trim()) {
106
- contextLines.push(line.trim());
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
- const handler = createInputHandler({
120
- marker: config.marker,
121
-
122
- onEnhance: async (line, cursor) => {
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
- process.stdin.on('data', chunk => handler.processChunk(chunk));
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": "1.9.0",
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 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. ' +
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 line itself.';
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: 256,
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 (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
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 (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
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
+ }