This commit is contained in:
Jarred Sumner
2026-02-02 06:44:29 -08:00
parent ddefa11070
commit b87793b67e
29 changed files with 7424 additions and 6 deletions

View File

@@ -759,7 +759,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) {
@@ -869,6 +868,28 @@ fn addInternalImports(b: *Build, mod: *Module, opts: *BunBuildOptions) void {
.root_source_file = b.path(async_path),
});
// Ghostty terminal module — used by Bun's TUI primitives (Screen/Writer).
// We provide terminal_options matching Ghostty's build_options.zig.Options.
{
// Must match ghostty's terminal/build_options.zig Artifact enum
const GhosttyArtifact = enum { ghostty, lib };
const ghostty_terminal_opts = b.addOptions();
ghostty_terminal_opts.addOption(GhosttyArtifact, "artifact", .lib);
ghostty_terminal_opts.addOption(bool, "c_abi", false);
ghostty_terminal_opts.addOption(bool, "oniguruma", false);
ghostty_terminal_opts.addOption(bool, "simd", true);
ghostty_terminal_opts.addOption(bool, "slow_runtime_safety", false);
ghostty_terminal_opts.addOption(bool, "kitty_graphics", false);
ghostty_terminal_opts.addOption(bool, "tmux_control_mode", false);
const ghostty_mod = b.createModule(.{
.root_source_file = b.path("vendor/ghostty/src/ghostty_terminal.zig"),
});
ghostty_mod.addOptions("terminal_options", ghostty_terminal_opts);
mod.addImport("ghostty", ghostty_mod);
}
// Generated code exposed as individual modules.
inline for (.{
.{ .file = "ZigGeneratedClasses.zig", .import = "ZigGeneratedClasses" },

View File

@@ -8,8 +8,11 @@
"@lezer/common": "^1.2.3",
"@lezer/cpp": "^1.1.3",
"@types/bun": "workspace:*",
"@xterm/headless": "^6.0.0",
"@xterm/xterm": "^6.0.0",
"bun-tracestrings": "github:oven-sh/bun.report#912ca63e26c51429d3e6799aa2a6ab079b188fd8",
"esbuild": "^0.21.5",
"marked": "^17.0.1",
"mitata": "^0.1.14",
"peechy": "0.4.34",
"prettier": "^3.6.2",
@@ -29,6 +32,7 @@
},
"packages/bun-types": {
"name": "bun-types",
"version": "1.3.8",
"dependencies": {
"@types/node": "*",
},
@@ -158,6 +162,10 @@
"@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="],
"@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
@@ -256,7 +264,7 @@
"lru-cache": ["@wolfy1339/lru-cache@11.0.2-patch.1", "", {}, "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA=="],
"marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"mitata": ["mitata@0.1.14", "", {}, "sha512-8kRs0l636eT4jj68PFXOR2D5xl4m56T478g16SzUPOYgkzQU+xaw62guAQxzBPm+SXb15GQi1cCpDxJfkr4CSA=="],
@@ -328,6 +336,8 @@
"@octokit/webhooks/@octokit/webhooks-methods": ["@octokit/webhooks-methods@4.1.0", "", {}, "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ=="],
"bun-tracestrings/marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="],
"camel-case/no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="],
"change-case/camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="],

View File

@@ -9,7 +9,11 @@
},
{
"output": "ZigGeneratedClassesSources.txt",
"paths": ["src/bun.js/*.classes.ts", "src/bun.js/{api,node,test,webcore}/*.classes.ts"]
"paths": [
"src/bun.js/*.classes.ts",
"src/bun.js/{api,node,test,webcore}/*.classes.ts",
"src/bun.js/api/tui/*.classes.ts"
]
},
{
"output": "JavaScriptSources.txt",

View File

@@ -0,0 +1,8 @@
register_repository(
NAME
ghostty
REPOSITORY
ghostty-org/ghostty
COMMIT
1b7a15899ad40fba4ce020f537055d30eaf99ee8
)

View File

@@ -10,8 +10,11 @@
"@lezer/common": "^1.2.3",
"@lezer/cpp": "^1.1.3",
"@types/bun": "workspace:*",
"@xterm/headless": "^6.0.0",
"@xterm/xterm": "^6.0.0",
"bun-tracestrings": "github:oven-sh/bun.report#912ca63e26c51429d3e6799aa2a6ab079b188fd8",
"esbuild": "^0.21.5",
"marked": "^17.0.1",
"mitata": "^0.1.14",
"peechy": "0.4.34",
"prettier": "^3.6.2",

View File

@@ -32,5 +32,6 @@
"bun",
"bun.js",
"types"
]
}
],
"version": "1.3.8"
}

View File

@@ -151,6 +151,7 @@ pub const FilePoll = struct {
const ShellStaticPipeWriter = bun.shell.ShellSubprocess.StaticPipeWriter.Poll;
const FileSink = jsc.WebCore.FileSink.Poll;
const TerminalPoll = bun.api.Terminal.Poll;
const TuiWriterPoll = bun.api.TuiTerminalWriter.IOWriter;
const DNSResolver = bun.api.dns.Resolver;
const GetAddrInfoRequest = bun.api.dns.GetAddrInfoRequest;
const Request = bun.api.dns.internal.Request;
@@ -183,6 +184,7 @@ pub const FilePoll = struct {
Process,
ShellBufferedWriter, // i do not know why, but this has to be here otherwise compiler will complain about dependency loop
TerminalPoll,
TuiWriterPoll,
});
pub const AllocatorType = enum {
@@ -422,6 +424,12 @@ pub const FilePoll = struct {
handler.onPoll(size_or_offset, poll.flags.contains(.hup));
},
@field(Owner.Tag, @typeName(TuiWriterPoll)) => {
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) TuiWriter", .{poll.fd});
var handler: *TuiWriterPoll = ptr.as(TuiWriterPoll);
handler.onPoll(size_or_offset, poll.flags.contains(.hup));
},
else => {
const possible_name = Owner.typeNameFromTag(@intFromEnum(ptr.tag()));
log("onUpdate " ++ kqueue_or_epoll ++ " (fd: {f}) disconnected? (maybe: {s})", .{ poll.fd, possible_name orelse "<unknown>" });

View File

@@ -25,6 +25,12 @@ pub const SocketHandlers = @import("./api/bun/socket.zig").Handlers;
pub const Subprocess = @import("./api/bun/subprocess.zig");
pub const Terminal = @import("./api/bun/Terminal.zig");
pub const tui = @import("./api/tui/tui.zig");
pub const TuiRenderer = tui.TuiRenderer;
pub const TuiScreen = tui.TuiScreen;
pub const TuiTerminalWriter = tui.TuiTerminalWriter;
pub const TuiBufferWriter = tui.TuiBufferWriter;
pub const TuiKeyReader = tui.TuiKeyReader;
pub const HashObject = @import("./api/HashObject.zig");
pub const JSONCObject = @import("./api/JSONCObject.zig");
pub const MarkdownObject = @import("./api/MarkdownObject.zig");

View File

@@ -82,6 +82,10 @@ pub const BunObject = struct {
pub const ValkeyClient = toJSLazyPropertyCallback(Bun.getValkeyClientConstructor);
pub const valkey = toJSLazyPropertyCallback(Bun.getValkeyDefaultClient);
pub const Terminal = toJSLazyPropertyCallback(Bun.getTerminalConstructor);
pub const TUIScreen = toJSLazyPropertyCallback(Bun.getScreenConstructor);
pub const TUITerminalWriter = toJSLazyPropertyCallback(Bun.getTerminalWriterConstructor);
pub const TUIBufferWriter = toJSLazyPropertyCallback(Bun.getBufferWriterConstructor);
pub const TUIKeyReader = toJSLazyPropertyCallback(Bun.getKeyReaderConstructor);
// --- Lazy property callbacks ---
// --- Getters ---
@@ -152,6 +156,10 @@ pub const BunObject = struct {
@export(&BunObject.ValkeyClient, .{ .name = lazyPropertyCallbackName("ValkeyClient") });
@export(&BunObject.valkey, .{ .name = lazyPropertyCallbackName("valkey") });
@export(&BunObject.Terminal, .{ .name = lazyPropertyCallbackName("Terminal") });
@export(&BunObject.TUIScreen, .{ .name = lazyPropertyCallbackName("TUIScreen") });
@export(&BunObject.TUITerminalWriter, .{ .name = lazyPropertyCallbackName("TUITerminalWriter") });
@export(&BunObject.TUIBufferWriter, .{ .name = lazyPropertyCallbackName("TUIBufferWriter") });
@export(&BunObject.TUIKeyReader, .{ .name = lazyPropertyCallbackName("TUIKeyReader") });
// --- Lazy property callbacks ---
// --- Callbacks ---
@@ -1342,6 +1350,22 @@ pub fn getTerminalConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject)
return api.Terminal.js.getConstructor(globalThis);
}
pub fn getScreenConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.TuiScreen.js.getConstructor(globalThis);
}
pub fn getTerminalWriterConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.TuiTerminalWriter.js.getConstructor(globalThis);
}
pub fn getBufferWriterConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.TuiBufferWriter.js.getConstructor(globalThis);
}
pub fn getKeyReaderConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.TuiKeyReader.js.getConstructor(globalThis);
}
pub fn getEmbeddedFiles(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) bun.JSError!jsc.JSValue {
const vm = globalThis.bunVM();
const graph = vm.standalone_module_graph orelse return try jsc.JSValue.createEmptyArray(globalThis, 0);

View File

@@ -0,0 +1,44 @@
import { define } from "../../../codegen/class-definitions";
export default [
define({
name: "TuiBufferWriter",
construct: true,
constructNeedsThis: true,
finalize: true,
configurable: false,
klass: {},
JSType: "0b11101110",
values: ["buffer"],
proto: {
render: {
fn: "render",
length: 2,
},
clear: {
fn: "clear",
length: 0,
},
close: {
fn: "close",
length: 0,
},
end: {
fn: "end",
length: 0,
},
cursorX: {
getter: "getCursorX",
},
cursorY: {
getter: "getCursorY",
},
byteOffset: {
getter: "getByteOffset",
},
byteLength: {
getter: "getByteLength",
},
},
}),
];

View File

@@ -0,0 +1,38 @@
import { define } from "../../../codegen/class-definitions";
export default [
define({
name: "TuiKeyReader",
construct: true,
finalize: true,
configurable: false,
klass: {},
JSType: "0b11101110",
proto: {
close: {
fn: "close",
length: 0,
},
onkeypress: {
setter: "setOnKeypress",
getter: "getOnKeypress",
},
onpaste: {
setter: "setOnPaste",
getter: "getOnPaste",
},
onmouse: {
setter: "setOnMouse",
getter: "getOnMouse",
},
onfocus: {
setter: "setOnFocus",
getter: "getOnFocus",
},
onblur: {
setter: "setOnBlur",
getter: "getOnBlur",
},
},
}),
];

View File

@@ -0,0 +1,73 @@
import { define } from "../../../codegen/class-definitions";
export default [
define({
name: "TuiScreen",
construct: true,
finalize: true,
configurable: false,
estimatedSize: true,
klass: {},
JSType: "0b11101110",
proto: {
setText: {
fn: "setText",
length: 4,
},
style: {
fn: "style",
length: 1,
},
clearRect: {
fn: "clearRect",
length: 4,
},
fill: {
fn: "fill",
length: 6,
},
copy: {
fn: "copy",
length: 7,
},
resize: {
fn: "resize",
length: 2,
},
clear: {
fn: "clear",
length: 0,
},
width: {
getter: "getWidth",
},
height: {
getter: "getHeight",
},
getCell: {
fn: "getCell",
length: 2,
},
hyperlink: {
fn: "hyperlink",
length: 1,
},
setHyperlink: {
fn: "setHyperlink",
length: 3,
},
clip: {
fn: "clip",
length: 4,
},
unclip: {
fn: "unclip",
length: 0,
},
drawBox: {
fn: "drawBox",
length: 5,
},
},
}),
];

View File

@@ -0,0 +1,82 @@
import { define } from "../../../codegen/class-definitions";
export default [
define({
name: "TuiTerminalWriter",
construct: true,
finalize: true,
configurable: false,
klass: {},
JSType: "0b11101110",
proto: {
render: {
fn: "render",
length: 2,
},
clear: {
fn: "clear",
length: 0,
},
close: {
fn: "close",
length: 0,
},
end: {
fn: "end",
length: 0,
},
enterAltScreen: {
fn: "enterAltScreen",
length: 0,
},
exitAltScreen: {
fn: "exitAltScreen",
length: 0,
},
enableMouseTracking: {
fn: "enableMouseTracking",
length: 0,
},
disableMouseTracking: {
fn: "disableMouseTracking",
length: 0,
},
enableFocusTracking: {
fn: "enableFocusTracking",
length: 0,
},
disableFocusTracking: {
fn: "disableFocusTracking",
length: 0,
},
enableBracketedPaste: {
fn: "enableBracketedPaste",
length: 0,
},
disableBracketedPaste: {
fn: "disableBracketedPaste",
length: 0,
},
write: {
fn: "write",
length: 1,
},
cursorX: {
getter: "getCursorX",
},
cursorY: {
getter: "getCursorY",
},
columns: {
getter: "getColumns",
},
rows: {
getter: "getRows",
},
onresize: {
setter: "setOnResize",
getter: "getOnResize",
},
},
}),
];

View File

@@ -0,0 +1,177 @@
//! TuiBufferWriter — JS-visible renderer that writes ANSI into a caller-owned ArrayBuffer.
//!
//! `render()` writes from byte 0, truncates if frame > capacity, returns total
//! rendered byte count.
//!
//! Read-only getters:
//! - `byteOffset` = min(rendered_len, capacity) — position after last write
//! - `byteLength` = total rendered bytes (may exceed capacity, signals truncation)
//!
//! `close()` / `end()` — clear renderer, release buffer ref, throw on future `render()`.
const TuiBufferWriter = @This();
const TuiRenderer = @import("./renderer.zig");
const TuiScreen = @import("./screen.zig");
const CursorStyle = TuiRenderer.CursorStyle;
const ghostty = @import("ghostty").terminal;
const size = ghostty.size;
pub const js = jsc.Codegen.JSTuiBufferWriter;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
renderer: TuiRenderer = .{},
/// Internal buffer used by TuiRenderer, then copied into the ArrayBuffer.
output: std.ArrayList(u8) = .{},
/// True after close() / end() has been called.
closed: bool = false,
/// Number of bytes actually copied into the ArrayBuffer (min of rendered, capacity).
byte_offset: usize = 0,
/// Total number of rendered bytes (may exceed capacity).
byte_length: usize = 0,
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, this_value: jsc.JSValue) bun.JSError!*TuiBufferWriter {
const arguments = callframe.arguments();
if (arguments.len < 1)
return globalThis.throw("TUIBufferWriter requires an ArrayBuffer or TypedArray argument", .{});
const arg = arguments[0];
// Buffer mode: ArrayBuffer or TypedArray
if (arg.asArrayBuffer(globalThis) == null)
return globalThis.throw("TUIBufferWriter requires an ArrayBuffer or TypedArray argument", .{});
const this = bun.new(TuiBufferWriter, .{});
// Store the ArrayBuffer on the JS object via GC-traced write barrier.
js.gc.set(.buffer, this_value, globalThis, arg);
return this;
}
fn deinit(this: *TuiBufferWriter) void {
this.renderer.deinit();
this.output.deinit(bun.default_allocator);
bun.destroy(this);
}
pub fn finalize(this: *TuiBufferWriter) callconv(.c) void {
deinit(this);
}
// --- render ---
/// render(screen, options?)
/// Copies ANSI frame into the ArrayBuffer, returns total rendered byte count.
pub fn render(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUIBufferWriter is closed", .{});
const arguments = callframe.arguments();
if (arguments.len < 1) return globalThis.throw("render requires a Screen argument", .{});
const screen = TuiScreen.fromJS(arguments[0]) orelse
return globalThis.throw("render: argument must be a TUIScreen", .{});
// Parse optional cursor options
var cursor_x: ?size.CellCountInt = null;
var cursor_y: ?size.CellCountInt = null;
var cursor_visible: ?bool = null;
var cursor_style: ?CursorStyle = null;
var cursor_blinking: ?bool = null;
if (arguments.len > 1 and arguments[1].isObject()) {
const opts = arguments[1];
if (try opts.getTruthy(globalThis, "cursorX")) |v| {
const val = try v.coerce(i32, globalThis);
cursor_x = @intCast(@max(0, @min(val, screen.page.size.cols -| 1)));
}
if (try opts.getTruthy(globalThis, "cursorY")) |v| {
const val = try v.coerce(i32, globalThis);
cursor_y = @intCast(@max(0, @min(val, screen.page.size.rows -| 1)));
}
if (try opts.getTruthy(globalThis, "cursorVisible")) |v| {
if (v.isBoolean()) cursor_visible = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "cursorStyle")) |v| {
cursor_style = parseCursorStyle(v, globalThis);
}
if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| {
if (v.isBoolean()) cursor_blinking = v.asBoolean();
}
}
const ab_val = js.gc.get(.buffer, callframe.this()) orelse
return globalThis.throw("render: ArrayBuffer has been detached", .{});
const ab = ab_val.asArrayBuffer(globalThis) orelse
return globalThis.throw("render: ArrayBuffer has been detached", .{});
const dest = ab.byteSlice();
if (dest.len == 0)
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);
const total_len = this.output.items.len;
const copy_len = @min(total_len, dest.len);
@memcpy(dest[0..copy_len], this.output.items[0..copy_len]);
this.byte_offset = copy_len;
this.byte_length = total_len;
return jsc.JSValue.jsNumber(copy_len);
}
/// clear()
pub fn clear(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUIBufferWriter is closed", .{});
this.renderer.clear();
this.byte_offset = 0;
this.byte_length = 0;
return .js_undefined;
}
/// close()
pub fn close(this: *TuiBufferWriter, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return .js_undefined;
this.closed = true;
this.renderer.clear();
this.byte_offset = 0;
this.byte_length = 0;
return .js_undefined;
}
/// end() — alias for close()
pub fn end(this: *TuiBufferWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
return this.close(globalThis, callframe);
}
// --- Getters ---
pub fn getCursorX(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_x)));
}
pub fn getCursorY(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_y)));
}
pub fn getByteOffset(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.byte_offset)));
}
pub fn getByteLength(this: *const TuiBufferWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.byte_length)));
}
// --- Helpers ---
fn parseCursorStyle(value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) ?CursorStyle {
const str = value.toSliceClone(globalThis) catch return null;
defer str.deinit();
return cursor_style_map.get(str.slice());
}
const cursor_style_map = bun.ComptimeEnumMap(CursorStyle);
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;

