ccx-relay 2.1.0 → 2.4.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.js CHANGED
@@ -8,7 +8,7 @@ import { createInputHandler } from '../src/input.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,30 +111,36 @@ process.stdout.on('resize', () => {
113
111
  ptyProcess.resize(process.stdout.columns, process.stdout.rows);
114
112
  });
115
113
 
116
- process.stdin.setRawMode(true);
117
- process.stdin.resume();
118
-
114
+ // ── Erase current input (single or multi-line) ────────────────────────────────
119
115
  function eraseInput(line, cursor) {
120
116
  const parts = line.split('\n');
121
- const extraLines = parts.length - 1;
122
- if (extraLines === 0) {
123
- const charsAfterCursor = line.length - cursor;
124
- if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
117
+ const extra = parts.length - 1;
118
+ if (extra === 0) {
119
+ const after = line.length - cursor;
120
+ if (after > 0) ptyProcess.write(`\x1b[${after}C`);
125
121
  ptyProcess.write(Buffer.alloc(line.length, 0x7f));
126
122
  } else {
127
123
  ptyProcess.write('\x1b[2K');
128
- for (let i = 0; i < extraLines; i++) ptyProcess.write('\x1b[1A\x1b[2K');
124
+ for (let i = 0; i < extra; i++) ptyProcess.write('\x1b[1A\x1b[2K');
129
125
  ptyProcess.write('\x1b[G');
130
126
  }
131
127
  }
132
128
 
129
+ // ── Input handler ──────────────────────────────────────────────────────────────
130
+ process.stdin.setRawMode(true);
131
+ process.stdin.resume();
132
+
133
133
  const handler = createInputHandler({
134
134
  marker: config.marker,
135
135
 
136
136
  onEnhance: async (line, cursor) => {
137
- const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
137
+ const toSend = line.endsWith(config.marker)
138
+ ? line.slice(0, -config.marker.length)
139
+ : line;
140
+
138
141
  handler.setBusy(true);
139
142
  spinnerStart();
143
+
140
144
  let improved;
141
145
  try {
142
146
  const context = contextLines.length > 0 ? contextLines.join('\n') : null;
@@ -155,6 +159,7 @@ const handler = createInputHandler({
155
159
  handler.setLine(toSend);
156
160
  return;
157
161
  }
162
+
158
163
  await spinnerStop('success', 'Enhanced');
159
164
  eraseInput(line, cursor);
160
165
  ptyProcess.write(improved);
@@ -162,7 +167,7 @@ const handler = createInputHandler({
162
167
  handler.setLine(improved);
163
168
  },
164
169
 
165
- onSubmit: (_line) => {},
170
+ onSubmit: () => {},
166
171
 
167
172
  onPassthrough: (chunk) => ptyProcess.write(chunk),
168
173
 
@@ -174,6 +179,7 @@ const handler = createInputHandler({
174
179
 
175
180
  process.stdin.on('data', chunk => handler.processChunk(chunk));
176
181
 
182
+ // ── Cleanup ────────────────────────────────────────────────────────────────────
177
183
  function cleanupAndExit(code) {
178
184
  try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {}
179
185
  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": "2.1.0",
4
+ "version": "2.4.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/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) {
@@ -101,7 +103,7 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
101
103
  continue;
102
104
  }
103
105
 
104
- // 3. Standard CSI sequences (arrows, Home, End, etc.)
106
+ // 3. Standard CSI sequences (arrows, Home, End, bracketed paste, etc.)
105
107
  const csi = parseCsiSeq(chunk, i);
106
108
  if (csi !== null) {
107
109
  if (csi.final === 'D' && !csi.params) cursor = Math.max(0, cursor - 1);
@@ -110,6 +112,8 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
110
112
  else if (csi.final === 'F' && !csi.params) cursor = lineBuffer.length;
111
113
  else if (csi.final === '~' && csi.params === '1') cursor = 0;
112
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;
113
117
  onPassthrough(chunk.slice(i, i + csi.consumed));
114
118
  i += csi.consumed;
115
119
  continue;
@@ -119,7 +123,13 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
119
123
  onPassthrough(chunk.slice(i));
120
124
  return;
121
125
  } else if (byte === 0x0d) {
122
- 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) {
123
133
  onEnhance(lineBuffer, cursor);
124
134
  return;
125
135
  } else {
@@ -127,6 +137,15 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
127
137
  onPassthrough(Buffer.from([byte]));
128
138
  lineBuffer = '';
129
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]));
130
149
  }
131
150
  i++;
132
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
+ }