Swadhin Biswas feat: implement mervelas cli engine, built-in tools, and reactive UI · 4/3/2026
0358986
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
/**
* Early Input Capture
*
* This module captures terminal input that is typed before the REPL is fully
* initialized. Users often type `claude` and immediately start typing their
* prompt, but those early keystrokes would otherwise be lost during startup.
*
* Usage:
* 1. Call startCapturingEarlyInput() as early as possible in cli.tsx
* 2. When REPL is ready, call consumeEarlyInput() to get any buffered text
* 3. stopCapturingEarlyInput() is called automatically when input is consumed
*/
import { lastGrapheme } from './intl.js'
// Buffer for early input characters
let earlyInputBuffer = ''
// Flag to track if we're currently capturing
let isCapturing = false
// Reference to the readable handler so we can remove it later
let readableHandler: (() => void) | null = null
/**
* Start capturing stdin data early, before the REPL is initialized.
* Should be called as early as possible in the startup sequence.
*
* Only captures if stdin is a TTY (interactive terminal).
*/
export function startCapturingEarlyInput(): void {
// Only capture in interactive mode: stdin must be a TTY, and we must not
// be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT),
// which would make -p uninterruptible.
if (
!process.stdin.isTTY ||
isCapturing ||
process.argv.includes('-p') ||
process.argv.includes('--print')
) {
return
}
isCapturing = true
earlyInputBuffer = ''
// Set stdin to raw mode and use 'readable' event like Ink does
// This ensures compatibility with how the REPL will handle stdin later
try {
process.stdin.setEncoding('utf8')
process.stdin.setRawMode(true)
process.stdin.ref()
readableHandler = () => {
let chunk = process.stdin.read()
while (chunk !== null) {
if (typeof chunk === 'string') {
processChunk(chunk)
}
chunk = process.stdin.read()
}
}
process.stdin.on('readable', readableHandler)
process.stdin.resume()
} catch {
// If we can't set raw mode, just silently continue without early capture
isCapturing = false
}
}
/**
* Process a chunk of input data
*/
function processChunk(str: string): void {
let i = 0
while (i < str.length) {
const char = str[i]!
const code = char.charCodeAt(0)
// Ctrl+C (code 3) - stop capturing and exit immediately.
// We use process.exit here instead of gracefulShutdown because at this
// early stage of startup, the shutdown machinery isn't initialized yet.
if (code === 3) {
stopCapturingEarlyInput()
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(130) // Standard exit code for Ctrl+C
return
}
// Ctrl+D (code 4) - EOF, stop capturing
if (code === 4) {
stopCapturingEarlyInput()
return
}
// Backspace (code 127 or 8) - remove last grapheme cluster
if (code === 127 || code === 8) {
if (earlyInputBuffer.length > 0) {
const last = lastGrapheme(earlyInputBuffer)
earlyInputBuffer = earlyInputBuffer.slice(0, -(last.length || 1))
}
i++
continue
}
// Skip escape sequences (arrow keys, function keys, focus events, etc.)
// All escape sequences start with ESC (0x1B) and end with a byte in 0x40-0x7E
if (code === 27) {
i++ // Skip the ESC character
// Skip until the terminating byte (@ to ~) or end of string
while (
i < str.length &&
!(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126)
) {
i++
}
if (i < str.length) i++ // Skip the terminating byte
continue
}
// Skip other control characters (except tab and newline)
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
i++
continue
}
// Convert carriage return to newline
if (code === 13) {
earlyInputBuffer += '\n'
i++
continue
}
// Add printable characters and allowed control chars to buffer
earlyInputBuffer += char
i++
}
}
/**
* Stop capturing early input.
* Called automatically when input is consumed, or can be called manually.
*/
export function stopCapturingEarlyInput(): void {
if (!isCapturing) {
return
}
isCapturing = false
if (readableHandler) {
process.stdin.removeListener('readable', readableHandler)
readableHandler = null
}
// Don't reset stdin state - the REPL's Ink App will manage stdin state.
// If we call setRawMode(false) here, it can interfere with the REPL's
// own stdin setup which happens around the same time.
}
/**
* Consume any early input that was captured.
* Returns the captured input and clears the buffer.
* Automatically stops capturing when called.
*/
export function consumeEarlyInput(): string {
stopCapturingEarlyInput()
const input = earlyInputBuffer.trim()
earlyInputBuffer = ''
return input
}
/**
* Check if there is any early input available without consuming it.
*/
export function hasEarlyInput(): boolean {
return earlyInputBuffer.trim().length > 0
}
/**
* Seed the early input buffer with text that will appear pre-filled
* in the prompt input when the REPL renders. Does not auto-submit.
*/
export function seedEarlyInput(text: string): void {
earlyInputBuffer = text
}
/**
* Check if early input capture is currently active.
*/
export function isCapturingEarlyInput(): boolean {
return isCapturing
}