View File

@@ -0,0 +1,631 @@
//! TuiKeyReader — reads stdin in raw mode, parses escape sequences via
//! Ghostty's VT parser, and delivers structured key events to JS callbacks.
const TuiKeyReader = @This();
const ghostty = @import("ghostty").terminal;
const Parser = ghostty.Parser;
const Action = Parser.Action;
const RefCount = bun.ptr.RefCount(TuiKeyReader, "ref_count", deinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
pub const js = jsc.Codegen.JSTuiKeyReader;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
pub const IOReader = bun.io.BufferedReader;
ref_count: RefCount,
reader: IOReader = IOReader.init(TuiKeyReader),
parser: Parser = Parser.init(),
event_loop_handle: jsc.EventLoopHandle = undefined,
globalThis: *jsc.JSGlobalObject = undefined,
stdin_fd: bun.FileDescriptor = undefined,
/// JS callbacks stored as Strong.Optional refs.
onkeypress_callback: jsc.Strong.Optional = .empty,
onpaste_callback: jsc.Strong.Optional = .empty,
onmouse_callback: jsc.Strong.Optional = .empty,
onfocus_callback: jsc.Strong.Optional = .empty,
onblur_callback: jsc.Strong.Optional = .empty,
/// Bracketed paste accumulation buffer.
paste_buf: std.ArrayList(u8) = .{},
/// SGR mouse sequence accumulation buffer.
mouse_buf: [32]u8 = undefined,
mouse_len: u8 = 0,
flags: Flags = .{},
const Flags = packed struct {
closed: bool = false,
reader_done: bool = false,
in_paste: bool = false,
is_tty: bool = false,
/// Set when ESC O (SS3) was received; the next char is the SS3 payload.
ss3_pending: bool = false,
/// Set when bare ESC was received; the next char is alt+char.
esc_pending: bool = false,
/// Set when we're accumulating an SGR mouse event sequence.
in_mouse: bool = false,
_padding: u1 = 0,
};
pub fn constructor(globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
if (comptime bun.Environment.isWindows) {
return globalThis.throw("TUIKeyReader is not supported on Windows", .{});
}
const stdin_fd = bun.FD.fromNative(0);
// Set raw mode if stdin is a TTY.
const is_tty = std.posix.isatty(0);
if (is_tty) {
if (Bun__ttySetMode(0, 1) != 0)
return globalThis.throw("Failed to set raw mode on stdin", .{});
}
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 },
});
this.reader.setParent(this);
switch (this.reader.start(stdin_fd, true)) {
.result => {},
.err => {
_ = Bun__ttySetMode(0, 0);
bun.destroy(this);
return globalThis.throw("Failed to start reading stdin", .{});
},
}
this.reader.flags.close_handle = false; // Do NOT close stdin.
// Don't call reader.read() here — defer until onkeypress callback is set,
// otherwise the initial read may consume data before JS has a chance to
// set up its callback handler.
return this;
}
fn deinit(this: *TuiKeyReader) void {
this.onkeypress_callback.deinit();
this.onpaste_callback.deinit();
this.onmouse_callback.deinit();
this.onfocus_callback.deinit();
this.onblur_callback.deinit();
if (!this.flags.closed) {
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.deinit();
}
this.parser.deinit();
this.paste_buf.deinit(bun.default_allocator);
bun.destroy(this);
}
pub fn finalize(this: *TuiKeyReader) callconv(.c) void {
if (!this.flags.closed) {
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.close();
}
this.deref();
}
pub fn eventLoop(this: *TuiKeyReader) jsc.EventLoopHandle {
return this.event_loop_handle;
}
pub fn loop(this: *TuiKeyReader) *bun.Async.Loop {
if (comptime bun.Environment.isWindows) {
return this.event_loop_handle.loop().uv_loop;
} else {
return this.event_loop_handle.loop();
}
}
// --- BufferedReader callbacks ---
pub fn onReadChunk(this: *TuiKeyReader, chunk: []const u8, _: bun.io.ReadState) bool {
this.processInput(chunk);
return true;
}
pub fn onReaderDone(this: *TuiKeyReader) void {
if (!this.flags.reader_done) {
this.flags.reader_done = true;
}
}
pub fn onReaderError(this: *TuiKeyReader, _: bun.sys.Error) void {
if (!this.flags.reader_done) {
this.flags.reader_done = true;
}
}
// --- JS methods ---
pub fn close(this: *TuiKeyReader, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (!this.flags.closed) {
if (this.flags.is_tty) _ = Bun__ttySetMode(0, 0);
this.flags.closed = true;
this.reader.close();
}
return .js_undefined;
}
pub fn setOnKeypress(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
const was_empty = this.onkeypress_callback.get() == null;
if (value.isCallable()) {
this.onkeypress_callback = jsc.Strong.Optional.create(value, globalThis);
// Start reading on first callback set.
if (was_empty and !this.flags.closed) {
this.reader.read();
}
} else if (value.isUndefinedOrNull()) {
this.onkeypress_callback.deinit();
}
}
pub fn getOnKeypress(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onkeypress_callback.get() orelse .js_undefined;
}
pub fn setOnPaste(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value.isCallable()) {
this.onpaste_callback = jsc.Strong.Optional.create(value, globalThis);
} else if (value.isUndefinedOrNull()) {
this.onpaste_callback.deinit();
}
}
pub fn getOnPaste(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onpaste_callback.get() orelse .js_undefined;
}
pub fn setOnMouse(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value.isCallable()) {
this.onmouse_callback = jsc.Strong.Optional.create(value, globalThis);
// Start reading on first callback set.
if (!this.flags.closed) {
this.reader.read();
}
} else if (value.isUndefinedOrNull()) {
this.onmouse_callback.deinit();
}
}
pub fn getOnMouse(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onmouse_callback.get() orelse .js_undefined;
}
pub fn setOnFocus(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value.isCallable()) {
this.onfocus_callback = jsc.Strong.Optional.create(value, globalThis);
} else if (value.isUndefinedOrNull()) {
this.onfocus_callback.deinit();
}
}
pub fn getOnFocus(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onfocus_callback.get() orelse .js_undefined;
}
pub fn setOnBlur(this: *TuiKeyReader, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value.isCallable()) {
this.onblur_callback = jsc.Strong.Optional.create(value, globalThis);
} else if (value.isUndefinedOrNull()) {
this.onblur_callback.deinit();
}
}
pub fn getOnBlur(this: *TuiKeyReader, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onblur_callback.get() orelse .js_undefined;
}
// --- Input processing via Ghostty parser ---
fn processInput(this: *TuiKeyReader, data: []const u8) void {
var i: usize = 0;
while (i < data.len) {
const byte = data[i];
// 0x7F (DEL) is ignored by the VT parser — handle it directly as backspace.
if (byte == 0x7f) {
if (this.flags.in_paste) {
this.paste_buf.append(bun.default_allocator, byte) catch {};
} else {
this.emitKeypress("backspace", &.{0x7f}, false, false, false);
}
i += 1;
continue;
}
// UTF-8 multi-byte sequences: the raw VT parser doesn't handle these.
// Decode them and emit as print events directly.
if (byte >= 0xC0) {
const seq_len = bun.strings.utf8ByteSequenceLength(byte);
if (i + seq_len <= data.len) {
const seq = data[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 decoded = bun.strings.decodeWTF8RuneT(&seq_bytes, seq_len, u21, 0xFFFD);
if (decoded == 0xFFFD) {
i += 1;
continue;
}
if (this.flags.in_paste) {
this.paste_buf.appendSlice(bun.default_allocator, seq) catch {};
} else if (this.flags.esc_pending) {
this.flags.esc_pending = false;
this.emitPrintChar(decoded, true);
} else {
this.emitPrintChar(decoded, false);
}
i += seq_len;
continue;
}
i += 1; // Incomplete UTF-8 at end.
continue;
}
// Continuation bytes (0x80-0xBF) — skip stray ones.
if (byte >= 0x80) {
i += 1;
continue;
}
// ASCII: feed through the Ghostty VT parser.
const actions = this.parser.next(byte);
for (&actions) |maybe_action| {
const action = maybe_action orelse continue;
this.handleAction(action, byte);
}
i += 1;
}
}
fn emitPrintChar(this: *TuiKeyReader, cp: u21, alt: bool) void {
var buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp);
if (len == 0) return;
this.emitKeypress(buf[0..len], buf[0..len], false, false, alt);
}
fn handleAction(this: *TuiKeyReader, action: Action, raw_byte: u8) void {
_ = raw_byte;
switch (action) {
.print => |cp| {
// Check if we're in SS3 mode (previous was ESC O).
if (this.flags.ss3_pending) {
this.flags.ss3_pending = false;
const name: []const u8 = switch (cp) {
'P' => "f1",
'Q' => "f2",
'R' => "f3",
'S' => "f4",
'A' => "up",
'B' => "down",
'C' => "right",
'D' => "left",
'H' => "home",
'F' => "end",
else => return,
};
this.emitKeypress(name, name, false, false, false);
return;
}
// Check if previous was a bare ESC (alt prefix).
if (this.flags.esc_pending) {
this.flags.esc_pending = false;
var buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp);
if (len == 0) return;
this.emitKeypress(buf[0..len], buf[0..len], false, false, true);
return;
}
// Printable character.
if (this.flags.in_paste) {
var buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp);
if (len == 0) return;
this.paste_buf.appendSlice(bun.default_allocator, buf[0..len]) catch {};
return;
}
var buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp);
if (len == 0) return;
this.emitKeypress(buf[0..len], buf[0..len], false, false, false);
},
.execute => |c| {
// If bare ESC was pending, consume it — this is just a control char after ESC.
if (this.flags.esc_pending) {
this.flags.esc_pending = false;
const result = executeToKey(c);
this.emitKeypress(result.name, &.{c}, result.ctrl, false, true);
return;
}
// C0 control character.
if (this.flags.in_paste) {
this.paste_buf.append(bun.default_allocator, c) catch {};
return;
}
const result = executeToKey(c);
this.emitKeypress(result.name, &.{c}, result.ctrl, false, false);
},
.csi_dispatch => |csi| {
// Clear any pending ESC state (CSI follows ESC [).
this.flags.esc_pending = false;
this.flags.ss3_pending = false;
this.handleCSI(csi);
},
.esc_dispatch => |esc| {
this.handleESC(esc);
},
else => {},
}
}
fn handleCSI(this: *TuiKeyReader, csi: Action.CSI) void {
// Check for bracketed paste start/end.
if (csi.final == '~' and csi.params.len >= 1) {
if (csi.params[0] == 200) {
this.flags.in_paste = true;
this.paste_buf.clearRetainingCapacity();
return;
}
if (csi.params[0] == 201) {
this.flags.in_paste = false;
this.emitPaste();
return;
}
}
if (this.flags.in_paste) return;
// Focus events: CSI I (focus in), CSI O (focus out).
if (csi.final == 'I' and csi.params.len == 0 and csi.intermediates.len == 0) {
this.emitFocus(true);
return;
}
if (csi.final == 'O' and csi.params.len == 0 and csi.intermediates.len == 0) {
this.emitFocus(false);
return;
}
// SGR mouse events: CSI < Pb ; Px ; Py M/m
const has_lt = for (csi.intermediates) |c| {
if (c == '<') break true;
} else false;
if (has_lt and (csi.final == 'M' or csi.final == 'm') and csi.params.len >= 3) {
this.emitMouse(csi.params[0], csi.params[1], csi.params[2], csi.final == 'm');
return;
}
// Extract modifier from parameter 2 (if present).
const modifier: u16 = if (csi.params.len >= 2) csi.params[1] else 0;
const ctrl = if (modifier > 0) (modifier -| 1) & 4 != 0 else false;
const alt = if (modifier > 0) (modifier -| 1) & 2 != 0 else false;
const shift = if (modifier > 0) (modifier -| 1) & 1 != 0 else false;
// Check for intermediates — '>' means xterm-style, '?' means DECRPM, etc.
const has_gt = for (csi.intermediates) |c| {
if (c == '>') break true;
} else false;
// Kitty protocol: CSI codepoint u / CSI codepoint;modifier u
if (csi.final == 'u' and !has_gt) {
if (csi.params.len >= 1 and csi.params[0] > 0 and csi.params[0] < 128) {
const name = ctrlName(@intCast(csi.params[0]));
this.emitKeypress(name, name, ctrl, shift, alt);
}
return;
}
const name: []const u8 = switch (csi.final) {
'A' => "up",
'B' => "down",
'C' => "right",
'D' => "left",
'H' => "home",
'F' => "end",
'P' => "f1",
'Q' => "f2",
'R' => "f3",
'S' => "f4",
'Z' => "tab", // shift+tab
'~' => tildeKey(csi.params),
else => return,
};
const is_shift_tab = csi.final == 'Z';
this.emitKeypress(name, name, ctrl, if (is_shift_tab) true else shift, alt);
}
fn handleESC(this: *TuiKeyReader, esc: Action.ESC) void {
if (this.flags.in_paste) return;
// SS3: ESC O — the final byte 'O' means the NEXT char is the SS3 payload.
if (esc.intermediates.len == 0 and esc.final == 'O') {
this.flags.ss3_pending = true;
return;
}
// Bare ESC dispatch with no intermediates — this is alt+char.
if (esc.intermediates.len == 0) {
const name = ctrlName(esc.final);
this.emitKeypress(name, name, false, false, true);
}
}
fn tildeKey(params: []const u16) []const u8 {
if (params.len == 0) return "~";
return switch (params[0]) {
1 => "home",
2 => "insert",
3 => "delete",
4 => "end",
5 => "pageup",
6 => "pagedown",
15 => "f5",
17 => "f6",
18 => "f7",
19 => "f8",
20 => "f9",
21 => "f10",
23 => "f11",
24 => "f12",
else => "~",
};
}
const KeyResult = struct {
name: []const u8,
ctrl: bool,
};
fn executeToKey(c: u8) KeyResult {
return switch (c) {
'\r', '\n' => .{ .name = "enter", .ctrl = false },
'\t' => .{ .name = "tab", .ctrl = false },
0x08 => .{ .name = "backspace", .ctrl = true },
0x7f => .{ .name = "backspace", .ctrl = false },
0x1b => .{ .name = "escape", .ctrl = false },
0x00 => .{ .name = "space", .ctrl = true },
// ctrl+a through ctrl+z, excluding \t (0x09), \n (0x0a), \r (0x0d)
0x01...0x07, 0x0b, 0x0c, 0x0e...0x1a => .{
.name = @as(*const [1]u8, @ptrCast(&ascii_table['a' + c - 1]))[0..1],
.ctrl = true,
},
// C1 control codes (0x80-0x9F) and other high bytes — ignore.
0x80...0xff => .{ .name = "", .ctrl = false },
else => .{ .name = @as(*const [1]u8, @ptrCast(&ascii_table[c]))[0..1], .ctrl = false },
};
}
fn ctrlName(c: u8) []const u8 {
if (c < 0x20 or c == 0x7f) return executeToKey(c).name;
if (c < 128) return @as(*const [1]u8, @ptrCast(&ascii_table[c]))[0..1];
return "";
}
// --- Event emission ---
/// Emit a mouse event to the JS callback.
fn emitMouse(this: *TuiKeyReader, button_code: u16, px: u16, py: u16, is_release: bool) void {
const callback = this.onmouse_callback.get() orelse return;
const globalThis = this.globalThis;
const event = jsc.JSValue.createEmptyObject(globalThis, 8);
// Decode SGR button code:
// bits 0-1: button (0=left, 1=middle, 2=right)
// bit 5: motion flag (32)
// bit 6: scroll flag (64)
// bits 2-4: modifiers (4=shift, 8=alt/meta, 16=ctrl)
const base_button = button_code & 0x03;
const is_motion = button_code & 32 != 0;
const is_scroll = button_code & 64 != 0;
const mod_shift = button_code & 4 != 0;
const mod_alt = button_code & 8 != 0;
const mod_ctrl = button_code & 16 != 0;
const event_type: []const u8 = if (is_scroll)
(if (base_button == 0) "scrollUp" else "scrollDown")
else if (is_release)
"up"
else if (is_motion)
(if (base_button == 3) "move" else "drag")
else
"down";
const button: i32 = if (is_scroll)
(if (base_button == 0) @as(i32, 4) else 5) // wheel up/down
else
@as(i32, @intCast(base_button));
event.put(globalThis, bun.String.static("type"), bun.String.createUTF8ForJS(globalThis, event_type) catch return);
event.put(globalThis, bun.String.static("button"), jsc.JSValue.jsNumber(button));
event.put(globalThis, bun.String.static("x"), jsc.JSValue.jsNumber(@as(i32, @intCast(px)) - 1)); // 1-based → 0-based
event.put(globalThis, bun.String.static("y"), jsc.JSValue.jsNumber(@as(i32, @intCast(py)) - 1)); // 1-based → 0-based
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));
globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event});
}
/// Emit a focus/blur event.
fn emitFocus(this: *TuiKeyReader, focused: bool) void {
const callback = if (focused)
this.onfocus_callback.get()
else
this.onblur_callback.get();
if (callback == null) return;
const globalThis = this.globalThis;
globalThis.bunVM().eventLoop().runCallback(callback.?, globalThis, .js_undefined, &.{});
}
fn emitKeypress(this: *TuiKeyReader, name: []const u8, sequence: []const u8, ctrl: bool, shift: bool, alt: bool) void {
if (name.len == 0) return;
const callback = this.onkeypress_callback.get() orelse return;
const globalThis = this.globalThis;
const event = jsc.JSValue.createEmptyObject(globalThis, 5);
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);
event.put(globalThis, bun.String.static("sequence"), seq_js);
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));
globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{event});
}
fn emitPaste(this: *TuiKeyReader) void {
const callback = this.onpaste_callback.get() orelse {
this.paste_buf.clearRetainingCapacity();
return;
};
const globalThis = this.globalThis;
const text = bun.String.createUTF8ForJS(globalThis, this.paste_buf.items) catch {
this.paste_buf.clearRetainingCapacity();
return;
};
this.paste_buf.clearRetainingCapacity();
globalThis.bunVM().eventLoop().runCallback(callback, globalThis, .js_undefined, &.{text});
}
/// Static table for printable ASCII character names.
const ascii_table: [128]u8 = blk: {
var table: [128]u8 = undefined;
for (0..128) |i| {
table[i] = @intCast(i);
}
break :blk table;
};
extern fn Bun__ttySetMode(fd: i32, mode: i32) i32;
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;

