ccx-relay 1.5.1 → 1.6.1

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/README.md CHANGED
@@ -38,7 +38,7 @@ npm install -g ccx-relay
38
38
  Run the one-time setup wizard:
39
39
 
40
40
  ```bash
41
- ccx init
41
+ ccx-init
42
42
  ```
43
43
 
44
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.
@@ -46,13 +46,13 @@ This will prompt for your Gemini API key (get one at https://aistudio.google.com
46
46
  Other init commands:
47
47
 
48
48
  ```bash
49
- ccx init --show # print current config (key masked)
50
- ccx init --reset # wipe config and start over
49
+ ccx-init --show # print current config (key masked)
50
+ ccx-init --reset # wipe config and start over
51
51
  ```
52
52
 
53
53
  ### Environment variable override
54
54
 
55
- You can skip `ccx init` and set env vars directly (useful for CI):
55
+ You can skip `ccx-init` and set env vars directly (useful for CI):
56
56
 
57
57
  ```bash
58
58
  export GEMINI_API_KEY=AIza...
@@ -81,14 +81,14 @@ While the wrapped process is running:
81
81
 
82
82
  ### Changing the marker
83
83
 
84
- Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes needed.
84
+ Change `CCX_MARKER` via `ccx-init` or env var (default `;;`). No source changes needed.
85
85
 
86
86
  ## Testing it locally
87
87
 
88
88
  1. `npm install`
89
- 2. `npm run test` — runs `node --check` against the CLI entry point to catch syntax errors.
89
+ 2. `npm test` — runs the unit test suite (`node --test`) covering config loading, the Gemini client, and keystroke handling.
90
90
  3. `npm link`
91
- 4. Set up `.env` with a real `GEMINI_API_KEY`.
91
+ 4. `ccx-init` — set up your real `GEMINI_API_KEY` (this validates the key and lets you pick a model live).
92
92
  5. Sanity-check the relay mechanics with a safe command first, before pointing it at `claude`:
93
93
 
94
94
  ```bash
@@ -97,9 +97,9 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
97
97
  ```
98
98
 
99
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.
100
+ then press **Enter**. You should see a spinner flash briefly (`⠋ Enhancing prompt...`), then `✓ Enhanced`, then
101
+ the line rewritten in place (e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter
102
+ again to run it as a normal shell command.
103
103
 
104
104
  6. Once the mechanics check out, run it against the real target:
105
105
 
@@ -109,22 +109,18 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
109
109
 
110
110
  ## Known limitations
111
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.
112
+ - **Cursor position is tracked, including mid-line edits.** Left/Right/Home/End move an internal cursor that stays
113
+ in sync with inserts, backspace-at-cursor, and both plain-byte and win32-input-mode key encodings — so arrowing
114
+ back into the middle of a line and editing there is expected to work, including for the enhance trigger.
120
115
  - **Byte-wise, not codepoint-wise.** Input is processed one byte at a time; multi-byte UTF-8 characters (accents,
121
116
  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.
117
+ child untouched when you're not triggering enhancement.
118
+ - **Status bar display is best-effort.** `ccx` draws the spinner/result to the last row of the terminal via ANSI
119
+ save/restore-cursor sequences. This works in standard ANSI/VT terminals (including the VS Code integrated
120
+ terminal) but isn't guaranteed on every terminal emulator, and a terminal resize mid-animation could leave a
121
+ stray line if the row count changes between draws.
126
122
  - **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.
123
+ trailing space after it or change the marker to something you don't otherwise type (via `ccx-init` or `CCX_MARKER`).
128
124
 
129
125
  ## Windows-specific notes
130
126
 
package/bin/ccx-init.js CHANGED
@@ -3,11 +3,10 @@
3
3
  import { createInterface } from 'node:readline';
4
4
  import { rmSync, existsSync } from 'node:fs';
5
5
  import { load, save, configPath } from '../src/config.js';
6
- import { enhance } from '../src/gemini.js';
6
+ import { enhance, listModels, RateLimitError } from '../src/gemini.js';
7
7
 
8
8
  const args = process.argv.slice(2);
9
9
 
10
- // Hidden input helper for API key
11
10
  async function promptHidden(question) {
12
11
  return new Promise(resolve => {
13
12
  process.stdout.write(question);
@@ -37,23 +36,19 @@ async function promptHidden(question) {
37
36
  });
38
37
  }
39
38
 
40
- // Regular prompt helper
41
39
  async function prompt(rl, question) {
42
40
  return new Promise(resolve => rl.question(question, resolve));
43
41
  }
44
42
 
45
- // Mask API key: show first 6 chars + ****...
46
43
  function maskKey(key) {
47
44
  if (!key) return '(not set)';
48
45
  if (key.length <= 6) return key + '****...';
49
46
  return key.slice(0, 6) + '****...';
50
47
  }
51
48
 
52
- // Show current config
53
49
  function showConfig() {
54
50
  const config = load();
55
51
  const path = configPath();
56
-
57
52
  console.log(`Config: ${path}\n`);
58
53
  console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
59
54
  console.log(` geminiModel: ${config.geminiModel}`);
@@ -61,82 +56,74 @@ function showConfig() {
61
56
  console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
62
57
  }
63
58
 
64
- // Reset config
65
59
  function resetConfig() {
66
60
  const path = configPath();
67
-
68
61
  if (existsSync(path)) {
69
62
  rmSync(path);
70
- console.log('Config reset. Run `ccx init` to set up again.');
63
+ console.log('Config reset. Run `ccx-init` to set up again.');
71
64
  } else {
72
65
  console.log('No config file found — nothing to reset.');
73
66
  }
74
67
  }
75
68
 
76
- // Interactive wizard
77
69
  async function runWizard() {
78
70
  console.log('\n Welcome to ccx setup\n');
79
71
 
80
- // Step 1: Prompt for API key
81
72
  const key = await promptHidden(' API key: ');
82
- if (!key) {
83
- console.log('No key entered. Aborting.');
84
- process.exit(1);
85
- }
73
+ if (!key) { console.log('No key entered. Aborting.'); process.exit(1); }
86
74
 
87
- // Step 2: Test the key
88
75
  process.stdout.write(' Testing key... ');
89
76
  try {
90
77
  await enhance('hello', { geminiApiKey: key, geminiModel: 'gemini-2.5-flash', timeoutSeconds: 10 });
91
78
  console.log('\x1b[32m✓ Valid\x1b[0m');
92
79
  } catch (err) {
93
- console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
94
- process.exit(1);
80
+ if (err instanceof RateLimitError) {
81
+ console.log('\x1b[32m✓ Valid\x1b[0m \x1b[2m(quota exceeded — key authenticated)\x1b[0m');
82
+ } else {
83
+ console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
84
+ process.exit(1);
85
+ }
95
86
  }
96
87
 
97
- // Step 3: Model picker
98
- const models = [
99
- 'gemini-2.5-flash',
100
- 'gemini-1.5-flash',
101
- 'gemini-2.5-pro'
102
- ];
88
+ // Fetch available models, fall back to hardcoded list
89
+ let models;
90
+ process.stdout.write(' Fetching available models... ');
91
+ try {
92
+ const all = await listModels(key);
93
+ models = all.filter(m => m.includes('gemini')).slice(0, 10);
94
+ if (models.length === 0) throw new Error('empty');
95
+ console.log(`\x1b[32m✓\x1b[0m ${models.length} found`);
96
+ } catch (_) {
97
+ models = ['gemini-2.5-flash', 'gemini-1.5-flash', 'gemini-2.5-pro'];
98
+ console.log('(using defaults)');
99
+ }
103
100
 
104
- const rl = createInterface({
105
- input: process.stdin,
106
- output: process.stdout,
107
- });
101
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
108
102
 
109
103
  console.log('');
110
104
  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');
105
+ models.forEach((m, i) => {
106
+ console.log(` ${i + 1}) ${m}${i === 0 ? ' (default)' : ''}`);
107
+ });
114
108
 
115
109
  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;
110
+ const idx = parseInt(modelChoice.trim(), 10);
111
+ const modelIndex = (!idx || idx < 1 || idx > models.length) ? 0 : idx - 1;
119
112
  const model = models[modelIndex];
120
113
 
121
- // Step 4: Trigger marker
122
114
  const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
123
115
  const marker = markerAnswer.trim() || ';;';
124
116
 
125
- // Step 5: Timeout seconds
126
117
  const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
127
118
  const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
128
119
 
129
120
  rl.close();
130
121
 
131
- // Step 6: Save config
132
122
  save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
133
-
134
- // Step 7: Success message
135
123
  console.log(`\n Config saved to ${configPath()}`);
136
124
  console.log(' Run `ccx claude` to start.\n');
137
125
  }
138
126
 
139
- // Main dispatch
140
127
  if (args.length === 0) {
141
128
  runWizard();
142
129
  } else if (args[0] === '--show') {
package/bin/ccx.js CHANGED
@@ -6,6 +6,49 @@ import { enhance, RateLimitError, AuthError, ModelError, TimeoutError, NetworkEr
6
6
  import { start as spinnerStart, stop as spinnerStop, clear as spinnerClear } from '../src/ui.js';
7
7
  import { createInputHandler } from '../src/input.js';
8
8
 
9
+ const argv = process.argv.slice(2);
10
+
11
+ // Shell tab-completion scripts
12
+ if (argv[0] === 'completion') {
13
+ const shell = argv[1] || 'bash';
14
+ const scripts = {
15
+ bash: [
16
+ '_ccx_completion() {',
17
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
18
+ ' COMPREPLY=($(compgen -W "claude bash zsh powershell cmd completion" -- "$cur"))',
19
+ '}',
20
+ 'complete -F _ccx_completion ccx',
21
+ ].join('\n'),
22
+ zsh: [
23
+ '#compdef ccx',
24
+ '_ccx() {',
25
+ ' local -a cmds',
26
+ ' cmds=(claude bash zsh powershell cmd completion)',
27
+ ' _describe \'command\' cmds',
28
+ '}',
29
+ '_ccx',
30
+ ].join('\n'),
31
+ powershell: [
32
+ 'Register-ArgumentCompleter -Native -CommandName ccx -ScriptBlock {',
33
+ ' param($wordToComplete, $commandAst, $cursorPosition)',
34
+ ' @(\'claude\',\'bash\',\'zsh\',\'powershell\',\'cmd\',\'completion\') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {',
35
+ ' [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_)',
36
+ ' }',
37
+ '}',
38
+ ].join('\n'),
39
+ };
40
+ if (!scripts[shell]) {
41
+ process.stderr.write(`Unknown shell: ${shell}. Use: bash, zsh, powershell\n`);
42
+ process.exit(1);
43
+ }
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');
48
+ else if (shell === 'powershell') process.stdout.write('# Invoke-Expression (ccx completion powershell)\n');
49
+ process.exit(0);
50
+ }
51
+
9
52
  let pty;
10
53
  try {
11
54
  pty = (await import('node-pty')).default;
@@ -21,7 +64,7 @@ try {
21
64
  const config = load();
22
65
 
23
66
  if (!config.geminiApiKey) {
24
- process.stderr.write('[ccx] No API key found. Run `ccx init` to set up.\n');
67
+ process.stderr.write('[ccx] No API key found. Run `ccx-init` to set up.\n');
25
68
  process.exit(1);
26
69
  }
27
70
 
@@ -30,9 +73,8 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
30
73
  process.exit(1);
31
74
  }
32
75
 
33
- const argv = process.argv.slice(2);
34
- const targetCmd = argv[0] || 'claude';
35
- const targetArgs= argv.slice(1);
76
+ const targetCmd = argv[0] || 'claude';
77
+ const targetArgs = argv.slice(1);
36
78
 
37
79
  const isWindows = process.platform === 'win32';
38
80
  const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
@@ -46,7 +88,26 @@ const ptyProcess = pty.spawn(shellFile, shellArgs, {
46
88
  env: process.env,
47
89
  });
48
90
 
49
- ptyProcess.onData(data => process.stdout.write(data));
91
+ // Ring buffer of recent PTY output lines for context-aware enhancement
92
+ const CONTEXT_MAX = 20;
93
+ const contextLines = [];
94
+
95
+ function stripAnsi(s) {
96
+ return s
97
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
98
+ .replace(/\x1b\][^\x07]*\x07/g, '')
99
+ .replace(/\r/g, '');
100
+ }
101
+
102
+ ptyProcess.onData(data => {
103
+ process.stdout.write(data);
104
+ for (const line of stripAnsi(data).split('\n')) {
105
+ if (line.trim()) {
106
+ contextLines.push(line.trim());
107
+ if (contextLines.length > CONTEXT_MAX) contextLines.shift();
108
+ }
109
+ }
110
+ });
50
111
 
51
112
  process.stdout.on('resize', () => {
52
113
  ptyProcess.resize(process.stdout.columns, process.stdout.rows);
@@ -58,38 +119,42 @@ process.stdin.resume();
58
119
  const handler = createInputHandler({
59
120
  marker: config.marker,
60
121
 
61
- onEnhance: async (line) => {
62
- // line may include trailing marker (;;) for the ;; trigger — strip it
122
+ onEnhance: async (line, cursor) => {
63
123
  const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
64
124
  handler.setBusy(true);
65
125
  spinnerStart();
66
126
  let improved;
67
127
  try {
68
- improved = await enhance(toSend, config);
128
+ const context = contextLines.length > 0 ? contextLines.join('\n') : null;
129
+ improved = await enhance(toSend, config, context);
69
130
  } catch (err) {
70
131
  let msg = 'Enhancement failed';
71
132
  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';
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';
74
135
  else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
75
136
  else if (err instanceof NetworkError) msg = 'No connection — original restored';
76
137
  await spinnerStop('error', msg);
77
- // Erase full echoed text (including marker if present), restore clean original
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`);
78
141
  ptyProcess.write(Buffer.alloc(line.length, 0x7f));
79
142
  ptyProcess.write(toSend);
80
143
  handler.setBusy(false);
144
+ handler.setLine(toSend);
81
145
  return;
82
146
  }
