Skip to content

Commit d8b3014

Browse files
committed
add noncanonical mode terminal input for prompt
1 parent 787a46d commit d8b3014

File tree

1 file changed

+151
-2
lines changed

1 file changed

+151
-2
lines changed

src/bun.js/webcore/prompt.zig

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ pub const prompt = struct {
194194
}
195195

196196
/// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-prompt
197+
/// This implementation has two modes:
198+
/// 1. If stdin is an interactive TTY, it switches the terminal to raw mode to
199+
/// provide a rich editing experience with cursor movement and history.
200+
/// 2. If stdin is not a TTY (e.g., piped input), it falls back to a simple
201+
/// buffered line reader.
197202
pub fn call(
198203
globalObject: *jsc.JSGlobalObject,
199204
callframe: *jsc.CallFrame,
@@ -231,7 +236,7 @@ pub const prompt = struct {
231236
// default.
232237
output.writeAll(if (has_message) " " else "Prompt ") catch {
233238
// 1. If we cannot show simple dialogs for this, then return false.
234-
return .false;
239+
return .null;
235240
};
236241

237242
if (has_default) {
@@ -240,7 +245,7 @@ pub const prompt = struct {
240245

241246
output.print("[{s}] ", .{default_string.slice()}) catch {
242247
// 1. If we cannot show simple dialogs for this, then return false.
243-
return .false;
248+
return .null;
244249
};
245250
}
246251

@@ -260,6 +265,150 @@ pub const prompt = struct {
260265
};
261266

262267
// 7. Pause while waiting for the user's response.
268+
if (comptime !Environment.isWindows) {
269+
const c_termios = @cImport({
270+
@cInclude("termios.h");
271+
@cInclude("unistd.h");
272+
@cInclude("signal.h");
273+
});
274+
275+
if (c_termios.isatty(bun.FD.stdin().native()) == 1) {
276+
var original_termios: c_termios.termios = undefined;
277+
if (c_termios.tcgetattr(bun.FD.stdin().native(), &original_termios) != 0) {
278+
return .null;
279+
}
280+
281+
defer {
282+
_ = c_termios.tcsetattr(bun.FD.stdin().native(), c_termios.TCSADRAIN, &original_termios);
283+
// Move cursor to next line after input is done
284+
_ = bun.Output.writer().writeAll("\n") catch {};
285+
bun.Output.flush();
286+
}
287+
288+
var raw_termios = original_termios;
289+
// Unset canonical mode and echo
290+
raw_termios.c_lflag &= ~@as(c_termios.tcflag_t, c_termios.ICANON | c_termios.ECHO);
291+
292+
if (c_termios.tcsetattr(bun.FD.stdin().native(), c_termios.TCSADRAIN, &raw_termios) != 0) {
293+
return .null;
294+
}
295+
296+
var input = std.ArrayList(u8).init(allocator);
297+
defer input.deinit();
298+
var cursor_index: usize = 0;
299+
300+
const reader = bun.Output.buffered_stdin.reader();
301+
var stdout_writer = bun.Output.writer();
302+
303+
while (true) {
304+
const byte = reader.readByte() catch {
305+
// User aborted (Ctrl+D)
306+
return .null;
307+
};
308+
309+
switch (byte) {
310+
// End of input
311+
'\n', '\r' => {
312+
if (input.items.len == 0 and !has_default) return jsc.ZigString.init("").toJS(globalObject);
313+
if (input.items.len == 0) return default;
314+
315+
var result = jsc.ZigString.init(input.items);
316+
result.markUTF8();
317+
return result.toJS(globalObject);
318+
},
319+
320+
// Backspace (ASCII 8) or DEL (ASCII 127)
321+
8, 127 => {
322+
if (cursor_index > 0) {
323+
_ = input.orderedRemove(cursor_index - 1);
324+
cursor_index -= 1;
325+
326+
// Redraw the line from the cursor
327+
_ = stdout_writer.writeAll("\x1b[D") catch {}; // Move cursor left
328+
_ = stdout_writer.writeAll(input.items[cursor_index..]) catch {};
329+
_ = stdout_writer.writeAll(" ") catch {}; // Clear the character at the end
330+
_ = stdout_writer.print("\x1b[{d}D", .{input.items.len - cursor_index + 1}) catch {}; // Move cursor back
331+
bun.Output.flush();
332+
}
333+
},
334+
335+
// Ctrl+C
336+
3 => {
337+
// This will trigger the defer and restore terminal settings
338+
_ = c_termios.raise(c_termios.SIGINT);
339+
return .null;
340+
},
341+
342+
// Escape sequence (e.g., arrow keys)
343+
27 => {
344+
// Try to read the next two bytes for [D (left) or [C (right)
345+
const byte2 = reader.readByte() catch continue;
346+
if (byte2 != '[') {
347+
continue;
348+
}
349+
switch (reader.readByte() catch continue) {
350+
'D' => { // Left arrow
351+
if (cursor_index > 0) {
352+
cursor_index -= 1;
353+
_ = stdout_writer.writeAll("\x1b[D") catch {};
354+
bun.Output.flush();
355+
}
356+
},
357+
'C' => { // Right arrow
358+
if (cursor_index < input.items.len) {
359+
cursor_index += 1;
360+
_ = stdout_writer.writeAll("\x1b[C") catch {};
361+
bun.Output.flush();
362+
}
363+
},
364+
'3' => { // DEL
365+
const next = reader.readByte() catch continue;
366+
if (next != '~') {
367+
// Signifies that there is a modifier key (SHIFT, CTRL).
368+
// We ignore the modifier as that is what canonical mode does.
369+
if (next == ';') {
370+
_ = reader.readByte() catch continue; // modifier key skipped
371+
const final = reader.readByte() catch continue;
372+
if (final != '~') {
373+
continue;
374+
}
375+
} else {
376+
continue;
377+
}
378+
}
379+
// Handle Delete key: remove character under cursor
380+
if (cursor_index < input.items.len) {
381+
_ = input.orderedRemove(cursor_index);
382+
383+
// Redraw from cursor
384+
_ = stdout_writer.writeAll(input.items[cursor_index..]) catch {};
385+
_ = stdout_writer.writeAll(" ") catch {};
386+
_ = stdout_writer.print("\x1b[{d}D", .{input.items.len - cursor_index + 1}) catch {};
387+
bun.Output.flush();
388+
}
389+
},
390+
else => {},
391+
}
392+
},
393+
394+
else => {
395+
try input.insert(cursor_index, byte);
396+
cursor_index += 1;
397+
398+
// Echo the new character and redraw the rest of the line
399+
_ = stdout_writer.writeAll(input.items[cursor_index - 1 ..]) catch {};
400+
// Move cursor back to its correct position
401+
if (input.items.len > cursor_index) {
402+
_ = stdout_writer.print("\x1b[{d}D", .{input.items.len - cursor_index}) catch {};
403+
}
404+
bun.Output.flush();
405+
},
406+
}
407+
}
408+
}
409+
}
410+
411+
// Fallback for non-interactive terminals (or Windows)
263412
const reader = bun.Output.buffered_stdin.reader();
264413
var second_byte: ?u8 = null;
265414
const first_byte = reader.readByte() catch {

0 commit comments

Comments
 (0)