View File

@@ -0,0 +1,407 @@
//! TuiRenderer — pure ANSI frame builder.
//! Diffs a TuiScreen into ANSI escape sequences using Ghostty's Page/Cell
//! for previous-frame buffer and u64 cell comparison.
//! No fd, no event loop, no ref-counting — just appends bytes to a caller-provided buffer.
const TuiRenderer = @This();
const ghostty = @import("ghostty").terminal;
const Page = ghostty.page.Page;
const Cell = ghostty.Cell;
const Style = ghostty.Style;
const size = ghostty.size;
const TuiScreen = @import("./screen.zig");
pub const CursorStyle = enum {
default,
block,
underline,
line,
/// Return the DECSCUSR parameter for this cursor style.
fn decscusr(this: CursorStyle, blinking: bool) u8 {
return switch (this) {
.default => 0,
.block => if (blinking) @as(u8, 1) else 2,
.underline => if (blinking) @as(u8, 3) else 4,
.line => if (blinking) @as(u8, 5) else 6,
};
}
};
prev_page: ?Page = null,
cursor_x: size.CellCountInt = 0,
cursor_y: size.CellCountInt = 0,
current_style_id: size.StyleCountInt = 0,
current_hyperlink_id: u16 = 0,
has_rendered: bool = false,
prev_rows: size.CellCountInt = 0,
prev_hyperlink_ids: ?[]u16 = null,
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,
pub fn render(
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,
) void {
this.buf = buf;
this.emit(BSU);
const 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);
if (need_full) this.renderFull(screen) else this.renderDiff(screen);
if (cursor_x != null or cursor_y != null) {
this.moveTo(cursor_x orelse 0, cursor_y orelse 0);
}
if (cursor_visible) |visible| {
if (visible) {
this.emit("\x1b[?25h");
} else {
this.emit("\x1b[?25l");
}
}
// Emit DECSCUSR if cursor style or blinking changed.
const new_style = cursor_style orelse this.current_cursor_style;
const new_blink = cursor_blinking orelse this.current_cursor_blinking;
if (new_style != this.current_cursor_style or new_blink != this.current_cursor_blinking) {
const param = new_style.decscusr(new_blink);
var local_buf: [8]u8 = undefined;
const seq = std.fmt.bufPrint(&local_buf, "\x1b[{d} q", .{@as(u32, param)}) catch unreachable;
this.emit(seq);
this.current_cursor_style = new_style;
this.current_cursor_blinking = new_blink;
}
this.emit(ESU);
this.swapScreens(screen);
this.prev_rows = screen.page.size.rows;
this.has_rendered = true;
}
pub fn clear(this: *TuiRenderer) void {
if (this.prev_page) |*p| {
p.deinit();
this.prev_page = null;
}
this.cursor_x = 0;
this.cursor_y = 0;
this.current_style_id = 0;
this.current_hyperlink_id = 0;
this.has_rendered = false;
this.prev_rows = 0;
if (this.prev_hyperlink_ids) |ids| {
bun.default_allocator.free(ids);
this.prev_hyperlink_ids = null;
}
}
pub fn deinit(this: *TuiRenderer) void {
if (this.prev_page) |*p| p.deinit();
if (this.prev_hyperlink_ids) |ids| bun.default_allocator.free(ids);
}
// --- Rendering internals ---
fn renderFull(this: *TuiRenderer, screen: *const TuiScreen) void {
this.emit("\x1b[?25l");
if (this.has_rendered) {
if (this.cursor_y > 0) {
this.emitCSI(this.cursor_y, 'A');
}
this.emit("\r");
this.cursor_x = 0;
this.cursor_y = 0;
}
this.current_style_id = 0;
var y: size.CellCountInt = 0;
while (y < screen.page.size.rows) : (y += 1) {
if (y > 0) this.emit("\r\n");
const cells = screen.page.getRow(y).cells.ptr(screen.page.memory)[0..screen.page.size.cols];
var blank_cells: usize = 0;
var x: size.CellCountInt = 0;
while (x < screen.page.size.cols) : (x += 1) {
const cell = cells[x];
if (cell.wide == .spacer_tail) continue;
if (cell.isEmpty() and !cell.hasStyling()) {
blank_cells += 1;
continue;
}
if (cell.codepoint() == ' ' and !cell.hasStyling()) {
blank_cells += 1;
continue;
}
if (blank_cells > 0) {
var i: usize = 0;
while (i < blank_cells) : (i += 1) this.emit(" ");
blank_cells = 0;
}
this.transitionHyperlink(screen.hyperlinks.getId(x, y, screen.page.size.cols), screen);
this.transitionStyle(cell.style_id, &screen.page);
this.writeCell(cell, &screen.page);
}
if (this.current_hyperlink_id != 0) {
this.emit("\x1b]8;;\x1b\\");
this.current_hyperlink_id = 0;
}
this.emit("\x1b[K");
}
if (this.prev_rows > screen.page.size.rows) {
var extra = this.prev_rows - screen.page.size.rows;
while (extra > 0) : (extra -= 1) {
this.emit("\r\n\x1b[2K");
}
this.emitCSI(this.prev_rows - screen.page.size.rows, 'A');
}
this.emit("\x1b[0m\x1b[?25h");
this.current_style_id = 0;
this.cursor_x = screen.page.size.cols;
this.cursor_y = screen.page.size.rows -| 1;
}
fn renderDiff(this: *TuiRenderer, screen: *const TuiScreen) void {
const prev = &(this.prev_page orelse return);
var y: size.CellCountInt = 0;
while (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) {
const cell = cells[x];
if (cell.wide == .spacer_tail) continue;
const cur_hid = screen.hyperlinks.getId(x, y, screen.page.size.cols);
const prev_hid = if (this.prev_hyperlink_ids) |pids| blk: {
const idx = @as(usize, y) * @as(usize, screen.page.size.cols) + @as(usize, x);
break :blk if (idx < pids.len) pids[idx] else @as(u16, 0);
} else @as(u16, 0);
if (@as(u64, @bitCast(cell)) == @as(u64, @bitCast(prev_cells[x])) and cur_hid == prev_hid) continue;
if (x != this.cursor_x or y != this.cursor_y) this.moveTo(x, y);
this.transitionHyperlink(cur_hid, screen);
this.transitionStyle(cell.style_id, &screen.page);
this.writeCell(cell, &screen.page);
this.cursor_x = x + if (cell.wide == .wide) @as(size.CellCountInt, 2) else @as(size.CellCountInt, 1);
this.cursor_y = y;
}
}
}
fn swapScreens(this: *TuiRenderer, screen: *const TuiScreen) void {
if (this.prev_page) |*p| {
if (p.size.cols != screen.page.size.cols or p.size.rows != screen.page.size.rows) {
p.deinit();
this.prev_page = null;
}
}
if (this.prev_page == null) {
this.prev_page = Page.init(.{ .cols = screen.page.size.cols, .rows = screen.page.size.rows, .styles = 256 }) catch return;
}
var prev = &(this.prev_page orelse return);
var y: size.CellCountInt = 0;
while (y < screen.page.size.rows) : (y += 1) {
const src_row = screen.page.getRow(y);
const src_cells = src_row.cells.ptr(screen.page.memory)[0..screen.page.size.cols];
const dst_row = prev.getRow(y);
const dst_cells = dst_row.cells.ptr(prev.memory)[0..prev.size.cols];
@memcpy(dst_cells[0..screen.page.size.cols], src_cells);
src_row.dirty = false;
dst_row.dirty = false;
}
if (screen.hyperlinks.ids) |src_ids| {
const count = @as(usize, screen.page.size.cols) * @as(usize, screen.page.size.rows);
if (this.prev_hyperlink_ids) |prev_ids| {
if (prev_ids.len != count) {
bun.default_allocator.free(prev_ids);
this.prev_hyperlink_ids = bun.default_allocator.alloc(u16, count) catch null;
}
} else {
this.prev_hyperlink_ids = bun.default_allocator.alloc(u16, count) catch null;
}
if (this.prev_hyperlink_ids) |prev_ids| {
@memcpy(prev_ids[0..count], src_ids[0..count]);
}
} else {
if (this.prev_hyperlink_ids) |prev_ids| {
@memset(prev_ids, 0);
}
}
}
// --- ANSI emission helpers ---
fn moveTo(this: *TuiRenderer, x: size.CellCountInt, y: size.CellCountInt) void {
if (y > this.cursor_y) {
this.emitCSI(y - this.cursor_y, 'B');
} else if (y < this.cursor_y) {
this.emitCSI(this.cursor_y - y, 'A');
}
if (x != this.cursor_x or y != this.cursor_y) {
this.emit("\r");
if (x > 0) this.emitCSI(x, 'C');
}
this.cursor_x = x;
this.cursor_y = y;
}
fn emitCSI(this: *TuiRenderer, n: anytype, code: u8) void {
var local_buf: [16]u8 = undefined;
const seq = std.fmt.bufPrint(&local_buf, "\x1b[{d}{c}", .{ @as(u32, @intCast(n)), code }) catch return;
this.emit(seq);
}
fn transitionHyperlink(this: *TuiRenderer, new_id: u16, screen: *const TuiScreen) void {
if (new_id == this.current_hyperlink_id) return;
if (new_id == 0) {
this.emit("\x1b]8;;\x1b\\");
} else {
if (screen.hyperlinks.getUrl(new_id)) |url| {
this.emit("\x1b]8;;");
this.emit(url);
this.emit("\x1b\\");
}
}
this.current_hyperlink_id = new_id;
}
fn transitionStyle(this: *TuiRenderer, new_id: size.StyleCountInt, page: *const Page) void {
if (new_id == this.current_style_id) return;
if (new_id == 0) {
this.emit("\x1b[0m");
this.current_style_id = 0;
return;
}
const s = page.styles.get(page.memory, new_id);
this.emit("\x1b[0m");
this.emitStyleSGR(s);
this.current_style_id = new_id;
}
fn emitStyleSGR(this: *TuiRenderer, s: *const Style) void {
if (s.flags.bold) this.emit("\x1b[1m");
if (s.flags.faint) this.emit("\x1b[2m");
if (s.flags.italic) this.emit("\x1b[3m");
switch (s.flags.underline) {
.none => {},
.single => this.emit("\x1b[4m"),
.double => this.emit("\x1b[4:2m"),
.curly => this.emit("\x1b[4:3m"),
.dotted => this.emit("\x1b[4:4m"),
.dashed => this.emit("\x1b[4:5m"),
}
if (s.flags.blink) this.emit("\x1b[5m");
if (s.flags.inverse) this.emit("\x1b[7m");
if (s.flags.invisible) this.emit("\x1b[8m");
if (s.flags.strikethrough) this.emit("\x1b[9m");
if (s.flags.overline) this.emit("\x1b[53m");
this.emitColor(s.fg_color, false);
this.emitColor(s.bg_color, true);
this.emitUnderlineColor(s.underline_color);
}
fn emitColor(this: *TuiRenderer, color: Style.Color, is_bg: bool) void {
const base: u8 = if (is_bg) 48 else 38;
switch (color) {
.none => {},
.palette => |idx| {
var local_buf: [16]u8 = undefined;
this.emit(std.fmt.bufPrint(&local_buf, "\x1b[{d};5;{d}m", .{ base, idx }) catch return);
},
.rgb => |rgb| {
var local_buf: [24]u8 = undefined;
this.emit(std.fmt.bufPrint(&local_buf, "\x1b[{d};2;{d};{d};{d}m", .{ base, rgb.r, rgb.g, rgb.b }) catch return);
},
}
}
fn emitUnderlineColor(this: *TuiRenderer, color: Style.Color) void {
switch (color) {
.none => {},
.palette => |idx| {
var local_buf: [16]u8 = undefined;
this.emit(std.fmt.bufPrint(&local_buf, "\x1b[58;5;{d}m", .{idx}) catch return);
},
.rgb => |rgb| {
var local_buf: [24]u8 = undefined;
this.emit(std.fmt.bufPrint(&local_buf, "\x1b[58;2;{d};{d};{d}m", .{ rgb.r, rgb.g, rgb.b }) catch return);
},
}
}
fn writeCell(this: *TuiRenderer, cell: Cell, page: *const Page) void {
switch (cell.content_tag) {
.codepoint => {
if (!cell.hasText()) {
this.emit(" ");
return;
}
var local_buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&local_buf, u21, cell.content.codepoint);
if (len == 0) {
this.emit(" ");
return;
}
this.emit(local_buf[0..len]);
},
.codepoint_grapheme => {
if (!cell.hasText()) {
this.emit(" ");
return;
}
var local_buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&local_buf, u21, cell.content.codepoint);
if (len == 0) {
this.emit(" ");
return;
}
this.emit(local_buf[0..len]);
if (page.lookupGrapheme(&cell)) |graphemes| {
for (graphemes) |gcp| {
const glen = bun.strings.encodeWTF8RuneT(&local_buf, u21, gcp);
if (glen > 0) this.emit(local_buf[0..glen]);
}
}
},
.bg_color_palette, .bg_color_rgb => this.emit(" "),
}
}
fn emit(this: *TuiRenderer, data: []const u8) void {
this.buf.appendSlice(bun.default_allocator, data) catch {};
}
const BSU = "\x1b[?2026h";
const ESU = "\x1b[?2026l";
const bun = @import("bun");
const std = @import("std");

