Add TUI APIs for ink migration: setAnsiText, key reader mode sequences, inline rendering

Phase 0 of the ink→Bun.TUI migration. Adds three new capabilities to the native TUI primitives:

- TUIScreen.setAnsiText(x, y, text): feeds ANSI text through Ghostty's VT parser, interprets SGR sequences to track style, writes styled cells directly
- TUIKeyReader constructor options (bracketedPaste, focusEvents, kittyKeyboard): writes mode-enabling sequences to stdout on construction, disables on close()
- TUITerminalWriter inline rendering mode: uses LF instead of CUD for viewport scrolling, tracks scrollback depth, detects unreachable dirty rows for full redraw

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jarred Sumner
2026-02-12 00:11:17 -08:00
parent 39d14bc0c4
commit 90509ea61c
12 changed files with 1043 additions and 45 deletions

View File

@@ -13,11 +13,11 @@ register_cmake_command(
TARGETS
c-ares
ARGS
-DCARES_STATIC=ON
-DCARES_STATIC_PIC=ON # FORCE_PIC was set to 1, but CARES_STATIC_PIC was set to OFF??
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
-DCARES_SHARED=OFF
-DCARES_BUILD_TOOLS=OFF # this was set to ON?
-DCARES_STATIC:BOOL=ON
-DCARES_STATIC_PIC:BOOL=ON
-DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=ON
-DCARES_SHARED:BOOL=OFF
-DCARES_BUILD_TOOLS:BOOL=OFF
-DCMAKE_INSTALL_LIBDIR=lib
LIB_PATH
lib

View File

@@ -13,34 +13,34 @@ register_cmake_command(
TARGETS
archive_static
ARGS
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
-DBUILD_SHARED_LIBS=OFF
-DENABLE_INSTALL=OFF
-DENABLE_TEST=OFF
-DENABLE_WERROR=OFF
-DENABLE_BZip2=OFF
-DENABLE_CAT=OFF
-DENABLE_CPIO=OFF
-DENABLE_UNZIP=OFF
-DENABLE_EXPAT=OFF
-DENABLE_ICONV=OFF
-DENABLE_LIBB2=OFF
-DENABLE_LibGCC=OFF
-DENABLE_LIBXML2=OFF
-DENABLE_WIN32_XMLLITE=OFF
-DENABLE_LZ4=OFF
-DENABLE_LZMA=OFF
-DENABLE_LZO=OFF
-DENABLE_MBEDTLS=OFF
-DENABLE_NETTLE=OFF
-DENABLE_OPENSSL=OFF
-DENABLE_PCRE2POSIX=OFF
-DENABLE_PCREPOSIX=OFF
-DENABLE_ZSTD=OFF
-DCMAKE_POSITION_INDEPENDENT_CODE:BOOL=ON
-DBUILD_SHARED_LIBS:BOOL=OFF
-DENABLE_INSTALL:BOOL=OFF
-DENABLE_TEST:BOOL=OFF
-DENABLE_WERROR:BOOL=OFF
-DENABLE_BZip2:BOOL=OFF
-DENABLE_CAT:BOOL=OFF
-DENABLE_CPIO:BOOL=OFF
-DENABLE_UNZIP:BOOL=OFF
-DENABLE_EXPAT:BOOL=OFF
-DENABLE_ICONV:BOOL=OFF
-DENABLE_LIBB2:BOOL=OFF
-DENABLE_LibGCC:BOOL=OFF
-DENABLE_LIBXML2:BOOL=OFF
-DENABLE_WIN32_XMLLITE:BOOL=OFF
-DENABLE_LZ4:BOOL=OFF
-DENABLE_LZMA:BOOL=OFF
-DENABLE_LZO:BOOL=OFF
-DENABLE_MBEDTLS:BOOL=OFF
-DENABLE_NETTLE:BOOL=OFF
-DENABLE_OPENSSL:BOOL=OFF
-DENABLE_PCRE2POSIX:BOOL=OFF
-DENABLE_PCREPOSIX:BOOL=OFF
-DENABLE_ZSTD:BOOL=OFF
# libarchive depends on zlib headers, otherwise it will
# spawn a processes to compress instead of using the library.
-DENABLE_ZLIB=OFF
-DHAVE_ZLIB_H=ON
-DENABLE_ZLIB:BOOL=OFF
-DHAVE_ZLIB_H:BOOL=ON
-DCMAKE_C_FLAGS="-I${VENDOR_PATH}/zlib"
LIB_PATH
libarchive

View File

@@ -0,0 +1,11 @@
--- zutil.h
+++ zutil.h
@@ -127,7 +127,7 @@
# endif
#endif
-#if defined(MACOS) || defined(TARGET_OS_MAC)
+#if defined(MACOS) || (defined(TARGET_OS_MAC) && !defined(__APPLE__))
# define OS_CODE 7
# ifndef Z_SOLO
# if defined(__MWERKS__) && __dest_os != __be_os && __dest_os != __win32_os

View File

@@ -14,6 +14,10 @@ export default [
fn: "setText",
length: 4,
},
setAnsiText: {
fn: "setAnsiText",
length: 3,
},
style: {
fn: "style",
length: 1,

View File

@@ -78,6 +78,8 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
var cursor_visible: ?bool = null;
var cursor_style: ?CursorStyle = null;
var cursor_blinking: ?bool = null;
var use_inline = false;
var viewport_h: u16 = 0;
if (arguments.len > 1 and arguments[1].isObject()) {
const opts = arguments[1];
if (try opts.getTruthy(globalThis, "cursorX")) |v| {
@@ -97,6 +99,12 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| {
if (v.isBoolean()) cursor_blinking = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "inline")) |v| {
if (v.isBoolean()) use_inline = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "viewportHeight")) |v| {
if (v.isNumber()) viewport_h = @intCast(@max(1, @min((try v.coerce(i32, globalThis)), 4096)));
}
}
const ab_val = js.gc.get(.buffer, callframe.this()) orelse
@@ -108,7 +116,11 @@ pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe
return globalThis.throw("render: ArrayBuffer is empty", .{});
this.output.clearRetainingCapacity();
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
if (use_inline and viewport_h > 0) {
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
const total_len = this.output.items.len;
const copy_len = @min(total_len, dest.len);

View File

@@ -52,14 +52,20 @@ const Flags = packed struct {
esc_pending: bool = false,
/// Set when we're accumulating an SGR mouse event sequence.
in_mouse: bool = false,
_padding: u1 = 0,
/// Mode sequences enabled via constructor options.
bracketed_paste: bool = false,
focus_events: bool = false,
kitty_keyboard: bool = false,
_padding: u6 = 0,
};
pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
if (comptime bun.Environment.isWindows) {
return globalThis.throw("TUIKeyReader is not supported on Windows", .{});
}
const arguments = callframe.arguments();
const stdin_fd = bun.FD.fromNative(0);
// Set raw mode if stdin is a TTY.
@@ -69,12 +75,34 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSErr
return globalThis.throw("Failed to set raw mode on stdin", .{});
}
// Parse optional constructor options.
var want_bracketed_paste = false;
var want_focus_events = false;
var want_kitty_keyboard = false;
if (arguments.len > 0 and arguments[0].isObject()) {
const opts = arguments[0];
if (try opts.getTruthy(globalThis, "bracketedPaste")) |v| {
if (v.isBoolean()) want_bracketed_paste = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "focusEvents")) |v| {
if (v.isBoolean()) want_focus_events = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "kittyKeyboard")) |v| {
if (v.isBoolean()) want_kitty_keyboard = v.asBoolean();
}
}
const this = bun.new(TuiKeyReader, .{
.ref_count = .init(),
.event_loop_handle = jsc.EventLoopHandle.init(globalThis.bunVM().eventLoop()),
.globalThis = globalThis,
.stdin_fd = stdin_fd,
.flags = .{ .is_tty = is_tty },
.flags = .{
.is_tty = is_tty,
.bracketed_paste = want_bracketed_paste,
.focus_events = want_focus_events,
.kitty_keyboard = want_kitty_keyboard,
},
});
this.reader.setParent(this);
@@ -91,6 +119,9 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSErr
// otherwise the initial read may consume data before JS has a chance to
// set up its callback handler.
// Write mode-enabling sequences to stdout.
this.enableModes();
return this;
}
@@ -101,6 +132,7 @@ fn deinit(this: *TuiKeyReader) void {
this.onfocus_callback.deinit();
this.onblur_callback.deinit();
if (!this.flags.closed) {
this.disableModes();
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.deinit();
@@ -112,6 +144,7 @@ fn deinit(this: *TuiKeyReader) void {
pub fn finalize(this: *TuiKeyReader) callconv(.c) void {
if (!this.flags.closed) {
this.disableModes();
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.close();
@@ -154,6 +187,7 @@ pub fn onReaderError(this: *TuiKeyReader, _: bun.sys.Error) void {
pub fn close(this: *TuiKeyReader, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (!this.flags.closed) {
this.disableModes();
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.close();
@@ -230,6 +264,62 @@ pub fn getOnBlur(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.J
return this.onblur_callback.get() orelse .js_undefined;
}
// --- Terminal mode sequences ---
/// Write mode-enabling escape sequences to stdout for modes requested
/// in the constructor options. Written to stdout (fd 1) regardless of
/// whether stdin is a TTY, since the user explicitly requested them.
fn enableModes(this: *const TuiKeyReader) void {
var buf: [64]u8 = undefined;
var pos: usize = 0;
if (this.flags.bracketed_paste) {
const seq = "\x1b[?2004h";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (this.flags.focus_events) {
const seq = "\x1b[?1004h";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (this.flags.kitty_keyboard) {
const seq = "\x1b[>1u";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (pos > 0) {
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
}
}
/// Write mode-disabling escape sequences to stdout. Called from close/deinit.
fn disableModes(this: *TuiKeyReader) void {
var buf: [64]u8 = undefined;
var pos: usize = 0;
// Disable in reverse order of enabling.
if (this.flags.kitty_keyboard) {
const seq = "\x1b[<u";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.kitty_keyboard = false;
}
if (this.flags.focus_events) {
const seq = "\x1b[?1004l";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.focus_events = false;
}
if (this.flags.bracketed_paste) {
const seq = "\x1b[?2004l";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.bracketed_paste = false;
}
if (pos > 0) {
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
}
}
// --- Input processing via Ghostty parser ---
fn processInput(this: *TuiKeyReader, data: []const u8) void {
@@ -568,6 +658,7 @@ fn emitMouse(this: *TuiKeyReader, button_code: u16, px: u16, py: u16, is_release
event.put(globalThis, bun.String.static("shift"), jsc.JSValue.jsBoolean(mod_shift));
event.put(globalThis, bun.String.static("alt"), jsc.JSValue.jsBoolean(mod_alt));
event.put(globalThis, bun.String.static("ctrl"), jsc.JSValue.jsBoolean(mod_ctrl));
event.put(globalThis, bun.String.static("option"), jsc.JSValue.jsBoolean(mod_alt));
globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event});
}
@@ -588,7 +679,7 @@ fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctr
const callback = this.onkeypress_callback.get() orelse return;
const globalThis = this.globalThis;
const event = jsc.JSValue.createEmptyObject(globalThis, 5);
const event = jsc.JSValue.createEmptyObject(globalThis, 6);
const name_js = bun.String.createUTF8ForJS(globalThis, name) catch return;
const seq_js = bun.String.createUTF8ForJS(globalThis, sequence) catch return;
event.put(globalThis, bun.String.static("name"), name_js);
@@ -596,6 +687,7 @@ fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctr
event.put(globalThis, bun.String.static("ctrl"), jsc.JSValue.jsBoolean(ctrl));
event.put(globalThis, bun.String.static("shift"), jsc.JSValue.jsBoolean(shift));
event.put(globalThis, bun.String.static("alt"), jsc.JSValue.jsBoolean(alt));
event.put(globalThis, bun.String.static("option"), jsc.JSValue.jsBoolean(alt));
globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event});
}

View File

@@ -41,6 +41,16 @@ current_cursor_style: CursorStyle = .default,
current_cursor_blinking: bool = false,
/// Set during render() to the target buffer. Not valid outside render().
buf: *std.ArrayList(u8) = undefined,
/// Inline mode state: number of content rows that have scrolled into
/// the terminal's scrollback buffer and are unreachable via cursor movement.
scrollback_rows: size.CellCountInt = 0,
/// The highest content row index that has been reached via LF emission.
/// Rows beyond this require LF (which scrolls) rather than CUD (which doesn't).
max_row_reached: size.CellCountInt = 0,
/// Terminal viewport height, used for inline mode scrollback tracking.
viewport_height: u16 = 0,
/// True when rendering in inline mode for this frame.
inline_mode: bool = false,
pub fn render(
this: *TuiRenderer,
@@ -51,14 +61,53 @@ pub fn render(
cursor_visible: ?bool,
cursor_style: ?CursorStyle,
cursor_blinking: ?bool,
) void {
this.renderInner(buf, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, false, 0);
}
pub fn renderInline(
this: *TuiRenderer,
buf: *std.ArrayList(u8),
screen: *const TuiScreen,
cursor_x: ?size.CellCountInt,
cursor_y: ?size.CellCountInt,
cursor_visible: ?bool,
cursor_style: ?CursorStyle,
cursor_blinking: ?bool,
viewport_height: u16,
) void {
this.renderInner(buf, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, true, viewport_height);
}
fn renderInner(
this: *TuiRenderer,
buf: *std.ArrayList(u8),
screen: *const TuiScreen,
cursor_x: ?size.CellCountInt,
cursor_y: ?size.CellCountInt,
cursor_visible: ?bool,
cursor_style: ?CursorStyle,
cursor_blinking: ?bool,
inline_mode: bool,
viewport_height: u16,
) void {
this.buf = buf;
this.inline_mode = inline_mode;
if (inline_mode and viewport_height > 0) {
this.viewport_height = viewport_height;
}
this.emit(BSU);
const need_full = !this.has_rendered or this.prev_page == null or
var need_full = !this.has_rendered or this.prev_page == null or
(if (this.prev_page) |p| p.size.cols != screen.page.size.cols or p.size.rows != screen.page.size.rows else true);
// In inline mode, check if any dirty rows are in scrollback (unreachable).
// If so, force a full redraw of the visible portion.
if (!need_full and inline_mode and this.scrollback_rows > 0) {
need_full = this.hasScrollbackChanges(screen);
}
if (need_full) this.renderFull(screen) else this.renderDiff(screen);
if (cursor_x != null or cursor_y != null) {
@@ -102,6 +151,8 @@ pub fn clear(this: *TuiRenderer) void {
this.current_hyperlink_id = 0;
this.has_rendered = false;
this.prev_rows = 0;
this.scrollback_rows = 0;
this.max_row_reached = 0;
if (this.prev_hyperlink_ids) |ids| {
bun.default_allocator.free(ids);
this.prev_hyperlink_ids = null;
@@ -113,23 +164,56 @@ pub fn deinit(this: *TuiRenderer) void {
if (this.prev_hyperlink_ids) |ids| bun.default_allocator.free(ids);
}
/// Check if any cells in the scrollback region (unreachable rows) have changed.
fn hasScrollbackChanges(this: *const TuiRenderer, screen: *const TuiScreen) bool {
const prev = &(this.prev_page orelse return true);
var y: size.CellCountInt = 0;
while (y < this.scrollback_rows and y < screen.page.size.rows) : (y += 1) {
const row = screen.page.getRow(y);
if (!row.dirty) continue;
const cells = row.cells.ptr(screen.page.memory)[0..screen.page.size.cols];
const prev_cells = prev.getRow(y).cells.ptr(prev.memory)[0..prev.size.cols];
var x: size.CellCountInt = 0;
while (x < screen.page.size.cols) : (x += 1) {
if (@as(u64, @bitCast(cells[x])) != @as(u64, @bitCast(prev_cells[x]))) {
return true;
}
}
}
return false;
}
// --- Rendering internals ---
fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
this.emit("\x1b[?25l");
// In inline mode, we can only move up to the first visible row.
// scrollback_rows tracks how many content rows are unreachable.
const start_y: size.CellCountInt = if (this.inline_mode) this.scrollback_rows else 0;
if (this.has_rendered) {
if (this.cursor_y > 0) {
this.emitCSI(this.cursor_y, 'A');
// Move cursor back to the start of our content region.
const reachable_top = if (this.inline_mode) start_y else 0;
if (this.cursor_y > reachable_top) {
this.emitCSI(this.cursor_y - reachable_top, 'A');
}
this.emit("\r");
this.cursor_x = 0;
this.cursor_y = 0;
this.cursor_y = reachable_top;
}
this.current_style_id = 0;
var y: size.CellCountInt = 0;
var first_visible = true;
var y: size.CellCountInt = start_y;
while (y < screen.page.size.rows) : (y += 1) {
if (y > 0) this.emit("\r\n");
if (!first_visible) {
// In inline mode, use LF to create new lines (scrolls viewport).
// In fullscreen mode, \r\n also works since we don't use alt screen here.
this.emit("\r\n");
}
first_visible = false;
const cells = screen.page.getRow(y).cells.ptr(screen.page.memory)[0..screen.page.size.cols];
var blank_cells: usize = 0;
@@ -165,6 +249,7 @@ fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
this.emit("\x1b[K");
}
// Clear extra rows from previous render if content shrank.
if (this.prev_rows > screen.page.size.rows) {
var extra = this.prev_rows - screen.page.size.rows;
while (extra > 0) : (extra -= 1) {
@@ -177,6 +262,18 @@ fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
this.current_style_id = 0;
this.cursor_x = screen.page.size.cols;
this.cursor_y = screen.page.size.rows -| 1;
// Update inline mode scrollback tracking.
if (this.inline_mode and this.viewport_height > 0) {
if (this.cursor_y >= this.max_row_reached) {
this.max_row_reached = this.cursor_y;
}
// Total content rows that have been pushed through the viewport.
// Rows beyond viewport_height are in scrollback.
if (this.max_row_reached +| 1 > this.viewport_height) {
this.scrollback_rows = (this.max_row_reached +| 1) - this.viewport_height;
}
}
}
fn renderDiff(this: *TuiRenderer, screen: *const TuiScreen) void {
@@ -260,7 +357,26 @@ fn swapScreens(this: *TuiRenderer, screen: *const TuiScreen) void {
fn moveTo(this: *TuiRenderer, x: size.CellCountInt, y: size.CellCountInt) void {
if (y > this.cursor_y) {
this.emitCSI(y - this.cursor_y, 'B');
if (this.inline_mode) {
// In inline mode, use LF for downward movement.
// LF scrolls the viewport when at the bottom, CUD does not.
var n = y - this.cursor_y;
while (n > 0) : (n -= 1) {
this.emit("\n");
}
// After LF, cursor is at column 0. We need to account for this
// when positioning X below.
this.cursor_x = 0;
// Update max_row_reached for scrollback tracking.
if (y > this.max_row_reached) {
this.max_row_reached = y;
if (this.viewport_height > 0 and this.max_row_reached +| 1 > this.viewport_height) {
this.scrollback_rows = (this.max_row_reached +| 1) - this.viewport_height;
}
}
} else {
this.emitCSI(y - this.cursor_y, 'B');
}
} else if (y < this.cursor_y) {
this.emitCSI(this.cursor_y - y, 'A');
}

View File

@@ -298,6 +298,220 @@ pub fn setText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *js
return jsc.JSValue.jsNumber(@as(i32, @intCast(col - start_x)));
}
/// setAnsiText(x, y, text) — parse ANSI escape sequences in `text` and write styled characters.
/// Interprets SGR (CSI … m) sequences to set the current style, writes printable characters
/// into cells at (x, y), advancing the column after each character.
/// CR resets column to start_x; LF advances row and resets column.
/// Returns the column count written (on the last row).
pub fn setAnsiText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 3) return globalThis.throw("setAnsiText requires (x, y, text)", .{});
if (!arguments[0].isNumber() or !arguments[1].isNumber())
return globalThis.throw("setAnsiText: x and y must be numbers", .{});
if (!arguments[2].isString()) return globalThis.throw("setAnsiText: text must be a string", .{});
const start_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols() -| 1)));
var y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows() -| 1)));
const str = try arguments[2].toSliceClone(globalThis);
defer str.deinit();
const text = str.slice();
var parser = Parser.init();
defer parser.deinit();
var current_style = Style{};
var current_style_id: size.StyleCountInt = 0;
var col = start_x;
const cols = this.getCols();
// Apply clipping
var clip_max_col: size.CellCountInt = cols;
var clip_min_y: size.CellCountInt = 0;
var clip_max_y: size.CellCountInt = this.getRows();
if (this.clip_depth > 0) {
const cr = this.clip_stack[this.clip_depth - 1];
clip_max_col = cr.x2;
clip_min_y = cr.y1;
clip_max_y = cr.y2;
}
var i: usize = 0;
while (i < text.len) {
const byte = text[i];
// Handle UTF-8 multi-byte sequences directly (parser only handles ASCII).
if (byte >= 0xC0) {
const seq_len = bun.strings.utf8ByteSequenceLength(byte);
if (i + seq_len <= text.len) {
const seq = text[i .. i + seq_len];
var seq_bytes = [4]u8{ seq[0], 0, 0, 0 };
if (seq_len > 1) seq_bytes[1] = seq[1];
if (seq_len > 2) seq_bytes[2] = seq[2];
if (seq_len > 3) seq_bytes[3] = seq[3];
const cp = bun.strings.decodeWTF8RuneT(&seq_bytes, seq_len, u21, 0xFFFD);
if (cp != 0xFFFD) {
this.writeAnsiChar(cp, &col, y, current_style_id, clip_max_col, clip_min_y, clip_max_y);
}
i += seq_len;
} else {
i += 1;
}
continue;
}
// Skip stray continuation bytes.
if (byte >= 0x80) {
i += 1;
continue;
}
// Feed ASCII through the VT parser.
const actions = parser.next(byte);
for (&actions) |maybe_action| {
const action = maybe_action orelse continue;
switch (action) {
.print => |cp| {
this.writeAnsiChar(@intCast(cp), &col, y, current_style_id, clip_max_col, clip_min_y, clip_max_y);
},
.execute => |c| {
switch (c) {
'\r' => col = start_x,
'\n' => {
y +|= 1;
col = start_x;
},
'\t' => {
// Advance to next tab stop (every 8 columns).
col = @min((col +| 8) & ~@as(size.CellCountInt, 7), cols);
},
else => {},
}
},
.csi_dispatch => |csi| {
if (csi.final == 'm') {
// Parse SGR attributes and update current style.
var sgr_parser = sgr.Parser{
.params = csi.params,
.params_sep = csi.params_sep,
};
while (sgr_parser.next()) |attr| {
applyAnsiAttribute(&current_style, attr);
}
// Intern the updated style.
if (current_style.default()) {
current_style_id = 0;
} else {
current_style_id = this.internStyle(current_style);
}
}
},
else => {},
}
}
i += 1;
}
return jsc.JSValue.jsNumber(@as(i32, @intCast(col -| start_x)));
}
/// Write a single codepoint at (col, y) with the given style, advancing col.
/// Used by setAnsiText to place characters parsed from ANSI input.
fn writeAnsiChar(
this: *TuiScreen,
cp: u21,
col: *size.CellCountInt,
y: size.CellCountInt,
sid: size.StyleCountInt,
clip_max_col: size.CellCountInt,
clip_min_y: size.CellCountInt,
clip_max_y: size.CellCountInt,
) void {
if (y < clip_min_y or y >= clip_max_y or y >= this.getRows()) return;
const effective_cols = clip_max_col;
const width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(cp), false), 2));
if (width == 0) {
// Zero-width: append as grapheme extension to preceding cell.
if (col.* > 0) {
const rc = this.getRowCells(y);
const target = graphemeTarget(rc.cells, col.*, 0);
this.page.appendGrapheme(rc.row, &rc.cells[target], @intCast(cp)) catch {};
rc.row.dirty = true;
}
return;
}
if (col.* >= effective_cols) return;
if (width == 2 and col.* + 1 >= effective_cols) return;
const rc = this.getRowCells(y);
rc.cells[col.*] = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(cp) },
.style_id = sid,
.wide = if (width == 2) .wide else .narrow,
};
rc.row.dirty = true;
if (sid != 0) rc.row.styled = true;
col.* += 1;
if (width == 2 and col.* < effective_cols) {
rc.cells[col.*] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail };
col.* += 1;
}
}
/// Intern a Style into the page's style set, using the cache to avoid duplicates.
fn internStyle(this: *TuiScreen, s: Style) size.StyleCountInt {
const key = StyleKey.fromStyle(s);
if (this.style_cache.get(key)) |cached_id| return cached_id;
const id = this.page.styles.add(this.page.memory, s) catch return 0;
this.style_cache.put(bun.default_allocator, key, id) catch {};
return id;
}
/// Apply an SGR attribute to a Style, modifying it in-place.
fn applyAnsiAttribute(s: *Style, attr: sgr.Attribute) void {
switch (attr) {
.unset => s.* = Style{},
.bold => s.flags.bold = true,
.reset_bold => {
s.flags.bold = false;
s.flags.faint = false;
},
.italic => s.flags.italic = true,
.reset_italic => s.flags.italic = false,
.faint => s.flags.faint = true,
.underline => |ul| s.flags.underline = ul,
.blink => s.flags.blink = true,
.reset_blink => s.flags.blink = false,
.inverse => s.flags.inverse = true,
.reset_inverse => s.flags.inverse = false,
.invisible => s.flags.invisible = true,
.reset_invisible => s.flags.invisible = false,
.strikethrough => s.flags.strikethrough = true,
.reset_strikethrough => s.flags.strikethrough = false,
.overline => s.flags.overline = true,
.reset_overline => s.flags.overline = false,
.direct_color_fg => |rgb| s.fg_color = .{ .rgb = rgb },
.direct_color_bg => |rgb| s.bg_color = .{ .rgb = rgb },
.@"8_fg" => |name| s.fg_color = .{ .palette = @intFromEnum(name) },
.@"8_bg" => |name| s.bg_color = .{ .palette = @intFromEnum(name) },
.@"8_bright_fg" => |name| s.fg_color = .{ .palette = @intFromEnum(name) },
.@"8_bright_bg" => |name| s.bg_color = .{ .palette = @intFromEnum(name) },
.@"256_fg" => |idx| s.fg_color = .{ .palette = idx },
.@"256_bg" => |idx| s.bg_color = .{ .palette = idx },
.reset_fg => s.fg_color = .none,
.reset_bg => s.bg_color = .none,
.underline_color => |rgb| s.underline_color = .{ .rgb = rgb },
.@"256_underline_color" => |idx| s.underline_color = .{ .palette = idx },
.reset_underline_color => s.underline_color = .none,
.unknown => {},
}
}
/// style({ fg, bg, bold, italic, underline, ... }) → styleId
pub fn style(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
@@ -869,6 +1083,9 @@ fn parseColor(globalThis: *jsc.JSGlobalObject, val: jsc.JSValue) bun.JSError!Sty
return .none;
}
const Parser = ghostty.Parser;
const color = ghostty.color;
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;

View File

@@ -230,6 +230,7 @@ pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callfra
var cursor_visible: ?bool = null;
var cursor_style: ?CursorStyle = null;
var cursor_blinking: ?bool = null;
var use_inline = false;
if (arguments.len > 1 and arguments[1].isObject()) {
const opts = arguments[1];
if (try opts.getTruthy(globalThis, "cursorX")) |v| {
@@ -249,15 +250,34 @@ pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callfra
if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| {
if (v.isBoolean()) cursor_blinking = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "inline")) |v| {
if (v.isBoolean()) use_inline = v.asBoolean();
}
}
// Get viewport height for inline mode.
const viewport_h: u16 = if (use_inline) blk: {
break :blk switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| ws.row,
.err => 24,
};
} else 0;
// Async double-buffered write.
if (this.write_pending) {
this.next_output.clearRetainingCapacity();
this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
if (use_inline) {
this.renderer.renderInline(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
} else {
this.output.clearRetainingCapacity();
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
if (use_inline) {
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
if (this.output.items.len > 0) {
this.write_offset = 0;
this.write_pending = true;

View File

@@ -1036,4 +1036,214 @@ describe("Bun.TUIKeyReader", () => {
expect(exitCode).toBe(0);
});
});
test("exposes option field as alias for alt on keypress events", async () => {
using dir = tempDir("tui-keyreader-option", {
"test.ts": `
const reader = new Bun.TUIKeyReader();
const events: any[] = [];
reader.onkeypress = (event: any) => {
events.push({ name: event.name, alt: event.alt, option: event.option });
if (events.length >= 2) {
reader.close();
console.log(JSON.stringify(events));
process.exit(0);
}
};
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' (no alt) then alt+b (\x1bb)
proc.stdin.write("a\x1bb");
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const events = JSON.parse(stdout.trim());
expect(events).toEqual([
{ name: "a", alt: false, option: false },
{ name: "b", alt: true, option: true },
]);
expect(exitCode).toBe(0);
});
// ─── Constructor options (mode sequences) ──────────────────────
describe("constructor options", () => {
test("accepts options object without crashing", async () => {
using dir = tempDir("tui-keyreader-opts", {
"test.ts": `
const reader = new Bun.TUIKeyReader({
bracketedPaste: true,
focusEvents: true,
kittyKeyboard: true,
});
reader.close();
console.log("ok");
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// stdout contains the mode sequences + "ok\n"
expect(stdout).toContain("ok");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("writes bracketed paste enable/disable sequences", async () => {
using dir = tempDir("tui-keyreader-bp", {
"test.ts": `
const reader = new Bun.TUIKeyReader({ bracketedPaste: true });
// Immediately close to get both enable and disable sequences
reader.close();
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Enable: CSI ?2004h, Disable: CSI ?2004l
expect(stdout).toContain("\x1b[?2004h");
expect(stdout).toContain("\x1b[?2004l");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("writes focus events enable/disable sequences", async () => {
using dir = tempDir("tui-keyreader-fe", {
"test.ts": `
const reader = new Bun.TUIKeyReader({ focusEvents: true });
reader.close();
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Enable: CSI ?1004h, Disable: CSI ?1004l
expect(stdout).toContain("\x1b[?1004h");
expect(stdout).toContain("\x1b[?1004l");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("writes kitty keyboard enable/disable sequences", async () => {
using dir = tempDir("tui-keyreader-kk", {
"test.ts": `
const reader = new Bun.TUIKeyReader({ kittyKeyboard: true });
reader.close();
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Enable: CSI >1u, Disable: CSI <u
expect(stdout).toContain("\x1b[>1u");
expect(stdout).toContain("\x1b[<u");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
test("no options means no mode sequences", async () => {
using dir = tempDir("tui-keyreader-noopts", {
"test.ts": `
const reader = new Bun.TUIKeyReader();
reader.close();
console.log("ok");
process.exit(0);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should just have "ok" and no escape sequences
expect(stdout.trim()).toBe("ok");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});
test("exposes option field as alias for alt on mouse events", async () => {
using dir = tempDir("tui-keyreader-mouse-option", {
"test.ts": `
const reader = new Bun.TUIKeyReader();
reader.onmouse = (event: any) => {
reader.close();
console.log(JSON.stringify({ alt: event.alt, option: event.option, type: event.type }));
process.exit(0);
};
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "test.ts")],
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// SGR mouse: alt+click at (1,1) — button 0 with alt modifier (bit 3 = 8)
proc.stdin.write("\x1b[<8;1;1M");
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const event = JSON.parse(stdout.trim());
expect(event).toEqual({ alt: true, option: true, type: "down" });
expect(exitCode).toBe(0);
});
});

View File

@@ -1024,4 +1024,256 @@ describe("Bun.TUIScreen", () => {
expect(screen.getCell(10, 5).char).toBe(".");
});
});
// ─── setAnsiText ────────────────────────────────────────────────
describe("setAnsiText", () => {
test("writes plain text without ANSI codes", () => {
const screen = new Bun.TUIScreen(80, 24);
const cols = screen.setAnsiText(0, 0, "Hello");
expect(cols).toBe(5);
expect(screen.getCell(0, 0).char).toBe("H");
expect(screen.getCell(4, 0).char).toBe("o");
});
test("applies bold SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[1m = bold on, then "Hi", then ESC[0m = reset
screen.setAnsiText(0, 0, "\x1b[1mHi\x1b[0m");
const boldId = screen.style({ bold: true });
expect(screen.getCell(0, 0).char).toBe("H");
expect(screen.getCell(0, 0).styleId).toBe(boldId);
expect(screen.getCell(1, 0).char).toBe("i");
expect(screen.getCell(1, 0).styleId).toBe(boldId);
});
test("applies foreground color SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[38;2;255;0;0m = red foreground
screen.setAnsiText(0, 0, "\x1b[38;2;255;0;0mRed\x1b[0m");
const redId = screen.style({ fg: 0xff0000 });
expect(screen.getCell(0, 0).char).toBe("R");
expect(screen.getCell(0, 0).styleId).toBe(redId);
});
test("applies background color SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[48;2;0;255;0m = green background
screen.setAnsiText(0, 0, "\x1b[48;2;0;255;0mGrn\x1b[0m");
const greenBgId = screen.style({ bg: 0x00ff00 });
expect(screen.getCell(0, 0).char).toBe("G");
expect(screen.getCell(0, 0).styleId).toBe(greenBgId);
});
test("applies 8-color named foreground", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[31m = red foreground (palette index 1)
screen.setAnsiText(0, 0, "\x1b[31mX\x1b[0m");
const redId = screen.style({ fg: { palette: 1 } });
expect(screen.getCell(0, 0).char).toBe("X");
expect(screen.getCell(0, 0).styleId).toBe(redId);
});
test("applies 256-color foreground", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[38;5;196m = 256-color palette index 196
screen.setAnsiText(0, 0, "\x1b[38;5;196mX\x1b[0m");
const id = screen.style({ fg: { palette: 196 } });
expect(screen.getCell(0, 0).char).toBe("X");
expect(screen.getCell(0, 0).styleId).toBe(id);
});
test("applies multiple attributes", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[1;3m = bold + italic
screen.setAnsiText(0, 0, "\x1b[1;3mBI\x1b[0m");
const id = screen.style({ bold: true, italic: true });
expect(screen.getCell(0, 0).char).toBe("B");
expect(screen.getCell(0, 0).styleId).toBe(id);
expect(screen.getCell(1, 0).styleId).toBe(id);
});
test("reset SGR clears all attributes", () => {
const screen = new Bun.TUIScreen(80, 24);
// Bold on, write "A", reset, write "B"
screen.setAnsiText(0, 0, "\x1b[1mA\x1b[0mB");
const boldId = screen.style({ bold: true });
expect(screen.getCell(0, 0).styleId).toBe(boldId);
expect(screen.getCell(1, 0).styleId).toBe(0); // default style
});
test("handles CR (carriage return)", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "Hello\rWorld");
// CR resets column to start, "World" overwrites "Hello"
expect(screen.getCell(0, 0).char).toBe("W");
expect(screen.getCell(4, 0).char).toBe("d");
});
test("handles LF (line feed)", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "Line1\nLine2");
expect(screen.getCell(0, 0).char).toBe("L");
expect(screen.getCell(4, 0).char).toBe("1");
expect(screen.getCell(0, 1).char).toBe("L");
expect(screen.getCell(4, 1).char).toBe("2");
});
test("handles tab characters", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "A\tB");
expect(screen.getCell(0, 0).char).toBe("A");
// Tab advances to column 8
expect(screen.getCell(8, 0).char).toBe("B");
});
test("handles CJK wide characters in ANSI text", () => {
const screen = new Bun.TUIScreen(80, 24);
const cols = screen.setAnsiText(0, 0, "\x1b[1m世界\x1b[0m");
expect(cols).toBe(4);
const boldId = screen.style({ bold: true });
expect(screen.getCell(0, 0).char).toBe("世");
expect(screen.getCell(0, 0).styleId).toBe(boldId);
expect(screen.getCell(0, 0).wide).toBe(1);
expect(screen.getCell(1, 0).wide).toBe(2); // spacer_tail
});
test("handles emoji in ANSI text", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "😀");
expect(screen.getCell(0, 0).char).toBe("😀");
expect(screen.getCell(0, 0).wide).toBe(1);
expect(screen.getCell(1, 0).wide).toBe(2);
});
test("clips at screen boundary", () => {
const screen = new Bun.TUIScreen(5, 1);
const cols = screen.setAnsiText(0, 0, "Hello World!");
expect(cols).toBe(5);
expect(screen.getCell(0, 0).char).toBe("H");
expect(screen.getCell(4, 0).char).toBe("o");
});
test("respects clip rect", () => {
const screen = new Bun.TUIScreen(20, 5);
screen.clip(5, 0, 10, 5);
screen.setAnsiText(5, 0, "Hello World");
expect(screen.getCell(5, 0).char).toBe("H");
expect(screen.getCell(9, 0).char).toBe("o");
// Beyond clip rect should be empty
expect(screen.getCell(10, 0).char).toBe(" ");
screen.unclip();
});
test("writes at offset position", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(10, 5, "Test");
expect(screen.getCell(10, 5).char).toBe("T");
expect(screen.getCell(13, 5).char).toBe("t");
});
test("empty string returns 0", () => {
const screen = new Bun.TUIScreen(80, 24);
const cols = screen.setAnsiText(0, 0, "");
expect(cols).toBe(0);
});
test("ANSI-only text (no printable chars) returns 0", () => {
const screen = new Bun.TUIScreen(80, 24);
const cols = screen.setAnsiText(0, 0, "\x1b[1m\x1b[0m");
expect(cols).toBe(0);
});
test("applies italic SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "\x1b[3mI\x1b[0m");
const italicId = screen.style({ italic: true });
expect(screen.getCell(0, 0).char).toBe("I");
expect(screen.getCell(0, 0).styleId).toBe(italicId);
});
test("applies underline SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "\x1b[4mU\x1b[0m");
const ulId = screen.style({ underline: true });
expect(screen.getCell(0, 0).char).toBe("U");
expect(screen.getCell(0, 0).styleId).toBe(ulId);
});
test("applies strikethrough SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "\x1b[9mS\x1b[0m");
const stId = screen.style({ strikethrough: true });
expect(screen.getCell(0, 0).char).toBe("S");
expect(screen.getCell(0, 0).styleId).toBe(stId);
});
test("applies dim/faint SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "\x1b[2mD\x1b[0m");
const faintId = screen.style({ faint: true });
expect(screen.getCell(0, 0).char).toBe("D");
expect(screen.getCell(0, 0).styleId).toBe(faintId);
});
test("applies inverse SGR", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.setAnsiText(0, 0, "\x1b[7mV\x1b[0m");
const invId = screen.style({ inverse: true });
expect(screen.getCell(0, 0).char).toBe("V");
expect(screen.getCell(0, 0).styleId).toBe(invId);
});
test("style transitions within text", () => {
const screen = new Bun.TUIScreen(80, 24);
// "A" bold, "B" italic, "C" plain
screen.setAnsiText(0, 0, "\x1b[1mA\x1b[0m\x1b[3mB\x1b[0mC");
const boldId = screen.style({ bold: true });
const italicId = screen.style({ italic: true });
expect(screen.getCell(0, 0).styleId).toBe(boldId);
expect(screen.getCell(1, 0).styleId).toBe(italicId);
expect(screen.getCell(2, 0).styleId).toBe(0);
});
test("throws with too few arguments", () => {
const screen = new Bun.TUIScreen(80, 24);
expect(() => (screen as any).setAnsiText(0, 0)).toThrow();
expect(() => (screen as any).setAnsiText(0)).toThrow();
expect(() => (screen as any).setAnsiText()).toThrow();
});
test("throws with non-string text", () => {
const screen = new Bun.TUIScreen(80, 24);
expect(() => (screen as any).setAnsiText(0, 0, 42)).toThrow();
});
test("complex ANSI with multiple lines and styles", () => {
const screen = new Bun.TUIScreen(80, 24);
// Bold red "ERR" on line 0, then normal "ok" on line 1
screen.setAnsiText(0, 0, "\x1b[1;38;2;255;0;0mERR\x1b[0m\nok");
const boldRedId = screen.style({ bold: true, fg: 0xff0000 });
expect(screen.getCell(0, 0).char).toBe("E");
expect(screen.getCell(0, 0).styleId).toBe(boldRedId);
expect(screen.getCell(2, 0).char).toBe("R");
expect(screen.getCell(0, 1).char).toBe("o");
expect(screen.getCell(0, 1).styleId).toBe(0);
});
test("ignores non-SGR CSI sequences", () => {
const screen = new Bun.TUIScreen(80, 24);
// CSI 2J = clear screen (non-SGR), should be ignored
screen.setAnsiText(0, 0, "A\x1b[2JB");
expect(screen.getCell(0, 0).char).toBe("A");
expect(screen.getCell(1, 0).char).toBe("B");
});
test("bright foreground colors", () => {
const screen = new Bun.TUIScreen(80, 24);
// ESC[91m = bright red foreground (palette index 9)
screen.setAnsiText(0, 0, "\x1b[91mX\x1b[0m");
const brightRedId = screen.style({ fg: { palette: 9 } });
expect(screen.getCell(0, 0).char).toBe("X");
expect(screen.getCell(0, 0).styleId).toBe(brightRedId);
});
});
});

View File

@@ -1509,4 +1509,68 @@ describe("Bun.TUIBufferWriter", () => {
expect(writer.byteOffset).toBe(0);
expect(writer.byteLength).toBe(0);
});
// ─── Inline mode ─────────────────────────────────────────────────
describe("inline mode via TUIBufferWriter", () => {
test("render with inline option uses LF instead of CUD", () => {
const buf = new ArrayBuffer(65536);
const writer = new Bun.TUIBufferWriter(buf);
const screen = new Bun.TUIScreen(10, 3);
screen.setText(0, 0, "Line1");
screen.setText(0, 1, "Line2");
screen.setText(0, 2, "Line3");
writer.render(screen, { inline: true, viewportHeight: 24 });
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
// Should contain BSU/ESU
expect(output).toContain("\x1b[?2026h");
expect(output).toContain("\x1b[?2026l");
// Should contain content
expect(output).toContain("Line1");
expect(output).toContain("Line2");
expect(output).toContain("Line3");
writer.close();
});
test("inline mode first render has CR+LF between rows", () => {
const buf = new ArrayBuffer(65536);
const writer = new Bun.TUIBufferWriter(buf);
const screen = new Bun.TUIScreen(10, 2);
screen.setText(0, 0, "A");
screen.setText(0, 1, "B");
writer.render(screen, { inline: true, viewportHeight: 24 });
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
// Between rows, renderFull emits \r\n
expect(output).toContain("\r\n");
expect(output).toContain("A");
expect(output).toContain("B");
writer.close();
});
test("inline diff uses LF for downward movement", () => {
const buf = new ArrayBuffer(65536);
const writer = new Bun.TUIBufferWriter(buf);
const screen = new Bun.TUIScreen(10, 3);
screen.setText(0, 0, "A");
screen.setText(0, 1, "B");
screen.setText(0, 2, "C");
// First render (full)
writer.render(screen, { inline: true, viewportHeight: 24 });
const firstLen = writer.byteOffset;
// Change only row 2
screen.setText(0, 2, "X");
writer.render(screen, { inline: true, viewportHeight: 24 });
const output = new TextDecoder().decode(new Uint8Array(buf, 0, writer.byteOffset));
// Diff render should contain the changed cell
expect(output).toContain("X");
writer.close();
});
});
});