11import { EventEmitter } from "node:events" ;
2+ import readline from "node:readline" ;
23import path from "node:path" ;
34import fs from "node:fs/promises" ;
45import 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-
1914export 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}
0 commit comments