View File

@@ -0,0 +1,794 @@
//! TuiScreen — a grid of styled cells for TUI rendering.
//! Wraps Ghostty's Page/Row/Cell/StyleSet directly.
const TuiScreen = @This();
const ghostty = @import("ghostty").terminal;
const Page = ghostty.page.Page;
const Cell = ghostty.Cell;
const Style = ghostty.Style;
const size = ghostty.size;
const sgr = ghostty.sgr;
pub const js = jsc.Codegen.JSTuiScreen;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
const ClipRect = struct { x1: size.CellCountInt, y1: size.CellCountInt, x2: size.CellCountInt, y2: size.CellCountInt };
/// Manages hyperlink URLs and a per-cell ID mapping.
pub const HyperlinkPool = struct {
/// Parallel array indexed by (y * cols + x), lazily allocated. 0 = no hyperlink.
ids: ?[]u16 = null,
/// Interned URL strings, indexed by (id - 1).
urls: std.ArrayListUnmanaged([]const u8) = .{},
/// URL string → ID for deduplication.
map: std.StringHashMapUnmanaged(u16) = .{},
/// Next ID to assign (starts at 1; 0 is reserved for "no hyperlink").
next_id: u16 = 1,
/// Intern a URL string. Returns an existing ID if the URL was already interned.
pub fn intern(self: *HyperlinkPool, url: []const u8) error{OutOfMemory}!u16 {
if (self.map.get(url)) |existing_id| return existing_id;
const id = self.next_id;
self.next_id +%= 1;
const owned_url = try bun.default_allocator.dupe(u8, url);
errdefer bun.default_allocator.free(owned_url);
try self.urls.append(bun.default_allocator, owned_url);
errdefer _ = self.urls.pop();
try self.map.put(bun.default_allocator, owned_url, id);
return id;
}
/// Look up the URL for a given hyperlink ID (1-based). Returns null for ID 0.
pub fn getUrl(self: *const HyperlinkPool, id: u16) ?[]const u8 {
if (id == 0) return null;
const idx = id - 1;
if (idx >= self.urls.items.len) return null;
return self.urls.items[idx];
}
/// Get the hyperlink ID for a cell at (x, y) given grid width `cols`.
pub fn getId(self: *const HyperlinkPool, x: size.CellCountInt, y: size.CellCountInt, cols: size.CellCountInt) u16 {
const cell_ids = self.ids orelse return 0;
const idx = @as(usize, y) * @as(usize, cols) + @as(usize, x);
if (idx >= cell_ids.len) return 0;
return cell_ids[idx];
}
/// Set the hyperlink ID for a cell at (x, y) given grid width `cols`.
/// Lazily allocates the ID array on first use.
pub fn setId(self: *HyperlinkPool, x: size.CellCountInt, y: size.CellCountInt, cols: size.CellCountInt, rows: size.CellCountInt, hid: u16) error{OutOfMemory}!void {
const cell_ids = try self.ensureIds(cols, rows);
const idx = @as(usize, y) * @as(usize, cols) + @as(usize, x);
if (idx < cell_ids.len) cell_ids[idx] = hid;
}
/// Ensure the per-cell ID array is allocated for the given grid dimensions.
fn ensureIds(self: *HyperlinkPool, cols: size.CellCountInt, rows: size.CellCountInt) error{OutOfMemory}![]u16 {
if (self.ids) |existing| return existing;
const count = @as(usize, cols) * @as(usize, rows);
const cell_ids = try bun.default_allocator.alloc(u16, count);
@memset(cell_ids, 0);
self.ids = cell_ids;
return cell_ids;
}
/// Zero all per-cell IDs (e.g. on screen clear). Does not free the array.
pub fn clearIds(self: *HyperlinkPool) void {
if (self.ids) |cell_ids| @memset(cell_ids, 0);
}
/// Free the per-cell ID array (e.g. on resize where dimensions change).
pub fn freeIds(self: *HyperlinkPool) void {
if (self.ids) |cell_ids| bun.default_allocator.free(cell_ids);
self.ids = null;
}
/// Release all resources.
pub fn deinit(self: *HyperlinkPool) void {
self.freeIds();
for (self.urls.items) |url| bun.default_allocator.free(url);
self.urls.deinit(bun.default_allocator);
self.map.deinit(bun.default_allocator);
self.* = .{};
}
};
page: Page,
clip_stack: [8]ClipRect = undefined,
clip_depth: u8 = 0,
hyperlinks: HyperlinkPool = .{},
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiScreen {
const arguments = callframe.arguments();
if (arguments.len < 2) return globalThis.throw("Screen requires (cols, rows) arguments", .{});
if (!arguments[0].isNumber() or !arguments[1].isNumber())
return globalThis.throw("Screen requires numeric cols and rows", .{});
const cols: size.CellCountInt = @intCast(@max(1, @min((try arguments[0].coerce(i32, globalThis)), 4096)));
const rows: size.CellCountInt = @intCast(@max(1, @min((try arguments[1].coerce(i32, globalThis)), 4096)));
const page = Page.init(.{ .cols = cols, .rows = rows, .styles = 256 }) catch {
return globalThis.throw("Failed to allocate Screen", .{});
};
return bun.new(TuiScreen, .{ .page = page });
}
pub fn finalize(this: *TuiScreen) callconv(.c) void {
this.hyperlinks.deinit();
this.page.deinit();
bun.destroy(this);
}
pub fn estimatedSize(this: *const TuiScreen) usize {
return this.page.memory.len;
}
fn getCols(self: *const TuiScreen) size.CellCountInt {
return self.page.size.cols;
}
fn getRows(self: *const TuiScreen) size.CellCountInt {
return self.page.size.rows;
}
fn getRowCells(self: *const TuiScreen, y: usize) struct { row: *ghostty.page.Row, cells: []Cell } {
const row = self.page.getRow(y);
const cells = row.cells.ptr(self.page.memory)[0..self.page.size.cols];
return .{ .row = row, .cells = cells };
}
/// setText(x, y, text, styleId)
pub fn setText(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 3) return globalThis.throw("setText requires (x, y, text[, styleId])", .{});
const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols() -| 1)));
const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows() -| 1)));
if (!arguments[2].isString()) return globalThis.throw("setText: text must be a string", .{});
const sid: size.StyleCountInt = if (arguments.len > 3 and arguments[3].isNumber())
@intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt))))
else
0;
// Apply clipping: for setText, clip restricts which row+col range is writable
const start_x = raw_x;
const y = raw_y;
var clip_max_col: size.CellCountInt = this.getCols();
if (this.clip_depth > 0) {
const cr = this.clip_stack[this.clip_depth - 1];
if (y < cr.y1 or y >= cr.y2 or start_x >= cr.x2) return jsc.JSValue.jsNumber(@as(i32, 0));
clip_max_col = cr.x2;
}
const str = try arguments[2].toSliceClone(globalThis);
defer str.deinit();
const text = str.slice();
const rc = this.getRowCells(y);
const row = rc.row;
const cells = rc.cells;
var col = start_x;
var i: usize = 0;
const cols = clip_max_col;
// Fast path: blast ASCII directly into cells — no per-codepoint decode
const first_non_ascii = bun.strings.firstNonASCII(text) orelse @as(u32, @intCast(text.len));
const ascii_end: usize = @min(first_non_ascii, cols -| col);
if (ascii_end > 0) {
for (text[0..ascii_end]) |byte| {
if (col >= cols) break;
cells[col] = .{
.content_tag = .codepoint,
.content = .{ .codepoint = byte },
.style_id = sid,
.wide = .narrow,
};
col += 1;
}
i = ascii_end;
row.dirty = true;
if (sid != 0) row.styled = true;
}
// Slow path: non-ASCII — decode codepoints, handle width/graphemes
var after_zwj = false;
while (i < text.len and col < cols) {
const cp_len = bun.strings.utf8ByteSequenceLength(text[i]);
if (cp_len == 0) {
i += 1;
continue;
}
if (i + cp_len > text.len) break;
var bytes = [4]u8{ text[i], 0, 0, 0 };
if (cp_len > 1) bytes[1] = text[i + 1];
if (cp_len > 2) bytes[2] = text[i + 2];
if (cp_len > 3) bytes[3] = text[i + 3];
const cp = bun.strings.decodeWTF8RuneT(&bytes, cp_len, u21, 0xFFFD);
if (cp == 0xFFFD and cp_len > 1) {
i += cp_len;
continue;
}
const width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(cp), false), 2));
if (width == 0) {
// Zero-width codepoint: append as grapheme extension to the
// preceding content cell (walk back past any spacer_tails).
if (col > start_x) {
const target = graphemeTarget(cells, col, start_x);
this.page.appendGrapheme(row, &cells[target], @intCast(cp)) catch {};
row.dirty = true;
}
after_zwj = (cp == 0x200D);
i += cp_len;
continue;
}
// After a ZWJ, the next codepoint is a grapheme continuation
// regardless of its own width (e.g. family sequence).
if (after_zwj) {
after_zwj = false;
if (col > start_x) {
const target = graphemeTarget(cells, col, start_x);
this.page.appendGrapheme(row, &cells[target], @intCast(cp)) catch {};
row.dirty = true;
}
i += cp_len;
continue;
}
if (width == 2 and col + 1 >= cols) break;
cells[col] = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(cp) },
.style_id = sid,
.wide = if (width == 2) .wide else .narrow,
};
row.dirty = true;
if (sid != 0) row.styled = true;
col += 1;
if (width == 2 and col < cols) {
cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail };
col += 1;
}
i += cp_len;
}
return jsc.JSValue.jsNumber(@as(i32, @intCast(col - start_x)));
}
/// 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();
if (arguments.len < 1 or !arguments[0].isObject()) return globalThis.throw("style requires an options object", .{});
const opts = arguments[0];
var s = Style{};
if (try opts.getTruthy(globalThis, "fg")) |v| s.fg_color = try parseColor(globalThis, v);
if (try opts.getTruthy(globalThis, "bg")) |v| s.bg_color = try parseColor(globalThis, v);
if (try opts.getTruthy(globalThis, "underlineColor")) |v| s.underline_color = try parseColor(globalThis, v);
// Build flags by setting individual fields
if (try opts.getTruthy(globalThis, "bold")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.bold = true;
};
if (try opts.getTruthy(globalThis, "italic")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.italic = true;
};
if (try opts.getTruthy(globalThis, "faint")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.faint = true;
};
if (try opts.getTruthy(globalThis, "blink")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.blink = true;
};
if (try opts.getTruthy(globalThis, "inverse")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.inverse = true;
};
if (try opts.getTruthy(globalThis, "invisible")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.invisible = true;
};
if (try opts.getTruthy(globalThis, "strikethrough")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.strikethrough = true;
};
if (try opts.getTruthy(globalThis, "overline")) |v| if (v.isBoolean() and v.asBoolean()) {
s.flags.overline = true;
};
if (try opts.getTruthy(globalThis, "underline")) |v| {
if (v.isString()) {
const ul_str = try v.toSliceClone(globalThis);
defer ul_str.deinit();
const UnderlineStyle = @TypeOf(s.flags.underline);
s.flags.underline = bun.ComptimeEnumMap(UnderlineStyle).get(ul_str.slice()) orelse .none;
} else if (v.isBoolean() and v.asBoolean()) {
s.flags.underline = .single;
}
}
// Default style (no flags, no colors) is always ID 0.
if (s.default()) return jsc.JSValue.jsNumber(@as(i32, 0));
const id = this.page.styles.add(this.page.memory, s) catch {
return globalThis.throw("Failed to intern style: style set full", .{});
};
return jsc.JSValue.jsNumber(@as(i32, @intCast(id)));
}
/// clearRect(x, y, w, h)
pub fn clearRect(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 4) return globalThis.throw("clearRect requires (x, y, w, h)", .{});
const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols())));
const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows())));
const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x)));
const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y)));
const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined;
var row_idx = clipped.y;
while (row_idx < clipped.y +| clipped.h) : (row_idx += 1) {
const row = this.page.getRow(row_idx);
this.page.clearCells(row, clipped.x, clipped.x +| clipped.w);
row.dirty = true;
}
return .js_undefined;
}
/// fill(x, y, w, h, char, styleId)
pub fn fill(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 5) return globalThis.throw("fill requires (x, y, w, h, char[, styleId])", .{});
const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols())));
const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows())));
const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x)));
const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y)));
var fill_cp: u21 = ' ';
if (arguments[4].isString()) {
const cs = try arguments[4].toSliceClone(globalThis);
defer cs.deinit();
const s = cs.slice();
if (s.len > 0) {
const cl = bun.strings.utf8ByteSequenceLength(s[0]);
if (cl == 0) {
fill_cp = ' ';
} else {
var bytes = [4]u8{ s[0], 0, 0, 0 };
if (cl > 1 and s.len > 1) bytes[1] = s[1];
if (cl > 2 and s.len > 2) bytes[2] = s[2];
if (cl > 3 and s.len > 3) bytes[3] = s[3];
fill_cp = bun.strings.decodeWTF8RuneT(&bytes, cl, u21, ' ');
}
}
} else if (arguments[4].isNumber()) {
fill_cp = @intCast(@max(0, @min((try arguments[4].coerce(i32, globalThis)), 0x10FFFF)));
}
const sid: size.StyleCountInt = if (arguments.len > 5 and arguments[5].isNumber())
@intCast(@max(0, @min((try arguments[5].coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt))))
else
0;
const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined;
const fill_width: u2 = @intCast(@min(bun.strings.visibleCodepointWidth(@intCast(fill_cp), false), 2));
const end_x = clipped.x +| clipped.w;
var row_idx = clipped.y;
while (row_idx < clipped.y +| clipped.h) : (row_idx += 1) {
const rc = this.getRowCells(row_idx);
var col = clipped.x;
while (col < end_x) {
if (fill_width == 2) {
if (col + 1 >= end_x) break; // wide char doesn't fit in remaining space
rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_cp }, .style_id = sid, .wide = .wide };
rc.cells[col + 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = ' ' }, .style_id = sid, .wide = .spacer_tail };
col += 2;
} else {
rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_cp }, .style_id = sid, .wide = .narrow };
col += 1;
}
}
rc.row.dirty = true;
if (sid != 0) rc.row.styled = true;
}
return .js_undefined;
}
/// copy(srcScreen, sx, sy, dx, dy, w, h)
pub fn copy(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 7) return globalThis.throw("copy requires (srcScreen, sx, sy, dx, dy, w, h)", .{});
const src = TuiScreen.fromJS(arguments[0]) orelse return globalThis.throw("copy: first argument must be a Screen", .{});
const sx: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), src.getCols())));
const sy: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), src.getRows())));
const raw_dx: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getCols())));
const raw_dy: size.CellCountInt = @intCast(@max(0, @min((try arguments[4].coerce(i32, globalThis)), this.getRows())));
const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[5].coerce(i32, globalThis)), @min(src.getCols() -| sx, this.getCols() -| raw_dx))));
const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[6].coerce(i32, globalThis)), @min(src.getRows() -| sy, this.getRows() -| raw_dy))));
const clipped = this.applyClip(raw_dx, raw_dy, raw_w, raw_h) orelse return .js_undefined;
// Adjust source offset based on how much the destination was shifted by clipping
const src_x_off = clipped.x -| raw_dx;
const src_y_off = clipped.y -| raw_dy;
var off: size.CellCountInt = 0;
while (off < clipped.h) : (off += 1) {
const src_cells = src.getRowCells(sy +| src_y_off +| off).cells;
const dst = this.getRowCells(clipped.y +| off);
@memcpy(dst.cells[clipped.x..][0..clipped.w], src_cells[sx +| src_x_off..][0..clipped.w]);
dst.row.dirty = true;
}
return .js_undefined;
}
/// resize(cols, rows)
pub fn resize(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 2) return globalThis.throw("resize requires (cols, rows)", .{});
const nc: size.CellCountInt = @intCast(@max(1, @min((try arguments[0].coerce(i32, globalThis)), 4096)));
const nr: size.CellCountInt = @intCast(@max(1, @min((try arguments[1].coerce(i32, globalThis)), 4096)));
if (nc == this.getCols() and nr == this.getRows()) return .js_undefined;
var new_page = Page.init(.{ .cols = nc, .rows = nr, .styles = 256 }) catch {
return globalThis.throw("Failed to resize Screen", .{});
};
const cc = @min(this.getCols(), nc);
const cr = @min(this.getRows(), nr);
var ri: size.CellCountInt = 0;
while (ri < cr) : (ri += 1) {
const src_cells = this.getRowCells(ri).cells;
const dst_row = new_page.getRow(ri);
const dst_cells = dst_row.cells.ptr(new_page.memory)[0..new_page.size.cols];
@memcpy(dst_cells[0..cc], src_cells[0..cc]);
dst_row.dirty = true;
}
this.page.deinit();
this.page = new_page;
// Free hyperlink ID array (it's sized to old dimensions)
this.hyperlinks.freeIds();
return .js_undefined;
}
/// clear()
pub fn clear(this: *TuiScreen, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
var ri: size.CellCountInt = 0;
while (ri < this.getRows()) : (ri += 1) {
const row = this.page.getRow(ri);
this.page.clearCells(row, 0, this.getCols());
row.dirty = true;
}
this.hyperlinks.clearIds();
return .js_undefined;
}
/// hyperlink(url) → hyperlinkId — interns a URL and returns its ID
pub fn hyperlink(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 1 or !arguments[0].isString()) return globalThis.throw("hyperlink requires a URL string", .{});
const url_str = try arguments[0].toSliceClone(globalThis);
defer url_str.deinit();
const id = try this.hyperlinks.intern(url_str.slice());
return jsc.JSValue.jsNumber(@as(i32, @intCast(id)));
}
/// setHyperlink(x, y, hyperlinkId) — set the hyperlink ID for a cell
pub fn setHyperlink(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 3) return globalThis.throw("setHyperlink requires (x, y, hyperlinkId)", .{});
const x = (try arguments[0].coerce(i32, globalThis));
const y = (try arguments[1].coerce(i32, globalThis));
if (x < 0 or x >= this.getCols() or y < 0 or y >= this.getRows()) return .js_undefined;
const hid: u16 = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), std.math.maxInt(u16))));
try this.hyperlinks.setId(@intCast(x), @intCast(y), this.getCols(), this.getRows(), hid);
this.page.getRow(@intCast(y)).dirty = true;
return .js_undefined;
}
/// clip(x1, y1, x2, y2) — push a clipping rectangle onto the stack
pub fn clip(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 4) return globalThis.throw("clip requires (x1, y1, x2, y2)", .{});
if (this.clip_depth >= 8) return globalThis.throw("clip stack overflow (max 8)", .{});
const cols = this.getCols();
const rows = this.getRows();
const x1: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), cols)));
const y1: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), rows)));
const x2: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), cols)));
const y2: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), rows)));
this.clip_stack[this.clip_depth] = .{ .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2 };
this.clip_depth += 1;
return .js_undefined;
}
/// unclip() — pop the clipping stack
pub fn unclip(this: *TuiScreen, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.clip_depth > 0) this.clip_depth -= 1;
return .js_undefined;
}
/// Apply the active clip rect to a region, returning the clamped bounds.
/// Returns null if the region is entirely outside the clip rect.
fn applyClip(this: *const TuiScreen, x: size.CellCountInt, y: size.CellCountInt, w: size.CellCountInt, h: size.CellCountInt) ?struct { x: size.CellCountInt, y: size.CellCountInt, w: size.CellCountInt, h: size.CellCountInt } {
if (this.clip_depth == 0) return .{ .x = x, .y = y, .w = w, .h = h };
const cr = this.clip_stack[this.clip_depth - 1];
const cx1 = @max(x, cr.x1);
const cy1 = @max(y, cr.y1);
const cx2 = @min(x +| w, cr.x2);
const cy2 = @min(y +| h, cr.y2);
if (cx1 >= cx2 or cy1 >= cy2) return null;
return .{ .x = cx1, .y = cy1, .w = cx2 -| cx1, .h = cy2 -| cy1 };
}
pub fn getWidth(this: *const TuiScreen, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.getCols())));
}
pub fn getHeight(this: *const TuiScreen, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.getRows())));
}
/// getCell(x, y) → { char, styleId, wide }
pub fn getCell(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 2) return globalThis.throw("getCell requires (x, y)", .{});
const x = (try arguments[0].coerce(i32, globalThis));
const y = (try arguments[1].coerce(i32, globalThis));
if (x < 0 or x >= this.getCols() or y < 0 or y >= this.getRows()) return .null;
const cell = this.getRowCells(@intCast(y)).cells[@intCast(x)];
const result = jsc.JSValue.createEmptyObject(globalThis, 3);
if (cell.content_tag == .codepoint or cell.content_tag == .codepoint_grapheme) {
const cp: u21 = @intCast(cell.content.codepoint);
if (cp == 0) {
result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " "));
} else {
var buf: [4]u8 = undefined;
const len = bun.strings.encodeWTF8RuneT(&buf, u21, cp);
if (len > 0) {
result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, buf[0..len]));
} else {
result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " "));
}
}
} else {
result.put(globalThis, bun.String.static("char"), try bun.String.createUTF8ForJS(globalThis, " "));
}
result.put(globalThis, bun.String.static("styleId"), jsc.JSValue.jsNumber(@as(i32, @intCast(cell.style_id))));
result.put(globalThis, bun.String.static("wide"), jsc.JSValue.jsNumber(@as(i32, @intFromEnum(cell.wide))));
return result;
}
/// drawBox(x, y, w, h, options?) — draw a bordered box.
/// Options: { style: "single"|"double"|"rounded"|"heavy", styleId, fill, fillChar }
pub fn drawBox(this: *TuiScreen, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments();
if (arguments.len < 4) return globalThis.throw("drawBox requires (x, y, w, h[, options])", .{});
const raw_x: size.CellCountInt = @intCast(@max(0, @min((try arguments[0].coerce(i32, globalThis)), this.getCols())));
const raw_y: size.CellCountInt = @intCast(@max(0, @min((try arguments[1].coerce(i32, globalThis)), this.getRows())));
const raw_w: size.CellCountInt = @intCast(@max(0, @min((try arguments[2].coerce(i32, globalThis)), this.getCols() -| raw_x)));
const raw_h: size.CellCountInt = @intCast(@max(0, @min((try arguments[3].coerce(i32, globalThis)), this.getRows() -| raw_y)));
if (raw_w < 2 or raw_h < 2) return .js_undefined;
var border_chars = BoxChars.single;
var sid: size.StyleCountInt = 0;
var do_fill = false;
var fill_char: u21 = ' ';
if (arguments.len > 4 and arguments[4].isObject()) {
const opts = arguments[4];
if (try opts.getTruthy(globalThis, "style")) |v| {
if (v.isString()) {
const s = try v.toSliceClone(globalThis);
defer s.deinit();
border_chars = BoxChars.fromName(s.slice());
}
}
if (try opts.getTruthy(globalThis, "styleId")) |v| {
if (v.isNumber()) sid = @intCast(@max(0, @min((try v.coerce(i32, globalThis)), std.math.maxInt(size.StyleCountInt))));
}
if (try opts.getTruthy(globalThis, "fill")) |v| {
if (v.isBoolean()) do_fill = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "fillChar")) |v| {
if (v.isString()) {
const cs = try v.toSliceClone(globalThis);
defer cs.deinit();
const fc = cs.slice();
if (fc.len > 0) {
const cl = bun.strings.utf8ByteSequenceLength(fc[0]);
if (cl > 0) {
var bytes = [4]u8{ fc[0], 0, 0, 0 };
if (cl > 1 and fc.len > 1) bytes[1] = fc[1];
if (cl > 2 and fc.len > 2) bytes[2] = fc[2];
if (cl > 3 and fc.len > 3) bytes[3] = fc[3];
fill_char = bun.strings.decodeWTF8RuneT(&bytes, cl, u21, ' ');
}
}
}
}
}
const clipped = this.applyClip(raw_x, raw_y, raw_w, raw_h) orelse return .js_undefined;
const x = clipped.x;
const y = clipped.y;
const w = clipped.w;
const h = clipped.h;
const x2 = x +| w;
const y2 = y +| h;
const orig_x2 = raw_x +| raw_w;
const orig_y2 = raw_y +| raw_h;
// Top border
if (y == raw_y) {
const rc = this.getRowCells(y);
if (x == raw_x) {
rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.tl }, .style_id = sid, .wide = .narrow };
}
var col = @max(x, raw_x + 1);
while (col < @min(x2, orig_x2 -| 1)) : (col += 1) {
rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.h }, .style_id = sid, .wide = .narrow };
}
if (x2 == orig_x2 and x2 > x) {
rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.tr }, .style_id = sid, .wide = .narrow };
}
rc.row.dirty = true;
if (sid != 0) rc.row.styled = true;
}
// Bottom border
if (y2 == orig_y2 and y2 > y) {
const rc = this.getRowCells(y2 - 1);
if (x == raw_x) {
rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.bl }, .style_id = sid, .wide = .narrow };
}
var col = @max(x, raw_x + 1);
while (col < @min(x2, orig_x2 -| 1)) : (col += 1) {
rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.h }, .style_id = sid, .wide = .narrow };
}
if (x2 == orig_x2 and x2 > x) {
rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.br }, .style_id = sid, .wide = .narrow };
}
rc.row.dirty = true;
if (sid != 0) rc.row.styled = true;
}
// Side borders and optional fill
const row_start = if (y == raw_y) y + 1 else y;
const row_end = if (y2 == orig_y2 and y2 > y) y2 - 1 else y2;
var row_idx = row_start;
while (row_idx < row_end) : (row_idx += 1) {
const rc = this.getRowCells(row_idx);
if (x == raw_x) {
rc.cells[x] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.v }, .style_id = sid, .wide = .narrow };
}
if (x2 == orig_x2 and x2 > x) {
rc.cells[x2 - 1] = .{ .content_tag = .codepoint, .content = .{ .codepoint = border_chars.v }, .style_id = sid, .wide = .narrow };
}
if (do_fill) {
const fill_start = @max(x, raw_x + 1);
const fill_end = @min(x2, orig_x2 -| 1);
var col = fill_start;
while (col < fill_end) : (col += 1) {
rc.cells[col] = .{ .content_tag = .codepoint, .content = .{ .codepoint = fill_char }, .style_id = sid, .wide = .narrow };
}
}
rc.row.dirty = true;
if (sid != 0) rc.row.styled = true;
}
return .js_undefined;
}
/// Box drawing character sets.
const BoxChars = struct {
tl: u21,
tr: u21,
bl: u21,
br: u21,
h: u21,
v: u21,
const single = BoxChars{ .tl = '┌', .tr = '┐', .bl = '└', .br = '┘', .h = '─', .v = '│' };
const double = BoxChars{ .tl = '╔', .tr = '╗', .bl = '╚', .br = '╝', .h = '═', .v = '║' };
const rounded = BoxChars{ .tl = '╭', .tr = '╮', .bl = '╰', .br = '╯', .h = '─', .v = '│' };
const heavy = BoxChars{ .tl = '┏', .tr = '┓', .bl = '┗', .br = '┛', .h = '━', .v = '┃' };
const ascii = BoxChars{ .tl = '+', .tr = '+', .bl = '+', .br = '+', .h = '-', .v = '|' };
fn fromName(name: []const u8) BoxChars {
if (bun.strings.eqlComptime(name, "double")) return double;
if (bun.strings.eqlComptime(name, "rounded")) return rounded;
if (bun.strings.eqlComptime(name, "heavy")) return heavy;
if (bun.strings.eqlComptime(name, "ascii")) return ascii;
return single;
}
};
/// Walk back from `col` past spacer_tail cells to find the content cell
/// that should receive grapheme extensions.
fn graphemeTarget(cells: []Cell, col: size.CellCountInt, start_x: size.CellCountInt) size.CellCountInt {
var target = col - 1;
while (target > start_x and cells[target].wide == .spacer_tail) {
target -= 1;
}
return target;
}
fn parseColor(globalThis: *jsc.JSGlobalObject, val: jsc.JSValue) bun.JSError!Style.Color {
if (val.isNumber()) {
const v: u32 = @bitCast(val.toInt32());
return .{ .rgb = .{
.r = @intCast((v >> 16) & 0xFF),
.g = @intCast((v >> 8) & 0xFF),
.b = @intCast(v & 0xFF),
} };
}
if (val.isString()) {
const str = try val.toSliceClone(globalThis);
defer str.deinit();
const s = str.slice();
const hex = if (s.len > 0 and s[0] == '#') s[1..] else s;
if (hex.len == 6) {
const r = std.fmt.parseInt(u8, hex[0..2], 16) catch 0;
const g = std.fmt.parseInt(u8, hex[2..4], 16) catch 0;
const b = std.fmt.parseInt(u8, hex[4..6], 16) catch 0;
return .{ .rgb = .{ .r = r, .g = g, .b = b } };
}
}
// Object form: { palette: 196 } for 256-color palette, or { r, g, b } for RGB
if (val.isObject()) {
if (try val.getTruthy(globalThis, "palette")) |p| {
if (p.isNumber()) {
const idx: u8 = @intCast(@max(0, @min((try p.coerce(i32, globalThis)), 255)));
return .{ .palette = idx };
}
}
// Object RGB: { r: 255, g: 0, b: 0 }
const r_val = try val.getTruthy(globalThis, "r");
const g_val = try val.getTruthy(globalThis, "g");
const b_val = try val.getTruthy(globalThis, "b");
if (r_val != null and g_val != null and b_val != null) {
const r: u8 = @intCast(@max(0, @min((try r_val.?.coerce(i32, globalThis)), 255)));
const g: u8 = @intCast(@max(0, @min((try g_val.?.coerce(i32, globalThis)), 255)));
const b: u8 = @intCast(@max(0, @min((try b_val.?.coerce(i32, globalThis)), 255)));
return .{ .rgb = .{ .r = r, .g = g, .b = b } };
}
}
return .none;
}
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;

