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 +31 -25
- package/package.json +1 -1
- package/src/input.js +21 -2
- package/src/popup.js +241 -0
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
|
|
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,30 +111,36 @@ process.stdout.on('resize', () => {
|
|
|
113
111
|
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
114
112
|
});
|
|
115
113
|
|
|
116
|
-
|
|
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
|
|
122
|
-
if (
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
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 <
|
|
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)
|
|
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: (
|
|
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.
|
|
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 (
|
|
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
|
+
}
|