Skip to content
Open
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 297 additions & 0 deletions src/bun.js/webcore/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,127 @@ fn confirm(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE
}

pub const prompt = struct {
const KEY_CTRL_C = 3;
const KEY_CTRL_D = 4;
const KEY_BACKSPACE = 8;
const KEY_TAB = 9;
const KEY_ENTER = 13;
const KEY_ESC = 27;
const KEY_DEL = 127;
Comment on lines +155 to +161
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these should be deleted and replaced with std.ascii.control_code


fn handleBackspace(input: *std.ArrayList(u8), cursor_index: *usize) void {
if (cursor_index.* > 0) {
const old_cursor_index = cursor_index.*;
const prev_codepoint_start = utf8Prev(input.items, old_cursor_index);

if (prev_codepoint_start) |start| {
var i: usize = 0;
while (i < old_cursor_index - start) : (i += 1) {
_ = input.orderedRemove(start);
}
cursor_index.* = start;
} else {
_ = input.orderedRemove(old_cursor_index - 1);
cursor_index.* -= 1;
}
}
}

fn utf8Prev(slice: []const u8, index: usize) ?usize {
if (index == 0) return null;
var i = index - 1;
// Search backward for the start byte of a codepoint.
// A continuation byte starts with 0b10xxxxxx.
while (i > 0 and (slice[i] & 0b11000000) == 0b10000000) {
i -= 1;
}
// If we found a start byte, return its index.
// This handles ASCII (0xxxxxxx) and multibyte start bytes (11xxxxxx).
// If we stopped at 0, it's either a valid start byte or an invalid continuation byte.
// We return i, and the caller's logic will handle the deletion.
return i;
}

fn utf8Next(slice: []const u8, index: usize) ?usize {
if (index >= slice.len) return null;
// Use the project's internal function to get the byte length of the codepoint.
const len = bun.strings.utf8ByteSequenceLength(slice[index]);
const next_index = index + len;
if (next_index > slice.len) return null;
return next_index;
}

fn fullRedraw(stdout_writer: anytype, input: *std.ArrayList(u8), cursor_index: usize, prompt_width: usize) !void {
// 1. Move cursor to the start of the input area using absolute positioning (column prompt_width + 1).
_ = stdout_writer.print("\x1b[{d}G", .{prompt_width + 1}) catch {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why catch {}? Looks like it would be okay to return writer errors from this function


// 2. Write the entire input buffer, expanding tabs to spaces to ensure correct alignment.
var i: usize = 0;
var current_column = prompt_width;
while (i < input.items.len) {
const char = input.items[i];
if (char == '\t') {
const spaces_to_add = 8;
var j: usize = 0;
while (j < spaces_to_add) : (j += 1) {
_ = stdout_writer.writeByte(' ') catch {};
}
current_column += spaces_to_add;
i += 1;
} else {
const codepoint_slice = input.items[i..];
const next_codepoint_start = utf8Next(codepoint_slice, 0);
const codepoint_len = next_codepoint_start orelse 1;
const char_width = columnWidth(codepoint_slice[0..codepoint_len]);

_ = stdout_writer.writeAll(codepoint_slice[0..codepoint_len]) catch {};

current_column += char_width;
i += codepoint_len;
}
}

// 3. Clear the rest of the line.
_ = stdout_writer.writeAll("\x1b[K") catch {};

// 4. Move cursor to the correct column position using absolute positioning.
const new_column_width = columnPosition(input.items, cursor_index, prompt_width);
_ = stdout_writer.print("\x1b[{d}G", .{prompt_width + new_column_width + 1}) catch {};

bun.Output.flush();
}

fn calculateColumn(slice: []const u8, byte_index: usize, start_column: usize) usize {
var column: usize = start_column;
var i: usize = 0;
while (i < byte_index) {
const char = slice[i];
if (char == '\t') {
// Tab stop of 8 columns
column += 8;
i += 1;
} else {
// Use the project's visible width function for other characters
const codepoint_slice = slice[i..];
const next_codepoint_start = utf8Next(codepoint_slice, 0);
const codepoint_len = next_codepoint_start orelse 1;
const char_width = columnWidth(codepoint_slice[0..codepoint_len]);

column += char_width;
i += codepoint_len;
}
}
return column;
}

fn columnPosition(slice: []const u8, byte_index: usize, start_column: usize) usize {
return calculateColumn(slice, byte_index, start_column) - start_column;
}

fn columnWidth(slice: []const u8) usize {
return bun.strings.visible.width.utf8(slice);
}

/// Adapted from `std.io.Reader.readUntilDelimiterArrayList` to only append
/// and assume capacity.
pub fn readUntilDelimiterArrayListAppendAssumeCapacity(
Expand Down Expand Up @@ -194,6 +315,11 @@ pub const prompt = struct {
}

/// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-prompt
/// This implementation has two modes:
/// 1. If stdin is an interactive TTY, it switches the terminal to raw mode to
/// provide a rich editing experience with cursor movement.
/// 2. If stdin is not a TTY (e.g., piped input), it falls back to a simple
/// buffered line reader.
pub fn call(
globalObject: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
Expand Down Expand Up @@ -260,6 +386,177 @@ pub const prompt = struct {
};

// 7. Pause while waiting for the user's response.
if (comptime !Environment.isWindows) {
const c_termios = @cImport({
@cInclude("termios.h");
@cInclude("unistd.h");
@cInclude("signal.h");
});

if (c_termios.isatty(bun.FD.stdin().native()) == 1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (c_termios.isatty(bun.FD.stdin().native()) == 1) {
if (bun.c.isatty(bun.FD.stdin().native()) == 1) {

also let's invert this check and break out of this block if it's false. this means all the code inside of the if will have one less indent

var original_termios: c_termios.termios = undefined;
var pending_sigint: bool = false;
if (c_termios.tcgetattr(bun.FD.stdin().native(), &original_termios) != 0) {
return .null;
}

defer {
_ = c_termios.tcsetattr(bun.FD.stdin().native(), c_termios.TCSADRAIN, &original_termios);
// Move cursor to next line after input is done
_ = bun.Output.writer().writeAll("\n") catch {};
bun.Output.flush();
if (pending_sigint) {
_ = c_termios.raise(c_termios.SIGINT);
}
}

var raw_termios = original_termios;
// Unset canonical mode, echo, signal generation, and extended input processing
raw_termios.c_lflag &= ~@as(c_termios.tcflag_t, c_termios.ICANON | c_termios.ECHO | c_termios.ISIG | c_termios.IEXTEN);
// Set VMIN=1 and VTIME=0 for non-canonical read (read returns after 1 byte)
raw_termios.c_cc[c_termios.VMIN] = 1;
raw_termios.c_cc[c_termios.VTIME] = 0;

if (c_termios.tcsetattr(bun.FD.stdin().native(), c_termios.TCSADRAIN, &raw_termios) != 0) {
return .null;
}
Comment on lines +393 to +425
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setting raw mode can be replaced with Bun__ttySetMode. see init_command.zig for an example of this


var input = std.ArrayList(u8).init(allocator);
defer input.deinit();
var cursor_index: usize = 0;
var prompt_width: usize = 0;

// Calculate prompt width for redraws
if (has_message) {
const message = try arguments[0].toSlice(globalObject, allocator);
defer message.deinit();
prompt_width += columnWidth(message.slice());
}
prompt_width += columnWidth(if (has_message) " " else "Prompt ");

if (has_default) {
const default_string = try arguments[1].toSlice(globalObject, allocator);
defer default_string.deinit();

prompt_width += columnWidth("[") + columnWidth(default_string.slice()) + columnWidth("] ");
}
Comment on lines +433 to +445
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of getting the string arguments again, would it be possible to reuse the results of arguments[0/1].toSlice from above


const reader = bun.Output.buffered_stdin.reader();
const stdout_writer = bun.Output.writer();

while (true) {
const byte = reader.readByte() catch {
// Real I/O error or EOF from upstream (not user EOT)
return .null;
};

switch (byte) {
KEY_TAB => {
try input.insert(cursor_index, byte);
cursor_index += 1;
},

// End of input
'\n', KEY_ENTER => {
if (input.items.len == 0 and !has_default) return jsc.ZigString.init("").toJS(globalObject);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (input.items.len == 0 and !has_default) return jsc.ZigString.init("").toJS(globalObject);
if (input.items.len == 0 and !has_default) return bun.String.empty.toJS(globalObject);

if (input.items.len == 0) return default;

var result = jsc.ZigString.init(input.items);
result.markUTF8();
return result.toJS(globalObject);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return result.toJS(globalObject);
return bun.String.createUTF8ForJS(globalObject, input.items);

},

// Backspace
KEY_BACKSPACE, KEY_DEL => handleBackspace(&input, &cursor_index),

// Ctrl+C
KEY_CTRL_C => {
// This will trigger the defer and restore terminal settings
pending_sigint = true;
return .null;
},

// Escape sequence (e.g., arrow keys, alt+backspace)
KEY_ESC => block: {
const byte2 = reader.readByte() catch break :block;

// Alt+Backspace -> treat as regular backspace
if (byte2 == KEY_DEL or byte2 == KEY_BACKSPACE) {
handleBackspace(&input, &cursor_index);
break :block; // Exit escape sequence handler to allow redraw
}

// Standard escape sequence (e.g., arrow keys)
if (byte2 != '[') {
break :block;
}

var final_byte = reader.readByte() catch break :block;

// Check for complex sequence (e.g., ESC [ 3 ~ or ESC [ 1 ; 5 D)
if (final_byte >= '0' and final_byte <= '9') {
// Consume parameters until the final command byte
while (true) {
const peek = reader.readByte() catch break;
if ((peek >= 'A' and peek <= 'Z') or peek == '~') {
final_byte = peek;
break;
}
}
}

switch (final_byte) {
'D' => { // Left arrow
if (cursor_index > 0) {
cursor_index = utf8Prev(input.items, cursor_index) orelse cursor_index - 1;
}
},
'C' => { // Right arrow
if (cursor_index < input.items.len) {
cursor_index = utf8Next(input.items, cursor_index) orelse cursor_index + 1;
}
},
'H' => { // Home
cursor_index = 0;
},
'F' => { // End
cursor_index = input.items.len;
},
'~' => { // Handles Delete (e.g. ESC [ 3 ~)
if (cursor_index < input.items.len) {
const next_codepoint_start = utf8Next(input.items, cursor_index);

if (next_codepoint_start) |end| {
var i: usize = 0;
while (i < end - cursor_index) : (i += 1) {
_ = input.orderedRemove(cursor_index);
}
} else {
// Fallback: delete one byte if invalid UTF-8
_ = input.orderedRemove(cursor_index);
}
}
},
else => {},
}
},

// Ctrl+D (EOT)
KEY_CTRL_D => {
return .null;
},

else => {
try input.insert(cursor_index, byte);
cursor_index += 1;
},
}
try fullRedraw(stdout_writer, &input, cursor_index, prompt_width);
}
}
}

// Fallback for non-interactive terminals (or Windows)
const reader = bun.Output.buffered_stdin.reader();
var second_byte: ?u8 = null;
const first_byte = reader.readByte() catch {
Expand Down