View File

@@ -0,0 +1,543 @@
//! TuiTerminalWriter — JS-visible renderer that outputs ANSI to a file descriptor.
//!
//! Accepts `Bun.file()` (a file-backed Blob) as its argument.
//! If the Blob wraps a raw fd, uses it directly (owns_fd = false).
//! If the Blob wraps a path, opens the file (owns_fd = true).
//!
//! Lifetime: ref-counted so that in-flight async writes keep the object alive
//! even if JS drops all references. Refs are held by:
//! 1. JS side (released in finalize)
//! 2. In-flight write (ref in render, deref in onWriterWrite/onWriterError)
//!
//! Double-buffered: while the IOWriter drains `output`, new frames render into
//! `next_output`. On drain, if `next_output` has data, the buffers are swapped
//! and the next async write starts immediately.
const TuiTerminalWriter = @This();
const TuiRenderer = @import("./renderer.zig");
const TuiScreen = @import("./screen.zig");
const CursorStyle = TuiRenderer.CursorStyle;
const ghostty = @import("ghostty").terminal;
const size = ghostty.size;
pub const IOWriter = bun.io.BufferedWriter(TuiTerminalWriter, struct {
pub const onWritable = TuiTerminalWriter.onWritable;
pub const getBuffer = TuiTerminalWriter.getWriterBuffer;
pub const onClose = TuiTerminalWriter.onWriterClose;
pub const onError = TuiTerminalWriter.onWriterError;
pub const onWrite = TuiTerminalWriter.onWriterWrite;
});
const RefCount = bun.ptr.RefCount(TuiTerminalWriter, "ref_count", deinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
pub const js = jsc.Codegen.JSTuiTerminalWriter;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
ref_count: RefCount,
renderer: TuiRenderer = .{},
fd: bun.FileDescriptor,
owns_fd: bool,
globalThis: *jsc.JSGlobalObject = undefined,
io_writer: IOWriter = .{},
event_loop: jsc.EventLoopHandle = undefined,
/// Buffer currently being drained by the IOWriter.
output: std.ArrayList(u8) = .{},
/// Receives the next rendered frame while `output` is in-flight.
next_output: std.ArrayList(u8) = .{},
write_offset: usize = 0,
/// True while the IOWriter is asynchronously draining `output`.
write_pending: bool = false,
/// True after close() / end() has been called.
closed: bool = false,
/// True if close() was called while a write is still pending.
closing: bool = false,
/// True while alternate screen mode is active.
alt_screen: bool = false,
/// JS callback for resize events.
onresize_callback: jsc.Strong.Optional = .empty,
/// Cached terminal dimensions for change detection.
cached_cols: u16 = 0,
cached_rows: u16 = 0,
/// True while mouse tracking is active.
mouse_tracking: bool = false,
/// True while focus tracking is active.
focus_tracking: bool = false,
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiTerminalWriter {
const arguments = callframe.arguments();
if (arguments.len < 1)
return globalThis.throw("TUITerminalWriter requires a Bun.file() argument", .{});
const arg = arguments[0];
const blob = arg.as(jsc.WebCore.Blob) orelse
return globalThis.throw("TUITerminalWriter requires a Bun.file() argument", .{});
if (!blob.needsToReadFile())
return globalThis.throw("TUITerminalWriter requires a file-backed Blob (use Bun.file())", .{});
const store = blob.store orelse
return globalThis.throw("TUITerminalWriter: Blob has no backing store", .{});
const pathlike = store.data.file.pathlike;
var fd: bun.FileDescriptor = undefined;
var owns_fd: bool = undefined;
switch (pathlike) {
.fd => |raw_fd| {
fd = raw_fd;
owns_fd = false;
},
.path => |path| {
var path_buf: bun.PathBuffer = undefined;
const result = bun.sys.open(path.sliceZ(&path_buf), bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o644);
switch (result) {
.result => |opened_fd| {
fd = opened_fd;
owns_fd = true;
},
.err => |err| {
return globalThis.throw("TUITerminalWriter: failed to open file: {f}", .{err});
},
}
},
}
const this = bun.new(TuiTerminalWriter, .{
.ref_count = .init(),
.fd = fd,
.owns_fd = owns_fd,
.globalThis = globalThis,
.event_loop = jsc.EventLoopHandle.init(globalThis.bunVM().eventLoop()),
});
this.io_writer.setParent(this);
_ = this.io_writer.start(fd, true);
this.io_writer.close_fd = false;
this.io_writer.updateRef(this.event_loop, false);
return this;
}
fn deinit(this: *TuiTerminalWriter) void {
this.onresize_callback.deinit();
this.renderer.deinit();
this.io_writer.end();
if (this.owns_fd) {
_ = this.fd.close();
}
this.output.deinit(bun.default_allocator);
this.next_output.deinit(bun.default_allocator);
bun.destroy(this);
}
pub fn finalize(this: *TuiTerminalWriter) callconv(.c) void {
this.deref();
}
pub fn eventLoop(this: *TuiTerminalWriter) jsc.EventLoopHandle {
return this.event_loop;
}
pub fn loop(this: *TuiTerminalWriter) *bun.Async.Loop {
if (comptime bun.Environment.isWindows) {
return this.event_loop.loop().uv_loop;
} else {
return this.event_loop.loop();
}
}
// --- BufferedWriter callbacks ---
pub fn getWriterBuffer(this: *TuiTerminalWriter) []const u8 {
if (this.write_offset >= this.output.items.len) return &.{};
return this.output.items[this.write_offset..];
}
pub fn onWriterWrite(this: *TuiTerminalWriter, amount: usize, status: bun.io.WriteStatus) void {
this.write_offset += amount;
const drained = status == .end_of_file or status == .drained or
this.write_offset >= this.output.items.len;
if (drained) {
if (this.flushNextOutput()) return;
this.io_writer.updateRef(this.event_loop, false);
this.write_pending = false;
if (this.closing) {
this.closed = true;
this.closing = false;
}
this.deref();
}
}
pub fn onWriterError(this: *TuiTerminalWriter, _: bun.sys.Error) void {
this.io_writer.updateRef(this.event_loop, false);
this.write_offset = 0;
this.output.clearRetainingCapacity();
this.next_output.clearRetainingCapacity();
this.write_pending = false;
if (this.closing) {
this.closed = true;
this.closing = false;
}
this.deref();
}
pub fn onWriterClose(_: *TuiTerminalWriter) void {}
pub fn onWritable(this: *TuiTerminalWriter) void {
_ = this.flushNextOutput();
}
/// If `next_output` has queued data, swap it into `output` and start the next
/// async write. Returns true if a new write was kicked off.
fn flushNextOutput(this: *TuiTerminalWriter) bool {
if (this.next_output.items.len == 0) return false;
this.output.clearRetainingCapacity();
const tmp = this.output;
this.output = this.next_output;
this.next_output = tmp;
this.write_offset = 0;
this.io_writer.write();
return true;
}
// --- render ---
/// render(screen, options?)
pub fn render(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
// Check for resize before rendering.
this.checkResize();
const arguments = callframe.arguments();
if (arguments.len < 1) return globalThis.throw("render requires a Screen argument", .{});
const screen = TuiScreen.fromJS(arguments[0]) orelse
return globalThis.throw("render: argument must be a TUIScreen", .{});
// Parse optional cursor options
var cursor_x: ?size.CellCountInt = null;
var cursor_y: ?size.CellCountInt = null;
var cursor_visible: ?bool = null;
var cursor_style: ?CursorStyle = null;
var cursor_blinking: ?bool = null;
if (arguments.len > 1 and arguments[1].isObject()) {
const opts = arguments[1];
if (try opts.getTruthy(globalThis, "cursorX")) |v| {
const val = try v.coerce(i32, globalThis);
cursor_x = @intCast(@max(0, @min(val, screen.page.size.cols -| 1)));
}
if (try opts.getTruthy(globalThis, "cursorY")) |v| {
const val = try v.coerce(i32, globalThis);
cursor_y = @intCast(@max(0, @min(val, screen.page.size.rows -| 1)));
}
if (try opts.getTruthy(globalThis, "cursorVisible")) |v| {
if (v.isBoolean()) cursor_visible = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "cursorStyle")) |v| {
cursor_style = parseCursorStyle(v, globalThis);
}
if (try opts.getTruthy(globalThis, "cursorBlinking")) |v| {
if (v.isBoolean()) cursor_blinking = v.asBoolean();
}
}
// 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);
} else {
this.output.clearRetainingCapacity();
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;
this.ref();
this.io_writer.updateRef(this.event_loop, true);
this.io_writer.write();
}
}
return .js_undefined;
}
/// clear()
pub fn clear(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
this.renderer.clear();
return .js_undefined;
}
/// close()
pub fn close(this: *TuiTerminalWriter, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return .js_undefined;
// Auto-disable tracking modes on close.
if (this.mouse_tracking) {
this.writeRaw("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l");
this.mouse_tracking = false;
}
if (this.focus_tracking) {
this.writeRaw("\x1b[?1004l");
this.focus_tracking = false;
}
// Auto-exit alternate screen on close.
if (this.alt_screen) {
this.writeRaw("\x1b[?1049l");
this.alt_screen = false;
}
if (this.write_pending) {
this.closing = true;
} else {
this.closed = true;
}
this.renderer.clear();
return .js_undefined;
}
/// end() — alias for close()
pub fn end(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
return this.close(globalThis, callframe);
}
// --- Alternate Screen ---
/// enterAltScreen()
pub fn enterAltScreen(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (this.alt_screen) return .js_undefined;
this.alt_screen = true;
this.writeRaw("\x1b[?1049h");
this.renderer.clear(); // Reset diff state for alt screen.
return .js_undefined;
}
/// exitAltScreen()
pub fn exitAltScreen(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (this.alt_screen) {
this.writeRaw("\x1b[?1049l");
this.alt_screen = false;
this.renderer.clear();
}
return .js_undefined;
}
// --- Mouse Tracking ---
/// enableMouseTracking() — enable SGR mouse mode
pub fn enableMouseTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (this.mouse_tracking) return .js_undefined;
this.mouse_tracking = true;
// Enable: button events + SGR encoding + any-event tracking
this.writeRaw("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h");
return .js_undefined;
}
/// disableMouseTracking()
pub fn disableMouseTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (!this.mouse_tracking) return .js_undefined;
this.mouse_tracking = false;
this.writeRaw("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l");
return .js_undefined;
}
// --- Focus Tracking ---
/// enableFocusTracking()
pub fn enableFocusTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (this.focus_tracking) return .js_undefined;
this.focus_tracking = true;
this.writeRaw("\x1b[?1004h");
return .js_undefined;
}
/// disableFocusTracking()
pub fn disableFocusTracking(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
if (!this.focus_tracking) return .js_undefined;
this.focus_tracking = false;
this.writeRaw("\x1b[?1004l");
return .js_undefined;
}
// --- Bracketed Paste ---
/// enableBracketedPaste()
pub fn enableBracketedPaste(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
this.writeRaw("\x1b[?2004h");
return .js_undefined;
}
/// disableBracketedPaste()
pub fn disableBracketedPaste(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
this.writeRaw("\x1b[?2004l");
return .js_undefined;
}
/// write(string) — write raw string bytes to the terminal
pub fn write(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
if (this.closed) return globalThis.throw("TUITerminalWriter is closed", .{});
const arguments = callframe.arguments();
if (arguments.len < 1 or !arguments[0].isString())
return globalThis.throw("write requires a string argument", .{});
const str = try arguments[0].toSliceClone(globalThis);
defer str.deinit();
this.writeRaw(str.slice());
return .js_undefined;
}
// --- Resize ---
pub fn setOnResize(this: *TuiTerminalWriter, globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value.isCallable()) {
this.onresize_callback = jsc.Strong.Optional.create(value, globalThis);
// Initialize cached dimensions on first set.
if (this.cached_cols == 0) {
switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| {
this.cached_cols = ws.col;
this.cached_rows = ws.row;
},
.err => {
this.cached_cols = 80;
this.cached_rows = 24;
},
}
}
// Install the global SIGWINCH handler if not already installed.
SigwinchHandler.install();
} else if (value.isUndefinedOrNull()) {
this.onresize_callback.deinit();
}
}
pub fn getOnResize(this: *TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return this.onresize_callback.get() orelse .js_undefined;
}
/// Check if terminal size changed (called from render path).
fn checkResize(this: *TuiTerminalWriter) void {
if (this.onresize_callback.get() == null) return;
if (!SigwinchHandler.consume()) return;
switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| {
if (ws.col != this.cached_cols or ws.row != this.cached_rows) {
this.cached_cols = ws.col;
this.cached_rows = ws.row;
this.dispatchResize(ws.col, ws.row);
}
},
.err => {},
}
}
fn dispatchResize(this: *TuiTerminalWriter, cols: u16, rows: u16) void {
const callback = this.onresize_callback.get() orelse return;
const globalThis = this.globalThis;
globalThis.bunVM().eventLoop().runCallback(
callback,
globalThis,
.js_undefined,
&.{
jsc.JSValue.jsNumber(@as(i32, @intCast(cols))),
jsc.JSValue.jsNumber(@as(i32, @intCast(rows))),
},
);
}
/// Global SIGWINCH handler — uses a static atomic flag.
const SigwinchHandler = struct {
var flag: std.atomic.Value(bool) = .init(false);
var installed: bool = false;
fn install() void {
if (installed) return;
installed = true;
if (comptime !bun.Environment.isWindows) {
const act = std.posix.Sigaction{
.handler = .{ .handler = handler },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.RESTART,
};
std.posix.sigaction(std.posix.SIG.WINCH, &act, null);
}
}
fn handler(_: c_int) callconv(.c) void {
flag.store(true, .release);
}
fn consume() bool {
return flag.swap(false, .acq_rel);
}
};
// --- Getters ---
pub fn getCursorX(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_x)));
}
pub fn getCursorY(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return jsc.JSValue.jsNumber(@as(i32, @intCast(this.renderer.cursor_y)));
}
pub fn getColumns(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| jsc.JSValue.jsNumber(@as(i32, @intCast(ws.col))),
.err => jsc.JSValue.jsNumber(@as(i32, 80)),
};
}
pub fn getRows(this: *const TuiTerminalWriter, _: *jsc.JSGlobalObject) callconv(.c) jsc.JSValue {
return switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| jsc.JSValue.jsNumber(@as(i32, @intCast(ws.row))),
.err => jsc.JSValue.jsNumber(@as(i32, 24)),
};
}
// --- Helpers ---
fn parseCursorStyle(value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) ?CursorStyle {
const str = value.toSliceClone(globalThis) catch return null;
defer str.deinit();
return cursor_style_map.get(str.slice());
}
const cursor_style_map = bun.ComptimeEnumMap(CursorStyle);
/// Write raw bytes directly to the IOWriter (for alt screen, etc.).
fn writeRaw(this: *TuiTerminalWriter, data: []const u8) void {
if (this.write_pending) {
this.next_output.appendSlice(bun.default_allocator, data) catch {};
} else {
this.output.clearRetainingCapacity();
this.output.appendSlice(bun.default_allocator, data) catch {};
this.write_offset = 0;
this.write_pending = true;
this.ref();
this.io_writer.updateRef(this.event_loop, true);
this.io_writer.write();
}
}
const bun = @import("bun");
const std = @import("std");
const jsc = bun.jsc;

