mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 13:51:47 +00:00
Compare commits
6 Commits
deps/updat
...
claude/gho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b027e729 | ||
|
|
20717423b7 | ||
|
|
c0e1f30779 | ||
|
|
ebb273727c | ||
|
|
e713816ee5 | ||
|
|
f30046f3bb |
49
build.zig
49
build.zig
@@ -609,7 +609,6 @@ fn configureObj(b: *Build, opts: *BunBuildOptions, obj: *Compile) void {
|
||||
|
||||
obj.no_link_obj = opts.os != .windows and !opts.no_llvm;
|
||||
|
||||
|
||||
if (opts.enable_asan and !enableFastBuild(b)) {
|
||||
if (@hasField(Build.Module, "sanitize_address")) {
|
||||
if (opts.enable_fuzzilli) {
|
||||
@@ -800,6 +799,11 @@ fn addInternalImports(b: *Build, mod: *Module, opts: *BunBuildOptions) void {
|
||||
});
|
||||
}
|
||||
|
||||
// libghostty-vt: Terminal VT emulator library from Ghostty
|
||||
// Provides terminal escape sequence parsing, state management, input encoding
|
||||
// Source is cloned by CMake to vendor/ghostty
|
||||
addGhosttyModule(b, mod);
|
||||
|
||||
// Finally, make it so all modules share the same import table.
|
||||
propagateImports(mod) catch @panic("OOM");
|
||||
}
|
||||
@@ -832,6 +836,49 @@ fn validateGeneratedPath(path: []const u8) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the ghostty-vt module for terminal emulation functionality.
|
||||
/// Ghostty source is cloned to vendor/ghostty by CMake (see BuildGhosttyVt.cmake).
|
||||
///
|
||||
/// The ghostty-vt module provides:
|
||||
/// - VT escape sequence parsing (CSI, OSC, DCS, etc.)
|
||||
/// - Terminal state management (screen, cursor, colors)
|
||||
/// - Input encoding for terminal applications
|
||||
fn addGhosttyModule(b: *Build, mod: *Module) void {
|
||||
|
||||
// Create the ghostty-vt module from vendor/ghostty/src/lib_vt.zig
|
||||
const ghostty_vt = b.createModule(.{
|
||||
.root_source_file = b.path("vendor/ghostty/src/lib_vt.zig"),
|
||||
});
|
||||
|
||||
// Add terminal_options - build configuration for the terminal module
|
||||
ghostty_vt.addAnonymousImport("terminal_options", .{
|
||||
.root_source_file = b.path("src/deps/ghostty/terminal_options.zig"),
|
||||
});
|
||||
|
||||
// Add unicode_tables - stub tables for unicode property lookups
|
||||
ghostty_vt.addAnonymousImport("unicode_tables", .{
|
||||
.root_source_file = b.path("src/deps/ghostty/unicode_tables.zig"),
|
||||
});
|
||||
|
||||
// Add symbols_tables - stub tables for symbol detection
|
||||
ghostty_vt.addAnonymousImport("symbols_tables", .{
|
||||
.root_source_file = b.path("src/deps/ghostty/symbols_tables.zig"),
|
||||
});
|
||||
|
||||
// Add props module for Properties type used by unicode_tables
|
||||
ghostty_vt.addAnonymousImport("props", .{
|
||||
.root_source_file = b.path("vendor/ghostty/src/unicode/props.zig"),
|
||||
});
|
||||
|
||||
// Add build_options for ghostty's SIMD code
|
||||
ghostty_vt.addAnonymousImport("build_options", .{
|
||||
.root_source_file = b.path("src/deps/ghostty/build_options.zig"),
|
||||
});
|
||||
|
||||
// Export ghostty module to bun
|
||||
mod.addImport("ghostty", ghostty_vt);
|
||||
}
|
||||
|
||||
const WindowsShim = struct {
|
||||
exe: *Compile,
|
||||
dbg: *Compile,
|
||||
|
||||
@@ -62,6 +62,7 @@ set(BUN_DEPENDENCIES
|
||||
LibArchive # must be loaded after zlib
|
||||
HdrHistogram # must be loaded after zlib
|
||||
Zstd
|
||||
GhosttyVt # libghostty-vt terminal emulator library
|
||||
)
|
||||
|
||||
include(CloneZstd)
|
||||
|
||||
22
cmake/targets/BuildGhosttyVt.cmake
Normal file
22
cmake/targets/BuildGhosttyVt.cmake
Normal file
@@ -0,0 +1,22 @@
|
||||
# Build libghostty-vt - Terminal VT emulator library from Ghostty
|
||||
# This clones the ghostty repository so Bun's build.zig can import it as a Zig module.
|
||||
#
|
||||
# libghostty-vt provides:
|
||||
# - Terminal escape sequence parsing
|
||||
# - Terminal state management (screen, cursor, scrollback)
|
||||
# - Input event encoding (Kitty keyboard protocol)
|
||||
# - OSC/DCS/CSI sequence handling
|
||||
#
|
||||
# Usage in Zig: @import("ghostty") gives access to the lib_vt.zig API
|
||||
|
||||
register_repository(
|
||||
NAME
|
||||
ghostty
|
||||
REPOSITORY
|
||||
ghostty-org/ghostty
|
||||
TAG
|
||||
v1.1.3
|
||||
)
|
||||
|
||||
# The ghostty source is cloned to ${VENDOR_PATH}/ghostty
|
||||
# Bun's build.zig will reference it directly as a Zig module
|
||||
@@ -59,6 +59,76 @@ export default [
|
||||
getter: "getControlFlags",
|
||||
setter: "setControlFlags",
|
||||
},
|
||||
|
||||
// ----- Virtual Terminal (ghostty-vt) API -----
|
||||
// These methods provide terminal emulation: parsing escape sequences,
|
||||
// maintaining screen state (cells, cursor, colors), and rendering.
|
||||
|
||||
// Feed data to the virtual terminal parser
|
||||
// This processes escape sequences and updates the screen state
|
||||
feed: {
|
||||
fn: "feed",
|
||||
length: 1,
|
||||
},
|
||||
|
||||
// Get a specific cell from the screen buffer at (x, y)
|
||||
// Returns { char, wide, styled } or null if out of bounds
|
||||
at: {
|
||||
fn: "at",
|
||||
length: 2,
|
||||
},
|
||||
|
||||
// Get a line of text relative to the bottom of the screen
|
||||
// line(0) = bottom line, line(1) = one above bottom, etc.
|
||||
line: {
|
||||
fn: "line",
|
||||
length: 1,
|
||||
},
|
||||
|
||||
// Get the full screen content as text (getter)
|
||||
text: {
|
||||
getter: "getText",
|
||||
},
|
||||
|
||||
// Get cursor position { x, y, visible, style }
|
||||
cursor: {
|
||||
getter: "getCursor",
|
||||
},
|
||||
|
||||
// Get current screen dimensions
|
||||
cols: {
|
||||
getter: "getCols",
|
||||
},
|
||||
rows: {
|
||||
getter: "getRows",
|
||||
},
|
||||
|
||||
// Get terminal title (set via OSC sequences)
|
||||
title: {
|
||||
getter: "getTitle",
|
||||
},
|
||||
|
||||
// Check if terminal is in alternate screen mode
|
||||
alternateScreen: {
|
||||
getter: "getAlternateScreen",
|
||||
},
|
||||
|
||||
// Get scrollback buffer size
|
||||
scrollbackLines: {
|
||||
getter: "getScrollbackLines",
|
||||
},
|
||||
|
||||
// Clear the screen
|
||||
clear: {
|
||||
fn: "clearScreen",
|
||||
length: 0,
|
||||
},
|
||||
|
||||
// Reset terminal to initial state
|
||||
reset: {
|
||||
fn: "resetTerminal",
|
||||
length: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -69,6 +69,13 @@ this_value: jsc.JSRef = jsc.JSRef.empty(),
|
||||
/// State flags
|
||||
flags: Flags = .{},
|
||||
|
||||
/// Virtual terminal emulator (ghostty-vt) for parsing escape sequences
|
||||
/// and maintaining screen state. Lazily initialized on first use.
|
||||
vt: ?*VirtualTerminal = null,
|
||||
|
||||
/// Terminal title set via OSC sequences (initialized in initTerminal)
|
||||
vt_title: std.ArrayList(u8) = .empty,
|
||||
|
||||
pub const Flags = packed struct(u8) {
|
||||
closed: bool = false,
|
||||
finalized: bool = false,
|
||||
@@ -950,6 +957,12 @@ fn deinit(this: *Terminal) void {
|
||||
this.term_name.deinit();
|
||||
this.reader.deinit();
|
||||
this.writer.deinit();
|
||||
// Clean up virtual terminal if initialized
|
||||
if (this.vt) |vt| {
|
||||
vt.deinit();
|
||||
bun.default_allocator.destroy(vt);
|
||||
}
|
||||
this.vt_title.deinit(bun.default_allocator);
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
@@ -963,6 +976,432 @@ pub fn finalize(this: *Terminal) callconv(.c) void {
|
||||
this.deref();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Virtual Terminal (ghostty-vt) Integration
|
||||
// ============================================================================
|
||||
//
|
||||
// The VirtualTerminal wraps ghostty-vt to provide full terminal emulation:
|
||||
// - Parses escape sequences (CSI, OSC, DCS, etc.)
|
||||
// - Maintains screen state (cells, cursor, colors, scrollback)
|
||||
// - Supports alternate screen buffer
|
||||
// - Tracks terminal title via OSC sequences
|
||||
|
||||
/// VirtualTerminal wraps ghostty's Terminal for full VT emulation.
|
||||
/// It processes escape sequences and maintains terminal screen state.
|
||||
pub const VirtualTerminal = struct {
|
||||
/// The ghostty Terminal instance that manages all terminal state
|
||||
terminal: ?ghostty.Terminal = null,
|
||||
/// Terminal title set via OSC sequences
|
||||
title: std.ArrayList(u8) = .empty,
|
||||
/// Dimensions
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(cols: u16, rows: u16) !*Self {
|
||||
const vt = try bun.default_allocator.create(Self);
|
||||
errdefer bun.default_allocator.destroy(vt);
|
||||
|
||||
// Initialize ghostty Terminal
|
||||
var terminal = try ghostty.Terminal.init(bun.default_allocator, .{
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
.max_scrollback = 10_000,
|
||||
});
|
||||
errdefer terminal.deinit(bun.default_allocator);
|
||||
|
||||
vt.* = .{
|
||||
.terminal = terminal,
|
||||
.cols = cols,
|
||||
.rows = rows,
|
||||
};
|
||||
return vt;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
if (self.terminal) |*term| {
|
||||
term.deinit(bun.default_allocator);
|
||||
}
|
||||
self.title.deinit(bun.default_allocator);
|
||||
}
|
||||
|
||||
/// Feed a slice of bytes to the terminal, processing escape sequences
|
||||
pub fn feed(self: *Self, data: []const u8) !void {
|
||||
if (self.terminal) |*term| {
|
||||
var stream = term.vtStream();
|
||||
defer stream.deinit();
|
||||
try stream.nextSlice(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the text content of the active screen
|
||||
pub fn getText(self: *Self, alloc: std.mem.Allocator) ![]const u8 {
|
||||
if (self.terminal) |*term| {
|
||||
return try term.screens.active.dumpStringAlloc(alloc, .{ .active = .{} });
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// Get a line of text relative to the bottom of the screen
|
||||
/// offset 0 = bottom visible line, 1 = one above bottom, etc.
|
||||
pub fn getLineFromBottom(self: *Self, alloc: std.mem.Allocator, offset_from_bottom: usize) ![]const u8 {
|
||||
if (self.terminal) |*term| {
|
||||
const screen = term.screens.active;
|
||||
const total_rows = self.rows;
|
||||
if (offset_from_bottom >= total_rows) return "";
|
||||
|
||||
// Convert bottom-relative offset to top-relative row
|
||||
// offset 0 = bottom = row (total_rows - 1)
|
||||
// offset 1 = one above = row (total_rows - 2)
|
||||
const row_from_top: usize = total_rows - 1 - offset_from_bottom;
|
||||
|
||||
// Build the line by iterating cells in the row
|
||||
var result: std.ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(alloc);
|
||||
|
||||
const cols = self.cols;
|
||||
var col: usize = 0;
|
||||
while (col < cols) : (col += 1) {
|
||||
const cell = screen.pages.getCell(.{ .active = .{
|
||||
.x = @intCast(col),
|
||||
.y = @intCast(row_from_top),
|
||||
} }) orelse continue;
|
||||
|
||||
const cp = cell.cell.content.codepoint;
|
||||
if (cp == 0) continue; // Skip empty cells
|
||||
|
||||
// Encode the codepoint as UTF-8
|
||||
var buf: [4]u8 = undefined;
|
||||
const len = std.unicode.utf8Encode(cp, &buf) catch continue;
|
||||
try result.appendSlice(alloc, buf[0..len]);
|
||||
}
|
||||
|
||||
// Trim trailing spaces
|
||||
while (result.items.len > 0 and result.items[result.items.len - 1] == ' ') {
|
||||
_ = result.pop();
|
||||
}
|
||||
|
||||
return try result.toOwnedSlice(alloc);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/// Get cell at position (x, y)
|
||||
pub fn getCell(self: *Self, x: usize, y: usize) ?CellInfo {
|
||||
if (self.terminal) |*term| {
|
||||
const screen = term.screens.active;
|
||||
if (x >= self.cols or y >= self.rows) return null;
|
||||
|
||||
const cell = screen.pages.getCell(.{ .active = .{
|
||||
.x = @intCast(x),
|
||||
.y = @intCast(y),
|
||||
} }) orelse return null;
|
||||
|
||||
const cp = cell.cell.content.codepoint;
|
||||
return CellInfo{
|
||||
.char = if (cp == 0) ' ' else cp,
|
||||
.wide = cell.cell.wide != .narrow,
|
||||
.styled = cell.cell.style_id != 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get cursor position
|
||||
pub fn getCursorPos(self: *Self) struct { x: u16, y: u16 } {
|
||||
if (self.terminal) |*term| {
|
||||
const cursor = term.screens.active.cursor;
|
||||
return .{ .x = cursor.x, .y = cursor.y };
|
||||
}
|
||||
return .{ .x = 0, .y = 0 };
|
||||
}
|
||||
|
||||
/// Get cursor style
|
||||
pub fn getCursorStyle(self: *Self) enum { block, bar, underline } {
|
||||
if (self.terminal) |*term| {
|
||||
return switch (term.screens.active.cursor.cursor_style) {
|
||||
.block, .block_hollow => .block,
|
||||
.bar => .bar,
|
||||
.underline => .underline,
|
||||
};
|
||||
}
|
||||
return .block;
|
||||
}
|
||||
|
||||
/// Check if alternate screen is active
|
||||
pub fn isAlternateScreen(self: *Self) bool {
|
||||
if (self.terminal) |*term| {
|
||||
return term.screens.active_key == .alternate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get scrollback line count
|
||||
pub fn getScrollbackLines(self: *Self) usize {
|
||||
if (self.terminal) |*term| {
|
||||
// Count scrollback by checking how many rows are beyond the visible area
|
||||
const visible_rows: usize = term.rows;
|
||||
const page_rows: usize = term.screens.active.pages.rows;
|
||||
return if (page_rows > visible_rows) page_rows - visible_rows else 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Clear the screen
|
||||
pub fn clearScreen(self: *Self) void {
|
||||
if (self.terminal) |*term| {
|
||||
term.eraseDisplay(.complete, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset terminal to initial state
|
||||
pub fn reset(self: *Self) void {
|
||||
if (self.terminal) |*term| {
|
||||
term.fullReset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the terminal
|
||||
pub fn resize(self: *Self, new_cols: u16, new_rows: u16) !void {
|
||||
if (self.terminal) |*term| {
|
||||
try term.resize(.{
|
||||
.cols = new_cols,
|
||||
.rows = new_rows,
|
||||
});
|
||||
self.cols = new_cols;
|
||||
self.rows = new_rows;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Cell information returned by getCell
|
||||
pub const CellInfo = struct {
|
||||
char: u21,
|
||||
wide: bool,
|
||||
styled: bool,
|
||||
};
|
||||
|
||||
/// Lazily initialize the virtual terminal
|
||||
fn getOrCreateVT(this: *Terminal) !*VirtualTerminal {
|
||||
if (this.vt) |vt| return vt;
|
||||
|
||||
const vt = try VirtualTerminal.init(this.cols, this.rows);
|
||||
this.vt = vt;
|
||||
return vt;
|
||||
}
|
||||
|
||||
// ----- Virtual Terminal JS API Methods -----
|
||||
|
||||
/// Feed data to the virtual terminal parser
|
||||
/// Processes escape sequences and updates terminal state
|
||||
pub fn feed(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
const data_arg = callframe.argumentsAsArray(1)[0];
|
||||
if (data_arg.isUndefinedOrNull()) {
|
||||
return globalObject.throw("feed() requires a string or ArrayBuffer argument", .{});
|
||||
}
|
||||
|
||||
const vt = this.getOrCreateVT() catch {
|
||||
return globalObject.throw("Failed to initialize virtual terminal", .{});
|
||||
};
|
||||
|
||||
// Get the data to feed using StringOrBuffer
|
||||
const string_or_buffer = try jsc.Node.StringOrBuffer.fromJS(globalObject, bun.default_allocator, data_arg) orelse {
|
||||
return globalObject.throw("feed() argument must be a string or ArrayBuffer", .{});
|
||||
};
|
||||
defer string_or_buffer.deinit();
|
||||
|
||||
vt.feed(string_or_buffer.slice()) catch |err| {
|
||||
return globalObject.throw("Failed to process terminal data: {s}", .{@errorName(err)});
|
||||
};
|
||||
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// Get a cell from the screen buffer at position (x, y)
|
||||
/// Returns { char: string, wide: boolean, styled: boolean } or null
|
||||
pub fn at(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
const args = callframe.argumentsAsArray(2);
|
||||
const x_arg = args[0];
|
||||
const y_arg = args[1];
|
||||
|
||||
if (x_arg.isUndefinedOrNull() or y_arg.isUndefinedOrNull()) {
|
||||
return globalObject.throw("at() requires x and y arguments", .{});
|
||||
}
|
||||
|
||||
const x = x_arg.toInt32();
|
||||
const y = y_arg.toInt32();
|
||||
if (x < 0 or y < 0) {
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
const vt = this.vt orelse return JSValue.jsNull();
|
||||
const cell_info = vt.getCell(@intCast(x), @intCast(y)) orelse return JSValue.jsNull();
|
||||
|
||||
const result = JSValue.createEmptyObject(globalObject, 3);
|
||||
|
||||
// Convert codepoint to string
|
||||
var char_buf: [4]u8 = undefined;
|
||||
const char_len = std.unicode.utf8Encode(cell_info.char, &char_buf) catch 1;
|
||||
if (char_len == 1 and cell_info.char == ' ') {
|
||||
result.put(globalObject, jsc.ZigString.static("char"), jsc.ZigString.static(" ").toJS(globalObject));
|
||||
} else {
|
||||
result.put(globalObject, jsc.ZigString.static("char"), jsc.ZigString.init(char_buf[0..char_len]).toJS(globalObject));
|
||||
}
|
||||
|
||||
result.put(globalObject, jsc.ZigString.static("wide"), JSValue.jsBoolean(cell_info.wide));
|
||||
result.put(globalObject, jsc.ZigString.static("styled"), JSValue.jsBoolean(cell_info.styled));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get a line of text relative to the bottom of the screen
|
||||
/// line(0) = bottom line, line(1) = one above bottom, etc.
|
||||
pub fn line(
|
||||
this: *Terminal,
|
||||
globalObject: *jsc.JSGlobalObject,
|
||||
callframe: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
const row_arg = callframe.argumentsAsArray(1)[0];
|
||||
if (row_arg.isUndefinedOrNull()) {
|
||||
return globalObject.throw("line() requires a row number argument", .{});
|
||||
}
|
||||
|
||||
const offset_from_bottom = row_arg.toInt32();
|
||||
if (offset_from_bottom < 0) {
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
}
|
||||
|
||||
const vt = this.vt orelse return jsc.ZigString.static("").toJS(globalObject);
|
||||
|
||||
const line_text = vt.getLineFromBottom(bun.default_allocator, @intCast(offset_from_bottom)) catch {
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
};
|
||||
defer if (line_text.len > 0) bun.default_allocator.free(line_text);
|
||||
|
||||
if (line_text.len == 0) {
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
}
|
||||
|
||||
return jsc.ZigString.init(line_text).toJS(globalObject);
|
||||
}
|
||||
|
||||
/// Get the full screen text (getter)
|
||||
pub fn getText(this: *Terminal, globalObject: *jsc.JSGlobalObject) JSValue {
|
||||
const vt = this.vt orelse return jsc.ZigString.static("").toJS(globalObject);
|
||||
|
||||
const text = vt.getText(bun.default_allocator) catch {
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
};
|
||||
defer if (text.len > 0) bun.default_allocator.free(text);
|
||||
|
||||
if (text.len == 0) {
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
}
|
||||
|
||||
return jsc.ZigString.init(text).toJS(globalObject);
|
||||
}
|
||||
|
||||
/// Get cursor position and state
|
||||
/// Returns { x: number, y: number, visible: boolean, style: string }
|
||||
pub fn getCursor(this: *Terminal, globalObject: *jsc.JSGlobalObject) JSValue {
|
||||
const result = JSValue.createEmptyObject(globalObject, 4);
|
||||
|
||||
if (this.vt) |vt| {
|
||||
const pos = vt.getCursorPos();
|
||||
const cursor_style = vt.getCursorStyle();
|
||||
|
||||
result.put(globalObject, jsc.ZigString.static("x"), JSValue.jsNumber(pos.x));
|
||||
result.put(globalObject, jsc.ZigString.static("y"), JSValue.jsNumber(pos.y));
|
||||
result.put(globalObject, jsc.ZigString.static("visible"), JSValue.jsBoolean(true));
|
||||
|
||||
const style_str = switch (cursor_style) {
|
||||
.block => jsc.ZigString.static("block"),
|
||||
.bar => jsc.ZigString.static("bar"),
|
||||
.underline => jsc.ZigString.static("underline"),
|
||||
};
|
||||
result.put(globalObject, jsc.ZigString.static("style"), style_str.toJS(globalObject));
|
||||
} else {
|
||||
result.put(globalObject, jsc.ZigString.static("x"), JSValue.jsNumber(0));
|
||||
result.put(globalObject, jsc.ZigString.static("y"), JSValue.jsNumber(0));
|
||||
result.put(globalObject, jsc.ZigString.static("visible"), JSValue.jsBoolean(true));
|
||||
result.put(globalObject, jsc.ZigString.static("style"), jsc.ZigString.static("block").toJS(globalObject));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get cols
|
||||
pub fn getCols(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return JSValue.jsNumber(this.cols);
|
||||
}
|
||||
|
||||
/// Get rows
|
||||
pub fn getRows(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
return JSValue.jsNumber(this.rows);
|
||||
}
|
||||
|
||||
/// Get terminal title (set via OSC escape sequences)
|
||||
pub fn getTitle(this: *Terminal, globalObject: *jsc.JSGlobalObject) JSValue {
|
||||
if (this.vt) |vt| {
|
||||
if (vt.title.items.len > 0) {
|
||||
return jsc.ZigString.init(vt.title.items).toJS(globalObject);
|
||||
}
|
||||
}
|
||||
return jsc.ZigString.static("").toJS(globalObject);
|
||||
}
|
||||
|
||||
/// Check if in alternate screen mode
|
||||
pub fn getAlternateScreen(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
if (this.vt) |vt| {
|
||||
return JSValue.jsBoolean(vt.isAlternateScreen());
|
||||
}
|
||||
return JSValue.jsBoolean(false);
|
||||
}
|
||||
|
||||
/// Get scrollback line count
|
||||
pub fn getScrollbackLines(this: *Terminal, _: *jsc.JSGlobalObject) JSValue {
|
||||
if (this.vt) |vt| {
|
||||
return JSValue.jsNumber(vt.getScrollbackLines());
|
||||
}
|
||||
return JSValue.jsNumber(0);
|
||||
}
|
||||
|
||||
/// Clear the screen
|
||||
pub fn clearScreen(
|
||||
this: *Terminal,
|
||||
_: *jsc.JSGlobalObject,
|
||||
_: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.vt) |vt| {
|
||||
vt.clearScreen();
|
||||
}
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// Reset terminal to initial state
|
||||
pub fn resetTerminal(
|
||||
this: *Terminal,
|
||||
_: *jsc.JSGlobalObject,
|
||||
_: *jsc.CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.vt) |vt| {
|
||||
vt.reset();
|
||||
}
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
/// ghostty-vt module provides terminal VT emulation
|
||||
const ghostty = @import("ghostty");
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("bun");
|
||||
|
||||
9
src/deps/ghostty/build_options.zig
Normal file
9
src/deps/ghostty/build_options.zig
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Build options for ghostty-vt module.
|
||||
//! This provides the build_options that ghostty's SIMD code expects.
|
||||
|
||||
/// SIMD acceleration for optimized UTF-8 and escape sequence parsing.
|
||||
/// Currently disabled because ghostty's SIMD uses C++ implementations (vt.cpp)
|
||||
/// that would need to be built and linked separately.
|
||||
/// The scalar fallback paths provide correct functionality.
|
||||
/// Note: Keep in sync with terminal_options.simd
|
||||
pub const simd = false;
|
||||
1808
src/deps/ghostty/symbols_tables.zig
Normal file
1808
src/deps/ghostty/symbols_tables.zig
Normal file
File diff suppressed because one or more lines are too long
31
src/deps/ghostty/terminal_options.zig
Normal file
31
src/deps/ghostty/terminal_options.zig
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Terminal module build options for ghostty-vt.
|
||||
//! These are normally set by Zig build.zig but we hardcode them for Bun's integration.
|
||||
|
||||
pub const Artifact = enum {
|
||||
ghostty,
|
||||
lib,
|
||||
};
|
||||
|
||||
/// The target artifact - we're building as a library
|
||||
pub const artifact: Artifact = .lib;
|
||||
|
||||
/// C ABI is not needed for Zig-only usage
|
||||
pub const c_abi = false;
|
||||
|
||||
/// Oniguruma regex support - disabled for minimal build
|
||||
pub const oniguruma = false;
|
||||
|
||||
/// SIMD acceleration for optimized UTF-8 and escape sequence parsing.
|
||||
/// Currently disabled because ghostty's SIMD uses C++ implementations (vt.cpp)
|
||||
/// that would need to be built and linked separately.
|
||||
/// The scalar fallback paths provide correct functionality.
|
||||
pub const simd = false;
|
||||
|
||||
/// Slow runtime safety checks - disabled in production
|
||||
pub const slow_runtime_safety = false;
|
||||
|
||||
/// Kitty graphics protocol - requires oniguruma
|
||||
pub const kitty_graphics = false;
|
||||
|
||||
/// Tmux control mode - requires oniguruma
|
||||
pub const tmux_control_mode = false;
|
||||
31371
src/deps/ghostty/unicode_tables.zig
Normal file
31371
src/deps/ghostty/unicode_tables.zig
Normal file
File diff suppressed because one or more lines are too long
864
test/js/bun/terminal/ghostty.test.ts
Normal file
864
test/js/bun/terminal/ghostty.test.ts
Normal file
@@ -0,0 +1,864 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe } from "harness";
|
||||
|
||||
describe("Terminal VT emulation (ghostty)", () => {
|
||||
// ==========================================================================
|
||||
// Constructor and Basic Setup Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("constructor", () => {
|
||||
test("Terminal class exists and is constructible", () => {
|
||||
const Terminal = Bun.Terminal;
|
||||
expect(Terminal).toBeDefined();
|
||||
expect(typeof Terminal).toBe("function");
|
||||
});
|
||||
|
||||
test("creates terminal with default dimensions", () => {
|
||||
const terminal = new Bun.Terminal({});
|
||||
expect(terminal.cols).toBe(80);
|
||||
expect(terminal.rows).toBe(24);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("creates terminal with custom dimensions", () => {
|
||||
const terminal = new Bun.Terminal({
|
||||
rows: 50,
|
||||
cols: 120,
|
||||
});
|
||||
expect(terminal.cols).toBe(120);
|
||||
expect(terminal.rows).toBe(50);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("creates terminal with minimum dimensions", () => {
|
||||
const terminal = new Bun.Terminal({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
});
|
||||
expect(terminal.cols).toBe(1);
|
||||
expect(terminal.rows).toBe(1);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("creates terminal with maximum dimensions", () => {
|
||||
const terminal = new Bun.Terminal({
|
||||
rows: 65535,
|
||||
cols: 65535,
|
||||
});
|
||||
expect(terminal.cols).toBe(65535);
|
||||
expect(terminal.rows).toBe(65535);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on missing options object", () => {
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => new Bun.Terminal()).toThrow();
|
||||
});
|
||||
|
||||
test("throws on null options", () => {
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => new Bun.Terminal(null)).toThrow();
|
||||
});
|
||||
|
||||
test("ignores invalid dimension values (negative)", () => {
|
||||
const terminal = new Bun.Terminal({
|
||||
rows: -10,
|
||||
cols: -20,
|
||||
});
|
||||
// Should use defaults for invalid values
|
||||
expect(terminal.rows).toBe(24);
|
||||
expect(terminal.cols).toBe(80);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("ignores dimension values exceeding max", () => {
|
||||
const terminal = new Bun.Terminal({
|
||||
rows: 100000,
|
||||
cols: 100000,
|
||||
});
|
||||
// Should use defaults for values > 65535
|
||||
expect(terminal.rows).toBe(24);
|
||||
expect(terminal.cols).toBe(80);
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// feed() Method Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("feed()", () => {
|
||||
test("accepts string input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(() => terminal.feed("Hello, World!")).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("accepts ArrayBuffer input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
const buffer = new TextEncoder().encode("Hello, World!");
|
||||
expect(() => terminal.feed(buffer)).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("accepts Uint8Array input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
const buffer = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||
expect(() => terminal.feed(buffer)).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("accepts Buffer input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
const buffer = Buffer.from("Hello, World!");
|
||||
expect(() => terminal.feed(buffer)).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on null input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.feed(null)).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on undefined input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.feed(undefined)).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on number input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.feed(12345)).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on object input", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.feed({ data: "hello" })).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(() => terminal.feed("")).not.toThrow();
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(0);
|
||||
expect(cursor.y).toBe(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles empty buffer", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(() => terminal.feed(new Uint8Array(0))).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles very long string", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
const longBuffer = Buffer.alloc(10000, "A");
|
||||
expect(() => terminal.feed(longBuffer)).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles binary data with null bytes", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
const buffer = new Uint8Array([65, 0, 66, 0, 67]); // A\0B\0C
|
||||
expect(() => terminal.feed(buffer)).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles UTF-8 multibyte characters", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// Feed ASCII + multibyte characters
|
||||
terminal.feed("Hello 世界");
|
||||
const text = terminal.text;
|
||||
// ASCII portion should be preserved
|
||||
expect(text).toContain("Hello");
|
||||
// Multibyte characters are processed (may have encoding variations)
|
||||
expect(text.length).toBeGreaterThan(5);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("updates cursor position after feed", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABCDE");
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(5);
|
||||
expect(cursor.y).toBe(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles newlines correctly", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Line1\nLine2\nLine3");
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.y).toBe(2);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles carriage return", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Hello\rWorld");
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(5); // "World" overwrote "Hello"
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// at() Method Tests (Cell Access)
|
||||
// ==========================================================================
|
||||
|
||||
describe("at()", () => {
|
||||
test("returns cell info for valid position", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
const cell = terminal.at(0, 0);
|
||||
expect(cell).toBeDefined();
|
||||
expect(cell).not.toBeNull();
|
||||
expect(cell.char).toBe("A");
|
||||
expect(typeof cell.wide).toBe("boolean");
|
||||
expect(typeof cell.styled).toBe("boolean");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns correct character at each position", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("XYZ");
|
||||
|
||||
expect(terminal.at(0, 0)?.char).toBe("X");
|
||||
expect(terminal.at(1, 0)?.char).toBe("Y");
|
||||
expect(terminal.at(2, 0)?.char).toBe("Z");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns null for out-of-bounds x", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
const cell = terminal.at(100, 0);
|
||||
expect(cell).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns null for out-of-bounds y", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
const cell = terminal.at(0, 100);
|
||||
expect(cell).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns null for negative x", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
const cell = terminal.at(-1, 0);
|
||||
expect(cell).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns null for negative y", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
const cell = terminal.at(0, -1);
|
||||
expect(cell).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns space for unwritten cells", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("A");
|
||||
|
||||
// Cell at position 5,0 should be empty/space
|
||||
const cell = terminal.at(5, 0);
|
||||
expect(cell).toBeDefined();
|
||||
expect(cell?.char).toBe(" ");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns null before any feed (no VT initialized)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// VT is lazily initialized, so at() before feed() returns null
|
||||
const cell = terminal.at(0, 0);
|
||||
expect(cell).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles boundary positions", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 10 });
|
||||
terminal.feed("X");
|
||||
|
||||
// Last valid position
|
||||
const lastCell = terminal.at(9, 4);
|
||||
expect(lastCell).toBeDefined();
|
||||
|
||||
// First invalid positions
|
||||
expect(terminal.at(10, 0)).toBeNull();
|
||||
expect(terminal.at(0, 5)).toBeNull();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on missing arguments", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC");
|
||||
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.at()).toThrow();
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.at(0)).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles wide characters", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("A中B"); // 中 is a wide character
|
||||
|
||||
const cellA = terminal.at(0, 0);
|
||||
expect(cellA?.char).toBe("A");
|
||||
expect(cellA?.wide).toBe(false);
|
||||
|
||||
// Wide character
|
||||
const cellWide = terminal.at(1, 0);
|
||||
expect(cellWide?.wide).toBe(true);
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// line() Method Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("line()", () => {
|
||||
test("returns line relative to bottom (offset 0 = bottom)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("First\nSecond\nThird");
|
||||
|
||||
// Cursor is on row 2 (0-indexed) after "Third"
|
||||
// line(0) should return the content of row 2
|
||||
const bottomLine = terminal.line(0);
|
||||
expect(bottomLine).toBeDefined();
|
||||
expect(typeof bottomLine).toBe("string");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns correct lines for multi-line content", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("AAA\nBBB\nCCC\nDDD");
|
||||
|
||||
// DDD is at row 3, CCC at row 2, BBB at row 1, AAA at row 0
|
||||
// But line() is bottom-relative to visible area
|
||||
// With 10 rows, bottom is row 9
|
||||
// line(9) = row 0 = "AAA"
|
||||
// line(8) = row 1 = "BBB"
|
||||
// etc.
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns empty string for offset beyond screen", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
const line = terminal.line(100);
|
||||
expect(line).toBe("");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns empty string for negative offset", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
const line = terminal.line(-1);
|
||||
expect(line).toBe("");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns empty string before any feed", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
|
||||
const line = terminal.line(0);
|
||||
expect(line).toBe("");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("throws on missing argument", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.line()).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles null argument", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => terminal.line(null)).toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("trims trailing whitespace", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
const line = terminal.line(4); // Row 0 where "Hello" is
|
||||
// Should not have trailing spaces
|
||||
expect(line).toBe(line.trimEnd());
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// text Property Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("text property", () => {
|
||||
test("returns screen content as string", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Line 1\nLine 2\nLine 3");
|
||||
|
||||
const text = terminal.text;
|
||||
expect(typeof text).toBe("string");
|
||||
expect(text).toContain("Line 1");
|
||||
expect(text).toContain("Line 2");
|
||||
expect(text).toContain("Line 3");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns empty string before any feed", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
|
||||
const text = terminal.text;
|
||||
expect(text).toBe("");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("is a getter (not callable)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
// Should be accessible as property
|
||||
expect(terminal.text).toBeDefined();
|
||||
|
||||
// Should not be callable
|
||||
// @ts-expect-error - testing that it's not a function
|
||||
expect(typeof terminal.text).not.toBe("function");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("reflects current screen state", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 20 });
|
||||
|
||||
terminal.feed("First");
|
||||
expect(terminal.text).toContain("First");
|
||||
|
||||
terminal.feed("\nSecond");
|
||||
expect(terminal.text).toContain("Second");
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// cursor Property Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("cursor property", () => {
|
||||
test("returns cursor object with x, y, visible, style", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Hello");
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor).toBeDefined();
|
||||
expect(typeof cursor.x).toBe("number");
|
||||
expect(typeof cursor.y).toBe("number");
|
||||
expect(typeof cursor.visible).toBe("boolean");
|
||||
expect(typeof cursor.style).toBe("string");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("starts at position (0, 0)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed(""); // Initialize VT
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(0);
|
||||
expect(cursor.y).toBe(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("tracks horizontal movement", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABCDE");
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(5);
|
||||
expect(cursor.y).toBe(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("tracks vertical movement with newlines", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("A\nB\nC");
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.y).toBe(2);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("responds to escape sequence cursor positioning", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
// ESC[10;5H = move cursor to row 10, column 5 (1-indexed)
|
||||
terminal.feed("\x1b[10;5H");
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.y).toBe(9); // 0-indexed
|
||||
expect(cursor.x).toBe(4); // 0-indexed
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("default style is block", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("X");
|
||||
|
||||
expect(terminal.cursor.style).toBe("block");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns default values before feed", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
|
||||
const cursor = terminal.cursor;
|
||||
expect(cursor.x).toBe(0);
|
||||
expect(cursor.y).toBe(0);
|
||||
expect(cursor.style).toBe("block");
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Escape Sequence Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("escape sequences", () => {
|
||||
test("parses CSI cursor movement", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
terminal.feed("\x1b[10;20H"); // Move to row 10, col 20
|
||||
|
||||
expect(terminal.cursor.y).toBe(9);
|
||||
expect(terminal.cursor.x).toBe(19);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("parses cursor up (CUU)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
terminal.feed("\x1b[10;10H"); // Start at row 10
|
||||
terminal.feed("\x1b[3A"); // Move up 3
|
||||
|
||||
expect(terminal.cursor.y).toBe(6);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("parses cursor down (CUD)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
terminal.feed("\x1b[5;5H"); // Start at row 5
|
||||
terminal.feed("\x1b[3B"); // Move down 3
|
||||
|
||||
expect(terminal.cursor.y).toBe(7);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("parses cursor forward (CUF)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
terminal.feed("\x1b[5;5H"); // Start at col 5
|
||||
terminal.feed("\x1b[10C"); // Move forward 10
|
||||
|
||||
expect(terminal.cursor.x).toBe(14);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("parses cursor backward (CUB)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
terminal.feed("\x1b[5;15H"); // Start at col 15
|
||||
terminal.feed("\x1b[5D"); // Move backward 5
|
||||
|
||||
expect(terminal.cursor.x).toBe(9);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles alternate screen switch", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
|
||||
expect(terminal.alternateScreen).toBe(false);
|
||||
|
||||
// Switch to alternate screen (smcup - ESC[?1049h)
|
||||
terminal.feed("\x1b[?1049h");
|
||||
expect(terminal.alternateScreen).toBe(true);
|
||||
|
||||
// Switch back to main screen (rmcup - ESC[?1049l)
|
||||
terminal.feed("\x1b[?1049l");
|
||||
expect(terminal.alternateScreen).toBe(false);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles erase display (ED)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Hello World");
|
||||
terminal.feed("\x1b[2J"); // Erase entire display
|
||||
|
||||
// Screen should be cleared
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles erase line (EL)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Hello World");
|
||||
terminal.feed("\x1b[2K"); // Erase entire line
|
||||
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles SGR (colors/styles)", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
// Set red foreground, then write text
|
||||
terminal.feed("\x1b[31mRed Text\x1b[0m");
|
||||
|
||||
const cell = terminal.at(0, 0);
|
||||
expect(cell?.styled).toBe(true);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles tab character", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("A\tB");
|
||||
|
||||
// Tab should move cursor
|
||||
expect(terminal.cursor.x).toBeGreaterThan(2);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("handles backspace", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("ABC\x08"); // ABC then backspace
|
||||
|
||||
expect(terminal.cursor.x).toBe(2);
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// clear() and reset() Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("clear() and reset()", () => {
|
||||
test("clear() works without errors", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Some text here");
|
||||
|
||||
expect(() => terminal.clear()).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("reset() works without errors", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Some text here");
|
||||
|
||||
expect(() => terminal.reset()).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("clear() before any feed works", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(() => terminal.clear()).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("reset() before any feed works", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(() => terminal.reset()).not.toThrow();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("multiple clear() calls work", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Text");
|
||||
terminal.clear();
|
||||
terminal.feed("More text");
|
||||
terminal.clear();
|
||||
terminal.clear();
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("multiple reset() calls work", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Text");
|
||||
terminal.reset();
|
||||
terminal.feed("More text");
|
||||
terminal.reset();
|
||||
terminal.reset();
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// State After Close Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("operations after close", () => {
|
||||
test("closed property reflects state", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(terminal.closed).toBe(false);
|
||||
|
||||
terminal.close();
|
||||
expect(terminal.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("double close does not throw", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.close();
|
||||
expect(() => terminal.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// alternateScreen Property Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("alternateScreen property", () => {
|
||||
test("initially false", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
expect(terminal.alternateScreen).toBe(false);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("false before feed", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
expect(terminal.alternateScreen).toBe(false);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("toggles with escape sequences", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 24, cols: 80 });
|
||||
|
||||
terminal.feed("\x1b[?1049h");
|
||||
expect(terminal.alternateScreen).toBe(true);
|
||||
|
||||
terminal.feed("\x1b[?1049l");
|
||||
expect(terminal.alternateScreen).toBe(false);
|
||||
|
||||
terminal.feed("\x1b[?1049h");
|
||||
expect(terminal.alternateScreen).toBe(true);
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// scrollbackLines Property Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("scrollbackLines property", () => {
|
||||
test("initially zero", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(terminal.scrollbackLines).toBe(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns number", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(typeof terminal.scrollbackLines).toBe("number");
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// title Property Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("title property", () => {
|
||||
test("initially empty", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(terminal.title).toBe("");
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("returns string", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
expect(typeof terminal.title).toBe("string");
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Line Wrapping Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("line wrapping", () => {
|
||||
test("text wraps at column boundary", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 10 });
|
||||
terminal.feed("ABCDEFGHIJKLMNO"); // 15 chars in 10-col terminal
|
||||
|
||||
// Cursor should have wrapped to next line
|
||||
expect(terminal.cursor.y).toBeGreaterThan(0);
|
||||
terminal.close();
|
||||
});
|
||||
|
||||
test("cursor stays within bounds", () => {
|
||||
const terminal = new Bun.Terminal({ rows: 5, cols: 10 });
|
||||
terminal.feed(Buffer.alloc(100, "A"));
|
||||
|
||||
// Cursor should be within terminal bounds
|
||||
expect(terminal.cursor.x).toBeLessThan(10);
|
||||
expect(terminal.cursor.y).toBeLessThan(5);
|
||||
terminal.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Integration with Process Spawn Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("integration with spawn", () => {
|
||||
test("can parse ANSI output from spawned process", async () => {
|
||||
const proc = Bun.spawn({
|
||||
cmd: [bunExe(), "-e", "console.log('\\x1b[32mGreen\\x1b[0m')"],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
expect(stdout).toContain("Green");
|
||||
|
||||
await proc.exited;
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Memory/Resource Management Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe("resource management", () => {
|
||||
test("can create and close many terminals", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Test");
|
||||
terminal.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("using syntax works", async () => {
|
||||
await using terminal = new Bun.Terminal({ rows: 10, cols: 40 });
|
||||
terminal.feed("Hello");
|
||||
expect(terminal.closed).toBe(false);
|
||||
// Terminal should be closed after block
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user