Skip to content

Commit e84ada6

Browse files
committed
fix: replace raw mode CliInput with readline — fixes CJK backspace, multi-message hang, exit hang
1 parent 6a7c698 commit e84ada6

2 files changed

Lines changed: 55 additions & 193 deletions

File tree

src/cli/input.ts

Lines changed: 53 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
11
import { EventEmitter } from "node:events";
2+
import readline from "node:readline";
23
import path from "node:path";
34
import fs from "node:fs/promises";
45
import chalk from "chalk";
56

67
/**
7-
* Custom CLI input handler with:
8-
* - Option+Enter (⌥+Enter) for newline insertion
9-
* - `"""` toggle for multiline block mode
10-
* - File drag-and-drop detection (paths dropped into terminal)
8+
* CLI input handler using readline (reliable, no raw mode issues).
9+
* Supports:
10+
* - `"""` multiline block mode
11+
* - File drag-and-drop detection
1112
*/
1213

13-
export interface InputEvents {
14-
message: [text: string];
15-
file: [filePath: string];
16-
exit: [];
17-
}
18-
1914
export class CliInput extends EventEmitter {
20-
private buffer: string = "";
21-
private multilineMode: boolean = false;
15+
private rl: readline.Interface | null = null;
2216
private promptStr: string;
2317
private userDir: string;
18+
private multilineMode = false;
19+
private multilineBuffer: string[] = [];
2420

2521
constructor(params: { prompt: string; userDir: string }) {
2622
super();
@@ -30,166 +26,71 @@ export class CliInput extends EventEmitter {
3026

3127
/** Start listening for input */
3228
start(): void {
33-
process.stdin.setRawMode(true);
34-
process.stdin.resume();
35-
process.stdin.setEncoding("utf-8");
36-
this.showPrompt();
29+
this.rl = readline.createInterface({
30+
input: process.stdin,
31+
output: process.stdout,
32+
prompt: this.promptStr,
33+
});
34+
35+
this.rl.prompt();
3736

38-
process.stdin.on("data", (chunk: string) => {
39-
this.handleData(chunk);
37+
this.rl.on("line", (line: string) => {
38+
this.handleLine(line);
39+
});
40+
41+
this.rl.on("close", () => {
42+
this.emit("exit");
4043
});
4144
}
4245

4346
/** Stop listening */
4447
stop(): void {
45-
process.stdin.setRawMode(false);
46-
process.stdin.pause();
47-
}
48-
49-
/** Temporarily disable raw mode (for sub-prompts like exec confirmation) */
50-
pause(): void {
51-
process.stdin.setRawMode(false);
48+
this.rl?.close();
49+
this.rl = null;
5250
}
5351

54-
/** Re-enable raw mode after pause */
55-
resume(): void {
56-
process.stdin.setRawMode(true);
52+
/** Show the prompt again (called externally after processing) */
53+
showInputPrompt(): void {
54+
this.rl?.prompt();
5755
}
5856

59-
private showPrompt(): void {
57+
private handleLine(line: string): void {
58+
// --- Multiline mode ---
6059
if (this.multilineMode) {
61-
process.stdout.write(chalk.dim("... "));
62-
} else {
63-
process.stdout.write(this.promptStr);
64-
}
65-
}
66-
67-
private handleData(chunk: string): void {
68-
// Ctrl+C — exit
69-
if (chunk === "\x03") {
70-
process.stdout.write("\n");
71-
this.emit("exit");
72-
return;
73-
}
74-
75-
// Ctrl+D — exit
76-
if (chunk === "\x04") {
77-
process.stdout.write("\n");
78-
this.emit("exit");
79-
return;
80-
}
81-
82-
// Option+Enter (ESC followed by CR) — insert newline
83-
if (chunk === "\x1b\r" || chunk === "\x1b\n") {
84-
this.buffer += "\n";
85-
process.stdout.write("\n");
86-
process.stdout.write(chalk.dim("... "));
87-
return;
88-
}
89-
90-
// Shift+Enter in kitty terminal protocol — insert newline
91-
if (chunk === "\x1b[13;2u") {
92-
this.buffer += "\n";
93-
process.stdout.write("\n");
94-
process.stdout.write(chalk.dim("... "));
95-
return;
96-
}
97-
98-
// Enter (CR) — submit or continue multiline
99-
if (chunk === "\r" || chunk === "\n") {
100-
process.stdout.write("\n");
101-
102-
// Check for """ toggle
103-
if (this.buffer.trim() === '"""') {
104-
this.multilineMode = true;
105-
this.buffer = "";
106-
process.stdout.write(chalk.dim("📝 Multiline mode (enter \"\"\" to send)\n"));
107-
this.showPrompt();
108-
return;
109-
}
110-
111-
// In multiline mode, """ on its own line sends the buffer
112-
if (this.multilineMode && this.buffer.trimEnd().endsWith('"""')) {
60+
if (line.trim() === '"""') {
61+
// End multiline: send accumulated buffer
11362
this.multilineMode = false;
114-
const text = this.buffer.trimEnd().slice(0, -3).trimEnd();
115-
this.buffer = "";
63+
const text = this.multilineBuffer.join("\n").trim();
64+
this.multilineBuffer = [];
11665
if (text) {
11766
this.processInput(text);
11867
} else {
119-
this.showPrompt();
68+
this.rl?.prompt();
12069
}
121-
return;
122-
}
123-
124-
// In multiline mode, Enter just adds a newline
125-
if (this.multilineMode) {
126-
this.buffer += "\n";
127-
this.showPrompt();
128-
return;
129-
}
130-
131-
// Normal mode: submit
132-
const text = this.buffer.trim();
133-
this.buffer = "";
134-
if (text) {
135-
this.processInput(text);
13670
} else {
137-
this.showPrompt();
138-
}
139-
return;
140-
}
141-
142-
// Backspace
143-
if (chunk === "\x7f" || chunk === "\b") {
144-
if (this.buffer.length > 0) {
145-
const removed = this.buffer.slice(-1);
146-
this.buffer = this.buffer.slice(0, -1);
147-
// CJK and other wide characters take 2 terminal columns
148-
if (this.isWideChar(removed)) {
149-
process.stdout.write("\b \b\b \b");
150-
} else {
151-
process.stdout.write("\b \b");
152-
}
71+
this.multilineBuffer.push(line);
72+
process.stdout.write(chalk.dim("... "));
15373
}
15474
return;
15575
}
15676

157-
// Ctrl+U — clear line
158-
if (chunk === "\x15") {
159-
process.stdout.clearLine(0);
160-
process.stdout.cursorTo(0);
161-
this.buffer = "";
162-
this.showPrompt();
163-
return;
164-
}
165-
166-
// Escape alone — ignore (don't print)
167-
if (chunk === "\x1b") {
77+
// --- Start multiline mode ---
78+
if (line.trim() === '"""') {
79+
this.multilineMode = true;
80+
this.multilineBuffer = [];
81+
console.log(chalk.dim('📝 Multiline mode — type """ on a new line to send'));
82+
process.stdout.write(chalk.dim("... "));
16883
return;
16984
}
17085

171-
// Arrow keys and other escape sequences — ignore
172-
if (chunk.startsWith("\x1b[")) {
86+
// --- Normal single-line input ---
87+
const text = line.trim();
88+
if (!text) {
89+
this.rl?.prompt();
17390
return;
17491
}
17592

176-
// Paste detection: if chunk contains multiple chars with newlines, handle as paste
177-
if (chunk.length > 1 && chunk.includes("\n")) {
178-
const lines = chunk.split("\n");
179-
for (let i = 0; i < lines.length; i++) {
180-
this.buffer += lines[i];
181-
process.stdout.write(lines[i]);
182-
if (i < lines.length - 1) {
183-
this.buffer += "\n";
184-
process.stdout.write("\n" + chalk.dim("... "));
185-
}
186-
}
187-
return;
188-
}
189-
190-
// Regular character(s)
191-
this.buffer += chunk;
192-
process.stdout.write(chunk);
93+
this.processInput(text);
19394
}
19495

19596
private processInput(text: string): void {
@@ -201,7 +102,7 @@ export class CliInput extends EventEmitter {
201102
}
202103

203104
// Check if input looks like a file path (drag-and-drop detection)
204-
const cleanPath = text.replace(/^['"]|['"]$/g, "").trim(); // strip quotes from drag
105+
const cleanPath = text.replace(/^['"]|['"]$/g, "").trim();
205106
if (this.looksLikeFilePath(cleanPath)) {
206107
this.handleFileDrop(cleanPath);
207108
return;
@@ -212,77 +113,44 @@ export class CliInput extends EventEmitter {
212113
}
213114

214115
private looksLikeFilePath(text: string): boolean {
215-
// Must be a single "line" (no spaces that look like conversation)
216116
if (text.includes("\n")) return false;
217-
// Must start with / or ~ (absolute path) and not contain common sentence patterns
218117
if (!text.startsWith("/") && !text.startsWith("~")) return false;
219-
// Must have a file extension or end with /
220118
if (text.includes(" ") && !text.includes("\\ ")) return false;
221119
return true;
222120
}
223121

224122
private async handleFileDrop(filePath: string): Promise<void> {
225-
// Resolve ~ and escaped spaces
226123
const resolved = filePath
227124
.replace(/^~/, process.env.HOME ?? "")
228125
.replace(/\\ /g, " ");
229126

230127
try {
231128
const stat = await fs.stat(resolved);
232129
if (!stat.isFile()) {
233-
process.stdout.write(chalk.yellow("⚠️ Not a file: " + resolved + "\n"));
234-
this.showPrompt();
130+
console.log(chalk.yellow("⚠️ Not a file: " + resolved));
131+
this.rl?.prompt();
235132
return;
236133
}
237134

238135
const fileName = path.basename(resolved);
239136
const dest = path.join(this.userDir, fileName);
240137

241-
// Check if file already exists
242138
try {
243139
await fs.access(dest);
244-
process.stdout.write(chalk.yellow(`⚠️ ${fileName} already exists in user/\n`));
245-
this.showPrompt();
140+
console.log(chalk.yellow(`⚠️ ${fileName} already exists in user/`));
141+
this.rl?.prompt();
246142
return;
247143
} catch {
248144
// Doesn't exist, good
249145
}
250146

251147
await fs.copyFile(resolved, dest);
252148
const sizeKb = (stat.size / 1024).toFixed(1);
253-
process.stdout.write(
254-
chalk.green(`✓ Copied to user/${fileName} (${sizeKb} KB)\n`),
255-
);
149+
console.log(chalk.green(`✓ Copied to user/${fileName} (${sizeKb} KB)`));
256150
this.emit("file", dest);
257151
} catch {
258-
// Not a valid path, treat as regular message
259152
this.emit("message", filePath);
260153
}
261-
this.showPrompt();
262-
}
263-
264-
/** Show the prompt again (called externally after processing) */
265-
showInputPrompt(): void {
266-
this.showPrompt();
267-
}
268-
269-
/** Check if a character is full-width (CJK, emoji, etc.) */
270-
private isWideChar(ch: string): boolean {
271-
const code = ch.codePointAt(0) ?? 0;
272-
return (
273-
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
274-
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals
275-
(code >= 0x3040 && code <= 0x33bf) || // Japanese
276-
(code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Extension A
277-
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified
278-
(code >= 0xa960 && code <= 0xa97c) || // Hangul Jamo Extended-A
279-
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
280-
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility
281-
(code >= 0xfe30 && code <= 0xfe6b) || // CJK Compatibility Forms
282-
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
283-
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
284-
(code >= 0x1f000 && code <= 0x1fbff) || // Emoji & Symbols
285-
(code >= 0x20000 && code <= 0x2ffff) // CJK Extension B+
286-
);
154+
this.rl?.prompt();
287155
}
288156
}

src/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,12 @@ async function main() {
179179
console.log("");
180180
console.log(chalk.cyan("📖 Quick Guide:"));
181181
console.log(chalk.dim(" • 输入 exit 或 quit 或 Ctrl+C 退出"));
182-
console.log(chalk.dim(" • ⌥+Enter (Option+Enter) 换行,支持多行输入"));
183182
console.log(chalk.dim(' • 输入 """ 进入多行模式,再次输入 """ 发送'));
184183
console.log(chalk.dim(" • 拖拽文件到终端,自动复制到 user/ 文件夹"));
185184
console.log(chalk.dim(" • 在 skills/ 下添加 SKILL.md 可扩展 AI 的能力"));
186185
console.log(chalk.dim("\n" + "─".repeat(60)) + "\n");
187186

188-
// Create custom input handler
187+
// Create input handler
189188
const input = new CliInput({
190189
prompt: chalk.cyan("You: "),
191190
userDir: path.join(workspaceDir, "user"),
@@ -204,7 +203,7 @@ async function main() {
204203
input.showInputPrompt();
205204
});
206205

207-
input.on("file", (_filePath: string) => {
206+
input.on("file", () => {
208207
// File was already copied by CliInput
209208
});
210209

@@ -213,14 +212,9 @@ async function main() {
213212
console.log(chalk.dim("\nGoodbye! 🦐\n"));
214213
agent.stop();
215214
input.stop();
216-
clearInterval(keepAlive);
217215
process.exit(0);
218216
});
219217

220-
// Keep the process alive — this interval prevents early exit
221-
// when stdin might momentarily have no active listeners
222-
const keepAlive = setInterval(() => { }, 1 << 30);
223-
224218
input.start();
225219
}
226220

0 commit comments

Comments
 (0)