View File

@@ -0,0 +1,5 @@
pub const TuiScreen = @import("./screen.zig");
pub const TuiTerminalWriter = @import("./terminal_writer.zig");
pub const TuiBufferWriter = @import("./buffer_writer.zig");
pub const TuiRenderer = @import("./renderer.zig");
pub const TuiKeyReader = @import("./key_reader.zig");

View File

@@ -24,6 +24,10 @@
macro(TOML) \
macro(YAML) \
macro(Terminal) \
macro(TUIScreen) \
macro(TUITerminalWriter) \
macro(TUIBufferWriter) \
macro(TUIKeyReader) \
macro(Transpiler) \
macro(ValkeyClient) \
macro(argv) \

View File

@@ -998,6 +998,10 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3
TUIScreen BunObject_lazyPropCb_wrap_TUIScreen DontDelete|PropertyCallback
TUITerminalWriter BunObject_lazyPropCb_wrap_TUITerminalWriter DontDelete|PropertyCallback
TUIBufferWriter BunObject_lazyPropCb_wrap_TUIBufferWriter DontDelete|PropertyCallback
TUIKeyReader BunObject_lazyPropCb_wrap_TUIKeyReader DontDelete|PropertyCallback
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback
version constructBunVersion ReadOnly|DontDelete|PropertyCallback