83
147
  await spinnerStop('success', 'Enhanced');
84
- // Erase full echoed text (including marker), write improved
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`);
85
151
  ptyProcess.write(Buffer.alloc(line.length, 0x7f));
86
152
  ptyProcess.write(improved);
87
153
  handler.setBusy(false);
154
+ handler.setLine(improved);
88
155
  },
89
156
 
90
- onSubmit: (_line) => {
91
- // Enter already forwarded by input.js passthrough
92
- },
157
+ onSubmit: (_line) => {},
93
158
 
94
159
  onPassthrough: (chunk) => ptyProcess.write(chunk),
95
160
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "ccx-relay",
4
- "version": "1.5.1",
4
+ "version": "1.6.1",
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": {
@@ -11,8 +11,7 @@
11
11
  "files": [
12
12
  "bin",
13
13
  "src",
14
- "README.md",
15
- ".env.example"
14
+ "README.md"
16
15
  ],
17
16
  "scripts": {
18
17
  "test": "node --test test/**/*.test.js",
package/src/gemini.js CHANGED
@@ -4,79 +4,84 @@ export class ModelError extends Error {}
4
4
  export class TimeoutError extends Error {}
5
5
  export class NetworkError extends Error {}
6
6
 
7
- const PROMPT_PREFIX =
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
10
  'This is a single line typed into a terminal prompt, not a chat message - ' +
11
11
  'respond with EXACTLY ONE rewritten version as a single plain-text line. ' +
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.\n\nText:\n';
14
+ 'Output must contain nothing but the rewritten line itself.';
15
15
 
16
- export async function enhance(text, config) {
17
- const { geminiApiKey, geminiModel, timeoutSeconds } = config;
16
+ function buildAuthHeaders(apiKey) {
17
+ // The x-goog-api-key header authenticates both "Auth key" (AQ.-prefixed)
18
+ // and legacy "Standard key" (AIza-prefixed) formats. Authorization: Bearer
19
+ // does NOT work here - AQ. keys are not OAuth2 access tokens and return
20
+ // 401 Unauthorized when sent that way (confirmed against the live API).
21
+ return { 'x-goog-api-key': apiKey };
22
+ }
23
+
24
+ export async function listModels(apiKey) {
25
+ const controller = new AbortController();
26
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
27
+ try {
28
+ const response = await fetch(
29
+ 'https://generativelanguage.googleapis.com/v1beta/models',
30
+ {
31
+ headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(apiKey) },
32
+ signal: controller.signal,
33
+ }
34
+ );
35
+ if (!response.ok) throw new AuthError('Could not fetch model list');
36
+ const data = await response.json();
37
+ return (data.models || [])
38
+ .filter(m => m.supportedGenerationMethods?.includes('generateContent'))
39
+ .map(m => m.name.replace('models/', ''));
40
+ } catch (err) {
41
+ if (err instanceof AuthError) throw err;
42
+ throw new NetworkError('Could not fetch model list');
43
+ } finally {
44
+ clearTimeout(timeoutId);
45
+ }
46
+ }
18
47
 
48
+ export async function enhance(text, config, context = null) {
49
+ const { geminiApiKey, geminiModel, timeoutSeconds } = config;
19
50
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
20
51
  const controller = new AbortController();
21
52
  let timeoutId;
22
53
 
54
+ const promptText = context
55
+ ? `${SYSTEM_PROMPT}\n\nRecent terminal context:\n${context}\n\nText:\n${text}`
56
+ : `${SYSTEM_PROMPT}\n\nText:\n${text}`;
57
+
23
58
  try {
24
59
  timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
25
60
 
26
- const authHeaders = geminiApiKey.startsWith('AQ.')
27
- ? { 'Authorization': `Bearer ${geminiApiKey}` }
28
- : { 'x-goog-api-key': geminiApiKey };
29
-
30
61
  const response = await fetch(url, {
31
62
  method: 'POST',
32
63
  signal: controller.signal,
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- ...authHeaders,
36
- },
37
- body: JSON.stringify({
38
- contents: [{ parts: [{ text: PROMPT_PREFIX + text }] }],
39
- }),
64
+ headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(geminiApiKey) },
65
+ body: JSON.stringify({ contents: [{ parts: [{ text: promptText }] }] }),
40
66
  });
41
67
 
42
- // Handle bad HTTP status codes
43
68
  if (!response.ok) {
44
69
  const body = await response.text();
45
- if (response.status === 429) {
46
- throw new RateLimitError('Quota exceeded');
47
- } else if (response.status === 401 || response.status === 403) {
48
- throw new AuthError('Invalid key run ccx init');
49
- } else if (response.status === 404) {
50
- throw new ModelError('Model not found — run ccx init');
51
- } else {
52
- const truncated = body.substring(0, 200);
53
- throw new NetworkError(`API error ${response.status}: ${truncated}`);
54
- }
70
+ if (response.status === 429) throw new RateLimitError('Quota exceeded');
71
+ else if (response.status === 401 || response.status === 403) throw new AuthError('Invalid key — run ccx init');
72
+ else if (response.status === 404) throw new ModelError('Model not found — run ccx init');
73
+ else throw new NetworkError(`API error ${response.status}: ${body.substring(0, 200)}`);
55
74
  }
56
75
 
57
- // Parse response
58
76
  const data = await response.json();
59
77
  const result = data.candidates[0].content.parts.map(p => p.text || '').join('').trim();
60
-
61
- if (!result) {
62
- throw new NetworkError('Gemini returned empty response');
63
- }
64
-
78
+ if (!result) throw new NetworkError('Gemini returned empty response');
65
79
  return result;
66
80
  } catch (err) {
67
- // Handle fetch errors
68
- if (err.name === 'AbortError') {
69
- throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
70
- }
71
-
72
- // Re-throw custom errors
81
+ if (err.name === 'AbortError') throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
73
82
  if (err instanceof RateLimitError || err instanceof AuthError ||
74
83
  err instanceof ModelError || err instanceof NetworkError ||
75
- err instanceof TimeoutError) {
76
- throw err;
77
- }
78
-
79
- // Wrap other errors as NetworkError
84
+ err instanceof TimeoutError) throw err;
80
85
  throw new NetworkError('No connection');
81
86
  } finally {
82
87
  clearTimeout(timeoutId);
package/src/input.js CHANGED
@@ -1,14 +1,3 @@
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
1
  function parseWin32InputSeq(chunk, i) {
13
2
  if (chunk[i + 1] !== 0x5b) return null;
14
3
  let j = i + 2;
@@ -21,16 +10,25 @@ function parseWin32InputSeq(chunk, i) {
21
10
  return { consumed: j + 1 - i, vk, sc, uc, kd, cs, rc };
22
11
  }
23
12
 
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
- */
13
+ // Parses standard CSI sequences: ESC [ params final
14
+ function parseCsiSeq(chunk, i) {
15
+ if (i + 1 >= chunk.length || chunk[i + 1] !== 0x5b) return null;
16
+ let j = i + 2;
17
+ while (j < chunk.length && chunk[j] >= 0x20 && chunk[j] <= 0x3f) j++;
18
+ if (j >= chunk.length || chunk[j] < 0x40 || chunk[j] > 0x7e) return null;
19
+ const params = chunk.slice(i + 2, j).toString('ascii');
20
+ const final = String.fromCharCode(chunk[j]);
21
+ return { consumed: j + 1 - i, params, final };
22
+ }
23
+
28
24
  export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough, onCtrlC }) {
29
25
  let lineBuffer = '';
26
+ let cursor = 0;
30
27
  let busy = false;
31
28
 
32
29
  function reset() {
33
30
  lineBuffer = '';
31
+ cursor = 0;
34
32
  busy = false;
35
33
  }
36
34
 
@@ -38,40 +36,48 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
38
36
  busy = b;
39
37
  }
40
38
 
39
+ // Sync internal buffer after external rewrite (e.g. after enhancement)
40
+ function setLine(str) {
41
+ lineBuffer = str;
42
+ cursor = str.length;
43
+ }
44
+
41
45
  function processChunk(chunk) {
42
- // When busy, forward everything as-is
43
- if (busy) {
44
- onPassthrough(chunk);
45
- return;
46
- }
46
+ if (busy) { onPassthrough(chunk); return; }
47
47
 
48
48
  let i = 0;
49
49
  while (i < chunk.length) {
50
50
  const byte = chunk[i];
51
51
 
52
52
  if (byte === 0x1b) {
53
- // Try win32-input-mode sequence first
53
+ // 1. Win32 input mode sequence
54
54
  const seq = parseWin32InputSeq(chunk, i);
55
55
  if (seq !== null) {
56
56
  if (seq.kd === 1) {
57
57
  if (seq.vk === 13) {
58
- // Win32 Enter
59
58
  if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
60
- onEnhance(lineBuffer);
59
+ onEnhance(lineBuffer, cursor);
61
60
  return;
62
61
  } else {
63
62
  onSubmit(lineBuffer);
64
63
  lineBuffer = '';
64
+ cursor = 0;
65
65
  }
66
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;
67
+ if (cursor > 0) {
68
+ lineBuffer = lineBuffer.slice(0, cursor - 1) + lineBuffer.slice(cursor);
69
+ cursor--;
74
70
  }
71
+ } else if (seq.vk === 37) {
72
+ cursor = Math.max(0, cursor - 1);
73
+ } else if (seq.vk === 39) {
74
+ cursor = Math.min(lineBuffer.length, cursor + 1);
75
+ } else if (seq.vk === 36) {
76
+ cursor = 0;
77
+ } else if (seq.vk === 35) {
78
+ cursor = lineBuffer.length;
79
+ } else if (seq.vk === 77 && (seq.cs & (0x0001 | 0x0002)) && !(seq.cs & (0x0004 | 0x0008))) {
80
+ if (lineBuffer.length > 0) { onEnhance(lineBuffer, cursor); return; }
75
81
  }
76
82
  }
77
83
  onPassthrough(chunk.slice(i, i + seq.consumed));
@@ -79,50 +85,67 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
79
85
  continue;
80
86
  }
81
87
 
82
- // No win32 sequence — check for standard Alt+M (ESC followed by 0x6d)
88
+ // 2. Alt+M (ESC m)
83
89
  if (i + 1 < chunk.length && chunk[i + 1] === 0x6d) {
84
90
  if (lineBuffer.length > 0) {
85
- onEnhance(lineBuffer);
91
+ onEnhance(lineBuffer, cursor);
86
92
  return;
87
93
  } else {
88
94
  onPassthrough(chunk.slice(i, i + 2));
89
95
  i += 2;
90
96
  }
91
- } else {
92
- // Forward rest untouched
93
- onPassthrough(chunk.slice(i));
94
- return;
97
+ continue;
95
98
  }
99
+
100
+ // 3. Standard CSI sequences (arrows, Home, End, etc.)
101
+ const csi = parseCsiSeq(chunk, i);
102
+ if (csi !== null) {
103
+ if (csi.final === 'D' && !csi.params) cursor = Math.max(0, cursor - 1);
104
+ else if (csi.final === 'C' && !csi.params) cursor = Math.min(lineBuffer.length, cursor + 1);
105
+ else if (csi.final === 'H' && !csi.params) cursor = 0;
106
+ else if (csi.final === 'F' && !csi.params) cursor = lineBuffer.length;
107
+ else if (csi.final === '~' && csi.params === '1') cursor = 0;
108
+ else if (csi.final === '~' && csi.params === '4') cursor = lineBuffer.length;
109
+ onPassthrough(chunk.slice(i, i + csi.consumed));
110
+ i += csi.consumed;
111
+ continue;
112
+ }
113
+
114
+ // 4. Unknown ESC sequence — forward rest of chunk
115
+ onPassthrough(chunk.slice(i));
116
+ return;
96
117
  } else if (byte === 0x0d) {
97
- // Enter
98
118
  if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
99
- onEnhance(lineBuffer);
119
+ onEnhance(lineBuffer, cursor);
100
120
  return;
101
121
  } else {
102
122
  onSubmit(lineBuffer);
103
123
  onPassthrough(Buffer.from([byte]));
104
124
  lineBuffer = '';
125
+ cursor = 0;
105
126
  }
106
127
  i++;
107
128
  } else if (byte === 0x03) {
108
- // Ctrl+C
109
129
  onCtrlC();
110
130
  onPassthrough(Buffer.from([byte]));
111
131
  lineBuffer = '';
132
+ cursor = 0;
112
133
  i++;
113
134
  } else if (byte === 0x7f || byte === 0x08) {
114
- // Backspace
115
- lineBuffer = lineBuffer.slice(0, -1);
135
+ if (cursor > 0) {
136
+ lineBuffer = lineBuffer.slice(0, cursor - 1) + lineBuffer.slice(cursor);
137
+ cursor--;
138
+ }
116
139
  onPassthrough(Buffer.from([byte]));
117
140
  i++;
118
141
  } else {
119
- // Printable char
120
- lineBuffer += String.fromCharCode(byte);
142
+ lineBuffer = lineBuffer.slice(0, cursor) + String.fromCharCode(byte) + lineBuffer.slice(cursor);
143
+ cursor++;
121
144
  onPassthrough(Buffer.from([byte]));
122
145
  i++;
123
146
  }
124
147
  }
125
148
  }
126
149
 
127
- return { processChunk, setBusy, reset };
150
+ return { processChunk, setBusy, reset, setLine };
128
151
  }
package/src/ui.js CHANGED
@@ -1,27 +1,17 @@
1
1
  const BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2
- const FRAME_INTERVAL = 80; // ms
2
+ const FRAME_INTERVAL = 80;
3
3
 
4
4
  let spinnerInterval = null;
5
5
  let currentFrame = 0;
6
6
 
7
- /**
8
- * Draws the status line on stderr with cursor save/restore
9
- */
10
7
  function drawStatusLine(content) {
11
- const ansi = `\x1b[s\r\n\x1b[2K${content}\x1b[u`;
12
- process.stderr.write(ansi);
8
+ const row = process.stdout.rows || 24;
9
+ process.stderr.write(`\x1b[s\x1b[${row};1H\x1b[2K${content}\x1b[u`);
13
10
  }
14
11
 
15
- /**
16
- * Starts animating the Braille spinner on stderr
17
- */
18
12
  export function start() {
19
- // Reset frame counter and stop any existing interval
20
13
  currentFrame = 0;
21
- if (spinnerInterval) {
22
- clearInterval(spinnerInterval);
23
- }
24
-
14
+ if (spinnerInterval) clearInterval(spinnerInterval);
25
15
  spinnerInterval = setInterval(() => {
26
16
  const frame = BRAILLE_FRAMES[currentFrame % BRAILLE_FRAMES.length];
27
17
  drawStatusLine(`${frame} Enhancing prompt...`);
@@ -29,37 +19,16 @@ export function start() {
29
19
  }, FRAME_INTERVAL);
30
20
  }
31
21
 
32
- /**
33
- * Stops the spinner and shows a final status message
34
- */
35
22
  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
23
+ if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
43
24
  const symbol = state === 'success' ? '✓' : '✗';
44
25
  const colorCode = state === 'success' ? '32' : '31';
45
- const statusLine = `\x1b[${colorCode}m${symbol} ${message}\x1b[0m`;
46
-
47
- drawStatusLine(statusLine);
48
-
49
- // Wait 400ms
26
+ drawStatusLine(`\x1b[${colorCode}m${symbol} ${message}\x1b[0m`);
50
27
  await new Promise(r => setTimeout(r, 400));
51
-
52
- // Clear the status line
53
28
  drawStatusLine('');
54
29
  }
55
30
 
56
- /**
57
- * Immediately clears the status line and stops spinner
58
- */
59
31
  export function clear() {
60
- if (spinnerInterval) {
61
- clearInterval(spinnerInterval);
62
- spinnerInterval = null;
63
- }
32
+ if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
64
33
  drawStatusLine('');
65
34
  }
package/.env.example DELETED
@@ -1,12 +0,0 @@
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=;;