-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix prompt input truncating #24020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix prompt input truncating #24020
Changes from 11 commits
a7654da
4940d78
dd5ab02
24c3ac3
526aebd
60f0e22
d61fd6f
f635468
8063c49
761612d
48ec303
29ac69e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
|
||||||
| 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 {}; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why |
||||||
|
|
||||||
| // 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; | ||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| 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; | ||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| 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( | ||||||
|
|
@@ -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, | ||||||
|
|
@@ -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) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
also let's invert this check and break out of this block if it's false. this means all the code inside of the |
||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. setting raw mode can be replaced with |
||||||
|
|
||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||
|
|
||||||
| 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); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| if (input.items.len == 0) return default; | ||||||
|
|
||||||
| var result = jsc.ZigString.init(input.items); | ||||||
| result.markUTF8(); | ||||||
| return result.toJS(globalObject); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| }, | ||||||
|
|
||||||
| // 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 { | ||||||
|
|
||||||
There was a problem hiding this comment.
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