View File

@@ -50,6 +50,10 @@ pub const Classes = struct {
pub const Subprocess = api.Subprocess;
pub const ResourceUsage = api.Subprocess.ResourceUsage;
pub const Terminal = api.Terminal;
pub const TuiScreen = api.TuiScreen;
pub const TuiTerminalWriter = api.TuiTerminalWriter;
pub const TuiBufferWriter = api.TuiBufferWriter;
pub const TuiKeyReader = api.TuiKeyReader;
pub const TCPSocket = api.TCPSocket;
pub const TLSSocket = api.TLSSocket;
pub const UDPSocket = api.UDPSocket;

View File

@@ -242,6 +242,8 @@ pub fn crashHandler(
// To make the release-mode behavior easier to demo, debug mode
// checks for this CLI flag.
const debug_trace = bun.Environment.show_crash_trace and check_flag: {
if (Bun__forceDumpCrashTrace) break :check_flag true;
for (bun.argv) |arg| {
if (bun.strings.eqlComptime(arg, "--debug-crash-handler-use-trace-string")) {
break :check_flag false;
@@ -1647,13 +1649,15 @@ pub inline fn handleErrorReturnTrace(err: anyerror, maybe_trace: ?*std.builtin.S
}
extern "c" fn WTF__DumpStackTrace(ptr: [*]usize, count: usize) void;
pub export var Bun__forceDumpCrashTrace: bool = false;
/// Version of the standard library dumpStackTrace that has some fallbacks for
/// cases where such logic fails to run.
pub fn dumpStackTrace(trace: std.builtin.StackTrace, limits: WriteStackTraceLimits) void {
Output.flush();
var stderr_w = std.fs.File.stderr().writerStreaming(&.{});
const stderr = &stderr_w.interface;
if (!bun.Environment.show_crash_trace) {
if (!bun.Environment.show_crash_trace and !Bun__forceDumpCrashTrace) {
// debug symbols aren't available, lets print a tracestring
stderr.print("View Debug Trace: {f}\n", .{TraceString{
.action = .view_trace,

View File

@@ -265,6 +265,10 @@ pub const Tag = enum(u8) {
readv,
preadv,
ioctl_ficlone,
tcgetattr,
tcsetattr,
ioctl_TIOCGWINSZ,
ioctl_TIOCSWINSZ,
accept,
bind2,
connect2,
@@ -4344,6 +4348,56 @@ pub const umask = switch (Environment.os) {
.windows => @extern(*const fn (mode: u16) callconv(.c) u16, .{ .name = "_umask" }),
};
// --- Terminal I/O ---
pub fn tcgetattr(fd: bun.FileDescriptor) Maybe(std.posix.termios) {
if (comptime Environment.isWindows) {
return .{ .err = Error.fromCode(.NOTTY, .tcgetattr) };
}
const native = fd.native();
var termios: std.posix.termios = undefined;
const rc = std.posix.system.tcgetattr(native, &termios);
if (rc != 0) {
return .{ .err = Error.fromCode(std.posix.errno(rc), .tcgetattr) };
}
return .{ .result = termios };
}
pub fn tcsetattr(fd: bun.FileDescriptor, action: std.posix.TCSA, termios_p: std.posix.termios) Maybe(void) {
if (comptime Environment.isWindows) {
return .{ .err = Error.fromCode(.NOTTY, .tcsetattr) };
}
const native = fd.native();
const rc = std.posix.system.tcsetattr(native, action, &termios_p);
if (rc != 0) {
return .{ .err = Error.fromCode(std.posix.errno(rc), .tcsetattr) };
}
return .{ .result = {} };
}
pub fn getWinsize(fd: bun.FileDescriptor) Maybe(std.posix.winsize) {
if (comptime Environment.isWindows) {
return .{ .err = Error.fromCode(.NOTTY, .ioctl_TIOCGWINSZ) };
}
var ws: std.posix.winsize = undefined;
const rc = std.posix.system.ioctl(fd.native(), std.posix.T.IOCGWINSZ, @intFromPtr(&ws));
if (rc != 0) {
return .{ .err = Error.fromCode(std.posix.errno(rc), .ioctl_TIOCGWINSZ) };
}
return .{ .result = ws };
}
pub fn setWinsize(fd: bun.FileDescriptor, ws: std.posix.winsize) Maybe(void) {
if (comptime Environment.isWindows) {
return .{ .err = Error.fromCode(.NOTTY, .ioctl_TIOCSWINSZ) };
}
const rc = std.posix.system.ioctl(fd.native(), std.posix.T.IOCSWINSZ, @intFromPtr(&ws));
if (rc != 0) {
return .{ .err = Error.fromCode(std.posix.errno(rc), .ioctl_TIOCSWINSZ) };
}
return .{ .result = {} };
}
pub const File = @import("./sys/File.zig");
const builtin = @import("builtin");

View File

@@ -0,0 +1,286 @@
import { describe, expect, test } from "bun:test";
import { closeSync, openSync } from "fs";
import { tempDir } from "harness";
import { join } from "path";
/**
* Performance benchmarks for TUI Screen/Writer.
* These tests establish baselines for performance regression detection.
* They verify that operations complete within reasonable time bounds.
*/
describe("TUI Performance", () => {
test("setText ASCII throughput: 80x24 fills in < 50ms", () => {
const screen = new Bun.TUIScreen(80, 24);
const row = Buffer.alloc(80, "A").toString();
const iterations = 100;
const start = Bun.nanoseconds();
for (let iter = 0; iter < iterations; iter++) {
for (let y = 0; y < 24; y++) {
screen.setText(0, y, row);
}
}
const elapsed = (Bun.nanoseconds() - start) / 1e6; // ms
// 100 iterations of 24 rows × 80 chars = 192,000 setText calls worth of chars
// Should complete in well under 50ms
expect(elapsed).toBeLessThan(50);
});
test("setText CJK throughput: 80x24 fills in < 100ms", () => {
const screen = new Bun.TUIScreen(80, 24);
// 40 CJK chars = 80 columns
const row = Buffer.alloc(40 * 3, 0)
.fill("\xe4\xb8\x96") // 世 in UTF-8
.toString("utf8");
const iterations = 100;
const start = Bun.nanoseconds();
for (let iter = 0; iter < iterations; iter++) {
for (let y = 0; y < 24; y++) {
screen.setText(0, y, row);
}
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
// CJK is slower due to width computation, but should still be fast
expect(elapsed).toBeLessThan(100);
});
test("style interning: 1000 calls for same style < 50ms", () => {
const screen = new Bun.TUIScreen(80, 24);
const iterations = 1000;
const start = Bun.nanoseconds();
for (let i = 0; i < iterations; i++) {
screen.style({ bold: true, fg: 0xff0000 });
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
// Relaxed for debug builds — release builds should be < 5ms
expect(elapsed).toBeLessThan(50);
});
test("style interning: 200 unique styles < 10ms", () => {
const screen = new Bun.TUIScreen(80, 24);
const start = Bun.nanoseconds();
for (let i = 0; i < 200; i++) {
screen.style({ fg: i + 1 });
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(10);
});
test("full render 80x24 ASCII < 10ms", () => {
using dir = tempDir("tui-bench-full", {});
const filePath = join(String(dir), "output.bin");
const fd = openSync(filePath, "w");
try {
const screen = new Bun.TUIScreen(80, 24);
const row = Buffer.alloc(80, "X").toString();
for (let y = 0; y < 24; y++) {
screen.setText(0, y, row);
}
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
const start = Bun.nanoseconds();
writer.render(screen);
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(10);
closeSync(fd);
} catch (e) {
try {
closeSync(fd);
} catch {}
throw e;
}
});
test("full render 200x50 ASCII < 20ms", () => {
using dir = tempDir("tui-bench-full-large", {});
const filePath = join(String(dir), "output.bin");
const fd = openSync(filePath, "w");
try {
const screen = new Bun.TUIScreen(200, 50);
const row = Buffer.alloc(200, "X").toString();
for (let y = 0; y < 50; y++) {
screen.setText(0, y, row);
}
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
const start = Bun.nanoseconds();
writer.render(screen);
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(20);
closeSync(fd);
} catch (e) {
try {
closeSync(fd);
} catch {}
throw e;
}
});
test("diff render with 0 dirty rows < 1ms", () => {
using dir = tempDir("tui-bench-noop", {});
const filePath = join(String(dir), "output.bin");
const fd = openSync(filePath, "w");
try {
const screen = new Bun.TUIScreen(200, 50);
const row = Buffer.alloc(200, "X").toString();
for (let y = 0; y < 50; y++) {
screen.setText(0, y, row);
}
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
writer.render(screen); // first render (full)
// No changes — second render should be a no-op diff
const start = Bun.nanoseconds();
writer.render(screen);
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(1);
closeSync(fd);
} catch (e) {
try {
closeSync(fd);
} catch {}
throw e;
}
});
test("diff render with 3 dirty rows on 200x50 < 5ms", () => {
using dir = tempDir("tui-bench-diff", {});
const filePath = join(String(dir), "output.bin");
const fd = openSync(filePath, "w");
try {
const screen = new Bun.TUIScreen(200, 50);
const row = Buffer.alloc(200, "X").toString();
for (let y = 0; y < 50; y++) {
screen.setText(0, y, row);
}
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
writer.render(screen); // first render (full)
// Change 3 rows
screen.setText(0, 10, Buffer.alloc(200, "A").toString());
screen.setText(0, 25, Buffer.alloc(200, "B").toString());
screen.setText(0, 40, Buffer.alloc(200, "C").toString());
const start = Bun.nanoseconds();
writer.render(screen);
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(5);
closeSync(fd);
} catch (e) {
try {
closeSync(fd);
} catch {}
throw e;
}
});
test("clearRect performance: 1000 clears < 10ms", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.fill(0, 0, 80, 24, "X");
const start = Bun.nanoseconds();
for (let i = 0; i < 1000; i++) {
screen.clearRect(0, 0, 40, 12);
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(10);
});
test("fill performance: 1000 fills < 50ms", () => {
const screen = new Bun.TUIScreen(80, 24);
const start = Bun.nanoseconds();
for (let i = 0; i < 1000; i++) {
screen.fill(0, 0, 80, 24, "#");
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
// Relaxed for debug builds — release builds should be < 10ms
expect(elapsed).toBeLessThan(50);
});
test("copy performance: 1000 copies < 20ms", () => {
const src = new Bun.TUIScreen(80, 24);
const dst = new Bun.TUIScreen(80, 24);
src.fill(0, 0, 80, 24, "X");
const start = Bun.nanoseconds();
for (let i = 0; i < 1000; i++) {
dst.copy(src, 0, 0, 0, 0, 80, 24);
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(20);
});
test("resize cycle: 100 resizes < 50ms", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.fill(0, 0, 80, 24, "X");
const start = Bun.nanoseconds();
for (let i = 0; i < 100; i++) {
screen.resize(160, 48);
screen.resize(80, 24);
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(50);
});
test("getCell performance: 10000 reads < 200ms", () => {
const screen = new Bun.TUIScreen(80, 24);
screen.fill(0, 0, 80, 24, "X");
const start = Bun.nanoseconds();
for (let i = 0; i < 10000; i++) {
screen.getCell(i % 80, i % 24);
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
// Relaxed for debug builds — getCell allocates a JS object per call
expect(elapsed).toBeLessThan(200);
});
test("multiple render frames: 100 renders < 100ms", () => {
using dir = tempDir("tui-bench-multiframe", {});
const filePath = join(String(dir), "output.bin");
const fd = openSync(filePath, "w");
try {
const screen = new Bun.TUIScreen(80, 24);
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
// First render
screen.fill(0, 0, 80, 24, " ");
writer.render(screen);
const start = Bun.nanoseconds();
for (let i = 0; i < 100; i++) {
// Change 1-2 rows per frame (typical Claude Code usage)
screen.setText(0, i % 24, `Frame ${i} content here`);
writer.render(screen);
}
const elapsed = (Bun.nanoseconds() - start) / 1e6;
expect(elapsed).toBeLessThan(100);
closeSync(fd);
} catch (e) {
try {
closeSync(fd);
} catch {}
throw e;
}
});
});

605
test/js/bun/tui/e2e.test.ts Normal file
View File

@@ -0,0 +1,605 @@
import { Terminal } from "@xterm/headless";
import { describe, expect, test } from "bun:test";
import { closeSync, openSync, readFileSync } from "fs";
import { tempDir } from "harness";
import { join } from "path";
/**
* End-to-end tests: write to Screen → render via Writer → parse with xterm.js → verify.
* Validates the full pipeline: Ghostty cell storage → ANSI diff output → terminal state.
*/
function renderToAnsi(cols: number, rows: number, setup: (screen: InstanceType<typeof Bun.TUIScreen>) => void): string {
const screen = new Bun.TUIScreen(cols, rows);
setup(screen);
using dir = tempDir("tui-e2e", {});
const path = join(String(dir), "output.bin");
const fd = openSync(path, "w");
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
writer.render(screen);
closeSync(fd);
return readFileSync(path, "utf8");
}
function feedXterm(ansi: string, cols: number, rows: number): Terminal {
const term = new Terminal({ cols, rows, allowProposedApi: true });
term.write(ansi);
return term;
}
function xtermLine(term: Terminal, y: number): string {
return term.buffer.active.getLine(y)?.translateToString(true) ?? "";
}
function xtermCell(term: Terminal, x: number, y: number) {
const cell = term.buffer.active.getLine(y)?.getCell(x);
if (!cell) return null;
return {
char: cell.getChars(),
width: cell.getWidth(),
fg: cell.getFgColor(),
bg: cell.getBgColor(),
isFgRGB: cell.isFgRGB(),
isBgRGB: cell.isBgRGB(),
bold: cell.isBold(),
italic: cell.isItalic(),
underline: cell.isUnderline(),
strikethrough: cell.isStrikethrough(),
inverse: cell.isInverse(),
dim: cell.isDim(),
overline: cell.isOverline(),
};
}
/** Flush xterm.js write queue */
async function flush(term: Terminal): Promise<void> {
await new Promise<void>(resolve => term.write("", resolve));
}
/**
* Render a screen twice through the same writer, feed the combined output
* to xterm.js. Returns the terminal after both renders are applied.
*/
async function renderTwoFrames(
cols: number,
rows: number,
setup1: (screen: InstanceType<typeof Bun.TUIScreen>) => void,
setup2: (screen: InstanceType<typeof Bun.TUIScreen>) => void,
): Promise<Terminal> {
using dir = tempDir("tui-e2e-multi", {});
const path = join(String(dir), "output.bin");
const fd = openSync(path, "w");
const screen = new Bun.TUIScreen(cols, rows);
setup1(screen);
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
writer.render(screen); // frame 1 (full)
setup2(screen);
writer.render(screen); // frame 2 (diff)
closeSync(fd);
const ansi = readFileSync(path, "utf8");
const term = feedXterm(ansi, cols, rows);
await flush(term);
return term;
}
describe("TUI E2E: Screen → Writer → xterm.js", () => {
// ─── Basic rendering ─────────────────────────────────────────────
test("ASCII text renders correctly", async () => {
const ansi = renderToAnsi(40, 5, screen => {
screen.setText(0, 0, "Hello, World!");
screen.setText(0, 1, "Line two");
});
const term = feedXterm(ansi, 40, 5);
await flush(term);
expect(xtermLine(term, 0).trimEnd()).toBe("Hello, World!");
expect(xtermLine(term, 1).trimEnd()).toBe("Line two");
expect(xtermLine(term, 2).trim()).toBe("");
term.dispose();
});
test("CJK wide characters take 2 columns", async () => {
const ansi = renderToAnsi(20, 3, screen => {
screen.setText(0, 0, "A世界B");
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "A", width: 1 }));
expect(xtermCell(term, 1, 0)).toEqual(expect.objectContaining({ char: "世", width: 2 }));
expect(xtermCell(term, 2, 0)).toEqual(expect.objectContaining({ width: 0 }));
expect(xtermCell(term, 3, 0)).toEqual(expect.objectContaining({ char: "界", width: 2 }));
expect(xtermCell(term, 5, 0)).toEqual(expect.objectContaining({ char: "B" }));
term.dispose();
});
// ─── Style rendering ─────────────────────────────────────────────
test("bold style produces bold cells", async () => {
const ansi = renderToAnsi(20, 3, screen => {
const bold = screen.style({ bold: true });
screen.setText(0, 0, "Bold", bold);
screen.setText(5, 0, "Normal");
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
const boldCell = xtermCell(term, 0, 0)!;
expect(boldCell.char).toBe("B");
expect(boldCell.bold).toBeTruthy();
const normalCell = xtermCell(term, 5, 0)!;
expect(normalCell.char).toBe("N");
expect(normalCell.bold).toBeFalsy();
term.dispose();
});
test("italic style produces italic cells", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const italic = screen.style({ italic: true });
screen.setText(0, 0, "Ital", italic);
screen.setText(5, 0, "Norm");
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.italic).toBeTruthy();
expect(xtermCell(term, 5, 0)!.italic).toBeFalsy();
term.dispose();
});
test("dim (faint) style", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const dim = screen.style({ faint: true });
screen.setText(0, 0, "Dim", dim);
screen.setText(5, 0, "Norm");
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.dim).toBeTruthy();
expect(xtermCell(term, 5, 0)!.dim).toBeFalsy();
term.dispose();
});
test("strikethrough style", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const strike = screen.style({ strikethrough: true });
screen.setText(0, 0, "Strike", strike);
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.strikethrough).toBeTruthy();
term.dispose();
});
test("inverse style", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const inv = screen.style({ inverse: true });
screen.setText(0, 0, "Inv", inv);
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.inverse).toBeTruthy();
term.dispose();
});
test("overline style", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const over = screen.style({ overline: true });
screen.setText(0, 0, "Over", over);
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.overline).toBeTruthy();
term.dispose();
});
test("underline style", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const ul = screen.style({ underline: "single" });
screen.setText(0, 0, "UL", ul);
screen.setText(5, 0, "Norm");
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
expect(xtermCell(term, 0, 0)!.underline).toBeTruthy();
expect(xtermCell(term, 5, 0)!.underline).toBeFalsy();
term.dispose();
});
test("combined bold+italic+strikethrough", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const s = screen.style({ bold: true, italic: true, strikethrough: true });
screen.setText(0, 0, "Combo", s);
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
const cell = xtermCell(term, 0, 0)!;
expect(cell.bold).toBeTruthy();
expect(cell.italic).toBeTruthy();
expect(cell.strikethrough).toBeTruthy();
term.dispose();
});
test("RGB foreground color", async () => {
const ansi = renderToAnsi(20, 3, screen => {
const red = screen.style({ fg: 0xff0000 });
screen.setText(0, 0, "Red", red);
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "R", isFgRGB: true, fg: 0xff0000 }));
term.dispose();
});
test("RGB background color", async () => {
const ansi = renderToAnsi(20, 3, screen => {
const blue = screen.style({ bg: 0x0000ff });
screen.setText(0, 0, "Blue", blue);
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "B", isBgRGB: true, bg: 0x0000ff }));
term.dispose();
});
test("combined fg + bg colors", async () => {
const ansi = renderToAnsi(20, 1, screen => {
const s = screen.style({ fg: 0xff0000, bg: 0x00ff00 });
screen.setText(0, 0, "Both", s);
});
const term = feedXterm(ansi, 20, 1);
await flush(term);
const cell = xtermCell(term, 0, 0)!;
expect(cell.isFgRGB).toBeTruthy();
expect(cell.fg).toBe(0xff0000);
expect(cell.isBgRGB).toBeTruthy();
expect(cell.bg).toBe(0x00ff00);
term.dispose();
});
test("multiple styles on same line", async () => {
const ansi = renderToAnsi(30, 3, screen => {
const bold = screen.style({ bold: true });
const italic = screen.style({ italic: true });
screen.setText(0, 0, "Bold", bold);
screen.setText(5, 0, "Italic", italic);
screen.setText(12, 0, "Plain");
});
const term = feedXterm(ansi, 30, 3);
await flush(term);
const c0 = xtermCell(term, 0, 0)!;
expect(c0.bold).toBeTruthy();
expect(c0.italic).toBeFalsy();
const c5 = xtermCell(term, 5, 0)!;
expect(c5.italic).toBeTruthy();
expect(c5.bold).toBeFalsy();
const c12 = xtermCell(term, 12, 0)!;
expect(c12.bold).toBeFalsy();
expect(c12.italic).toBeFalsy();
term.dispose();
});
test("style reset between rows", async () => {
const ansi = renderToAnsi(20, 3, screen => {
const bold = screen.style({ bold: true });
screen.setText(0, 0, "BoldRow", bold);
screen.setText(0, 1, "PlainRow");
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermCell(term, 0, 0)!.bold).toBeTruthy();
expect(xtermCell(term, 0, 1)!.bold).toBeFalsy();
term.dispose();
});
// ─── Fill / Clear ─────────────────────────────────────────────────
test("fill fills region visible to xterm", async () => {
const ansi = renderToAnsi(10, 3, screen => {
screen.fill(0, 0, 10, 3, "#");
});
const term = feedXterm(ansi, 10, 3);
await flush(term);
expect(xtermLine(term, 0)).toBe("##########");
expect(xtermLine(term, 1)).toBe("##########");
expect(xtermLine(term, 2)).toBe("##########");
term.dispose();
});
test("clearRect clears cells visible to xterm", async () => {
const ansi = renderToAnsi(20, 3, screen => {
screen.fill(0, 0, 20, 3, "X");
screen.clearRect(5, 0, 10, 1);
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
const line0 = xtermLine(term, 0);
expect(line0.substring(0, 5)).toBe("XXXXX");
expect(line0.substring(15, 20)).toBe("XXXXX");
expect(xtermLine(term, 1)).toBe(Buffer.alloc(20, "X").toString());
term.dispose();
});
test("background color fills visible in xterm", async () => {
const ansi = renderToAnsi(10, 1, screen => {
const bg = screen.style({ bg: 0x0000ff });
screen.fill(0, 0, 5, 1, " ", bg);
screen.setText(5, 0, "X");
});
const term = feedXterm(ansi, 10, 1);
await flush(term);
// First 5 cells should have blue background
const bgCell = xtermCell(term, 0, 0)!;
expect(bgCell.isBgRGB).toBeTruthy();
expect(bgCell.bg).toBe(0x0000ff);
// Cell at 5 should have X
expect(xtermCell(term, 5, 0)!.char).toBe("X");
term.dispose();
});
// ─── Synchronized update ──────────────────────────────────────────
test("synchronized update markers are present", () => {
const ansi = renderToAnsi(10, 3, screen => {
screen.setText(0, 0, "Hi");
});
expect(ansi).toContain("\x1b[?2026h");
expect(ansi).toContain("\x1b[?2026l");
const bsuIdx = ansi.indexOf("\x1b[?2026h");
const esuIdx = ansi.indexOf("\x1b[?2026l");
const contentIdx = ansi.indexOf("Hi");
expect(bsuIdx).toBeLessThan(contentIdx);
expect(esuIdx).toBeGreaterThan(contentIdx);
});
// ─── Multi-frame / Diff rendering ────────────────────────────────
test("overwrite produces correct result after diff", async () => {
const term = await renderTwoFrames(
20,
3,
screen => {
screen.setText(0, 0, "Hello");
},
screen => {
screen.setText(0, 0, "AB"); // overwrite first 2 chars
},
);
expect(xtermLine(term, 0).trimEnd()).toBe("ABllo");
term.dispose();
});
test("clear then write across frames", async () => {
const term = await renderTwoFrames(
10,
3,
screen => {
screen.fill(0, 0, 10, 3, "X");
},
screen => {
screen.clearRect(0, 0, 10, 1); // clear row 0
screen.setText(0, 0, "Y"); // write Y on row 0
},
);
// Row 0 should start with Y, rest cleared
const line0 = xtermLine(term, 0);
expect(line0.charAt(0)).toBe("Y");
// Row 1 should still be X's
expect(xtermLine(term, 1)).toBe(Buffer.alloc(10, "X").toString());
term.dispose();
});
test("multiple renders accumulate correctly", async () => {
using dir = tempDir("tui-e2e-multi3", {});
const path = join(String(dir), "output.bin");
const fd = openSync(path, "w");
const screen = new Bun.TUIScreen(20, 3);
const writer = new Bun.TUITerminalWriter(Bun.file(fd));
// Frame 1: write "AAAA" at positions 0-3
screen.setText(0, 0, "AAAA");
writer.render(screen);
// Frame 2: overwrite first 2 with "BB" → cells: B B A A
screen.setText(0, 0, "BB");
writer.render(screen);
// Frame 3: write "C" at position 4 → cells: B B A A C
screen.setText(4, 0, "C");
writer.render(screen);
closeSync(fd);
const ansi = readFileSync(path, "utf8");
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermLine(term, 0).trimEnd()).toBe("BBAAC");
term.dispose();
});
test("style changes across renders", async () => {
const term = await renderTwoFrames(
20,
1,
screen => {
const bold = screen.style({ bold: true });
screen.setText(0, 0, "Text", bold);
},
screen => {
// Overwrite with plain text (no bold)
screen.setText(0, 0, "Text");
},
);
// After second frame, text should NOT be bold
expect(xtermCell(term, 0, 0)!.bold).toBeFalsy();
expect(xtermCell(term, 0, 0)!.char).toBe("T");
term.dispose();
});
test("adding bold in second frame", async () => {
const term = await renderTwoFrames(
20,
1,
screen => {
screen.setText(0, 0, "Plain");
},
screen => {
const bold = screen.style({ bold: true });
screen.setText(0, 0, "Plain", bold);
},
);
expect(xtermCell(term, 0, 0)!.bold).toBeTruthy();
term.dispose();
});
// ─── Large screen ─────────────────────────────────────────────────
test("large screen render (200x50)", async () => {
const cols = 200;
const rows = 50;
const ansi = renderToAnsi(cols, rows, screen => {
// Fill with a pattern
for (let y = 0; y < rows; y++) {
const ch = String.fromCharCode(65 + (y % 26)); // A-Z
screen.setText(0, y, Buffer.alloc(cols, ch).toString());
}
});
const term = feedXterm(ansi, cols, rows);
await flush(term);
// Verify a few rows
expect(xtermLine(term, 0)).toBe(Buffer.alloc(cols, "A").toString());
expect(xtermLine(term, 1)).toBe(Buffer.alloc(cols, "B").toString());
expect(xtermLine(term, 25)).toBe(Buffer.alloc(cols, "Z").toString());
expect(xtermLine(term, 26)).toBe(Buffer.alloc(cols, "A").toString());
expect(xtermLine(term, 49)).toBe(Buffer.alloc(cols, "X").toString());
term.dispose();
});
// ─── Mixed content ───────────────────────────────────────────────
test("mixed ASCII and CJK across rows", async () => {
const ansi = renderToAnsi(20, 3, screen => {
screen.setText(0, 0, "Hello");
screen.setText(0, 1, "世界ABC");
screen.setText(0, 2, "A世B界C");
});
const term = feedXterm(ansi, 20, 3);
await flush(term);
expect(xtermLine(term, 0).trimEnd()).toBe("Hello");
// Row 1: 世(2) 界(2) A B C = 7 cols
expect(xtermCell(term, 0, 1)!.char).toBe("世");
expect(xtermCell(term, 2, 1)!.char).toBe("界");
expect(xtermCell(term, 4, 1)!.char).toBe("A");
// Row 2: A(1) 世(2) B(1) 界(2) C(1) = 7 cols
expect(xtermCell(term, 0, 2)!.char).toBe("A");
expect(xtermCell(term, 1, 2)!.char).toBe("世");
expect(xtermCell(term, 3, 2)!.char).toBe("B");
expect(xtermCell(term, 4, 2)!.char).toBe("界");
expect(xtermCell(term, 6, 2)!.char).toBe("C");
term.dispose();
});
test("styled fill then overwrite with different style", async () => {
const ansi = renderToAnsi(10, 1, screen => {
const red = screen.style({ fg: 0xff0000 });
screen.fill(0, 0, 10, 1, "X", red);
const blue = screen.style({ fg: 0x0000ff });
screen.setText(3, 0, "HI", blue);
});
const term = feedXterm(ansi, 10, 1);
await flush(term);
// Cells 0-2 should be red X
expect(xtermCell(term, 0, 0)).toEqual(expect.objectContaining({ char: "X", fg: 0xff0000, isFgRGB: true }));
// Cells 3-4 should be blue HI
expect(xtermCell(term, 3, 0)).toEqual(expect.objectContaining({ char: "H", fg: 0x0000ff, isFgRGB: true }));
expect(xtermCell(term, 4, 0)).toEqual(expect.objectContaining({ char: "I", fg: 0x0000ff, isFgRGB: true }));
// Cells 5-9 should be red X again
expect(xtermCell(term, 5, 0)).toEqual(expect.objectContaining({ char: "X", fg: 0xff0000, isFgRGB: true }));
term.dispose();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff