Compare commits

...

9 Commits

Author SHA1 Message Date
autofix-ci[bot]
c1abb82719 [autofix.ci] apply automated fixes 2026-02-13 01:30:06 +00:00
Jarred Sumner
90509ea61c Add TUI APIs for ink migration: setAnsiText, key reader mode sequences, inline rendering
Phase 0 of the ink→Bun.TUI migration. Adds three new capabilities to the native TUI primitives:

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:11:17 -08:00
Jarred Sumner
39d14bc0c4 Create add-ghostty-terminal.patch 2026-02-11 19:33:49 -08:00
Jarred Sumner
825fba7377 Update BuildBun.cmake 2026-02-11 19:25:51 -08:00
Jarred Sumner
39702d2580 PR 2026-02-11 19:23:30 -08:00
Jarred Sumner
6713c836bd TUI 2026-02-11 19:23:29 -08:00
robobun
635034ee33 fix(shell): use-after-free in runFromJS when setupIOBeforeRun fails (#26920)
## Summary

- Fixes #26918 — segfault at address `0x28189480080` caused by
use-after-free in the shell interpreter
- When `setupIOBeforeRun()` fails (e.g., stdout handle unavailable on
Windows), the `runFromJS` error path called `deinitFromExec()` which
directly freed the GC-managed interpreter object with
`allocator.destroy(this)`. When the GC later swept and called
`deinitFromFinalizer()` on the already-freed memory, it caused a
segfault.
- Replaced `deinitFromExec()` with `derefRootShellAndIOIfNeeded(true)`
which properly cleans up runtime resources (IO handles, shell
environment) while leaving final object destruction to the GC finalizer
— matching the pattern already used in `finish()`.

## Test plan

- [x] Added regression test in `test/regression/issue/26918.test.ts`
that verifies the shell interpreter handles closed stdout gracefully
without crashing
- [x] Test passes with `bun bd test test/regression/issue/26918.test.ts`
- [ ] The actual crash is primarily reproducible on Windows where stdout
handles can be truly unavailable — CI Windows tests should validate the
fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-11 17:51:10 -08:00
robobun
3e792d0d2e fix(test): write JUnit reporter outfile when --bail triggers early exit (#26852)
## Summary
- When `--bail` caused an early exit after a test failure, the JUnit
reporter output file (`--reporter-outfile`) was never written because
`Global.exit()` was called before the normal completion path
- Extracted the JUnit write logic into a `writeJUnitReportIfNeeded()`
method on `CommandLineReporter` and call it in both bail exit paths
(test failure and unhandled rejection) as well as the normal completion
path

Closes #26851

## Test plan
- [x] Added regression test `test/regression/issue/26851.test.ts` with
two cases:
  - Single failing test file with `--bail` produces JUnit XML output
- Multiple test files where bail triggers on second file still writes
the report
- [x] Verified test fails with system bun (`USE_SYSTEM_BUN=1`)
- [x] Verified test passes with `bun bd test`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 17:41:45 -08:00
robobun
b7d505b6c1 deflake: make HMR rapid edits test event-driven (#26890)
## Summary
- Add `expectMessageEventually(value)` to the bake test harness `Client`
class — waits for a specific message to appear, draining any
intermediate messages that arrived before it
- Rewrite "hmr handles rapid consecutive edits" test to use raw
`Bun.write` + sleep for intermediate edits and `expectMessageEventually`
for the final assertion, avoiding flaky failures when HMR batches
updates non-deterministically across platforms

Fixes flaky failure on Windows where an extra "render 10" message
arrived after `expectMessage` consumed its expected messages but before
client disposal.

## Test plan
- [x] `bun bd test test/bake/dev-and-prod.test.ts` — all 12 tests pass
- [x] Ran the specific test multiple times to confirm no flakiness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Alistair Smith <alistair@anthropic.com>
2026-02-11 16:05:25 -08:00
79 changed files with 19077 additions and 50 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

@@ -69,6 +69,7 @@ if(ENABLE_TINYCC)
endif()
include(CloneZstd)
include(CloneGhostty)
# --- Codegen ---
@@ -737,6 +738,7 @@ register_command(
TARGETS
clone-zig
clone-zstd
clone-ghostty
bun-cppbind
SOURCES
${BUN_ZIG_SOURCES}

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,4 @@
--- /dev/null
+++ b/src/ghostty_terminal.zig
@@ -0,0 +1 @@
+pub const terminal = @import("terminal/main.zig");

View File

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

View File

@@ -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,77 @@
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,
},
setAnsiText: {
fn: "setAnsiText",
length: 3,
},
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,190 @@
//! 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();
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;
var use_inline = false;
var viewport_h: u16 = 0;
if (arguments.len > 1 and arguments[1].isObject()) {
const opts = arguments[1];
if (try opts.getTruthy(globalThis, "cursorX")) |v| {
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();
}
if (try opts.getTruthy(globalThis, "inline")) |v| {
if (v.isBoolean()) use_inline = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "viewportHeight")) |v| {
if (v.isNumber()) viewport_h = @intCast(@max(1, @min((try v.coerce(i32, globalThis)), 4096)));
}
}
const ab_val = js.gc.get(.buffer, callframe.this()) orelse
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();
if (use_inline and viewport_h > 0) {
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
const total_len = this.output.items.len;
const copy_len = @min(total_len, dest.len);
@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 TuiScreen = @import("./screen.zig");
const std = @import("std");
const TuiRenderer = @import("./renderer.zig");
const CursorStyle = TuiRenderer.CursorStyle;
const bun = @import("bun");
const jsc = bun.jsc;
const ghostty = @import("ghostty").terminal;
const size = ghostty.size;

View File

@@ -0,0 +1,724 @@
//! 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 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,
/// Mode sequences enabled via constructor options.
bracketed_paste: bool = false,
focus_events: bool = false,
kitty_keyboard: bool = false,
_padding: u6 = 0,
};
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*TuiKeyReader {
if (comptime bun.Environment.isWindows) {
return globalThis.throw("TUIKeyReader is not supported on Windows", .{});
}
const arguments = callframe.arguments();
const stdin_fd = bun.FD.fromNative(0);
// Set raw mode if stdin is a TTY.
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", .{});
}
// Parse optional constructor options.
var want_bracketed_paste = false;
var want_focus_events = false;
var want_kitty_keyboard = false;
if (arguments.len > 0 and arguments[0].isObject()) {
const opts = arguments[0];
if (try opts.getTruthy(globalThis, "bracketedPaste")) |v| {
if (v.isBoolean()) want_bracketed_paste = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "focusEvents")) |v| {
if (v.isBoolean()) want_focus_events = v.asBoolean();
}
if (try opts.getTruthy(globalThis, "kittyKeyboard")) |v| {
if (v.isBoolean()) want_kitty_keyboard = v.asBoolean();
}
}
const this = bun.new(TuiKeyReader, .{
.ref_count = .init(),
.event_loop_handle = jsc.EventLoopHandle.init(globalThis.bunVM().eventLoop()),
.globalThis = globalThis,
.stdin_fd = stdin_fd,
.flags = .{
.is_tty = is_tty,
.bracketed_paste = want_bracketed_paste,
.focus_events = want_focus_events,
.kitty_keyboard = want_kitty_keyboard,
},
});
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.
// Write mode-enabling sequences to stdout.
this.enableModes();
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) {
this.disableModes();
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) {
this.disableModes();
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) {
this.disableModes();
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;
}
// --- Terminal mode sequences ---
/// Write mode-enabling escape sequences to stdout for modes requested
/// in the constructor options. Written to stdout (fd 1) regardless of
/// whether stdin is a TTY, since the user explicitly requested them.
fn enableModes(this: *const TuiKeyReader) void {
var buf: [64]u8 = undefined;
var pos: usize = 0;
if (this.flags.bracketed_paste) {
const seq = "\x1b[?2004h";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (this.flags.focus_events) {
const seq = "\x1b[?1004h";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (this.flags.kitty_keyboard) {
const seq = "\x1b[>1u";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
}
if (pos > 0) {
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
}
}
/// Write mode-disabling escape sequences to stdout. Called from close/deinit.
fn disableModes(this: *TuiKeyReader) void {
var buf: [64]u8 = undefined;
var pos: usize = 0;
// Disable in reverse order of enabling.
if (this.flags.kitty_keyboard) {
const seq = "\x1b[<u";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.kitty_keyboard = false;
}
if (this.flags.focus_events) {
const seq = "\x1b[?1004l";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.focus_events = false;
}
if (this.flags.bracketed_paste) {
const seq = "\x1b[?2004l";
@memcpy(buf[pos..][0..seq.len], seq);
pos += seq.len;
this.flags.bracketed_paste = false;
}
if (pos > 0) {
_ = bun.sys.write(bun.FD.fromNative(1), buf[0..pos]);
}
}
// --- Input processing via Ghostty parser ---
fn processInput(this: *TuiKeyReader, data: []const u8) void {
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));
event.put(globalThis, bun.String.static("option"), jsc.JSValue.jsBoolean(mod_alt));
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, 6);
const name_js = bun.String.createUTF8ForJS(globalThis, name) catch return;
const seq_js = bun.String.createUTF8ForJS(globalThis, sequence) catch return;
event.put(globalThis, bun.String.static("name"), name_js);
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));
event.put(globalThis, bun.String.static("option"), 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 std = @import("std");
const ghostty = @import("ghostty").terminal;
const bun = @import("bun");
const jsc = bun.jsc;
const Parser = ghostty.Parser;
const Action = Parser.Action;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,564 @@
//! 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();
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;
var use_inline = false;
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();
}
if (try opts.getTruthy(globalThis, "inline")) |v| {
if (v.isBoolean()) use_inline = v.asBoolean();
}
}
// Get viewport height for inline mode.
const viewport_h: u16 = if (use_inline) blk: {
break :blk switch (bun.sys.getWinsize(this.fd)) {
.result => |ws| ws.row,
.err => 24,
};
} else 0;
// Async double-buffered write.
if (this.write_pending) {
this.next_output.clearRetainingCapacity();
if (use_inline) {
this.renderer.renderInline(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.next_output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
} else {
this.output.clearRetainingCapacity();
if (use_inline) {
this.renderer.renderInline(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking, viewport_h);
} else {
this.renderer.render(&this.output, screen, cursor_x, cursor_y, cursor_visible, cursor_style, cursor_blinking);
}
if (this.output.items.len > 0) {
this.write_offset = 0;
this.write_pending = true;
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 TuiScreen = @import("./screen.zig");
const std = @import("std");
const TuiRenderer = @import("./renderer.zig");
const CursorStyle = TuiRenderer.CursorStyle;
const bun = @import("bun");
const jsc = bun.jsc;
const ghostty = @import("ghostty").terminal;
const size = ghostty.size;

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

@@ -948,6 +948,7 @@ pub const CommandLineReporter = struct {
this.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ this.jest.bail, if (this.jest.bail == 1) "" else "s" });
Output.flush();
this.writeJUnitReportIfNeeded();
Global.exit(1);
}
},
@@ -970,6 +971,20 @@ pub const CommandLineReporter = struct {
Output.printStartEnd(bun.start_time, std.time.nanoTimestamp());
}
/// Writes the JUnit reporter output file if a JUnit reporter is active and
/// an outfile path was configured. This must be called before any early exit
/// (e.g. bail) so that the report is not lost.
pub fn writeJUnitReportIfNeeded(this: *CommandLineReporter) void {
if (this.reporters.junit) |junit| {
if (this.jest.test_options.reporter_outfile) |outfile| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(outfile) catch {};
}
}
}
pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void {
if (comptime !reporters.text and !reporters.lcov) {
return;
@@ -1772,12 +1787,7 @@ pub const TestCommand = struct {
Output.prettyError("\n", .{});
Output.flush();
if (reporter.reporters.junit) |junit| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(ctx.test_options.reporter_outfile.?) catch {};
}
reporter.writeJUnitReportIfNeeded();
if (vm.hot_reload == .watch) {
vm.runWithAPILock(jsc.VirtualMachine, vm, runEventLoopForWatch);
@@ -1920,6 +1930,7 @@ pub const TestCommand = struct {
if (reporter.jest.bail == reporter.summary().fail) {
reporter.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" });
reporter.writeJUnitReportIfNeeded();
vm.exit_handler.exit_code = 1;
vm.is_shutting_down = true;

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

@@ -1154,7 +1154,7 @@ pub const Interpreter = struct {
_ = callframe; // autofix
if (this.setupIOBeforeRun().asErr()) |e| {
defer this.#deinitFromExec();
defer this.#derefRootShellAndIOIfNeeded(true);
const shellerr = bun.shell.ShellErr.newSys(e);
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
}

View File

@@ -265,6 +265,10 @@ pub const Tag = enum(u8) {
readv,
preadv,
ioctl_ficlone,
tcgetattr,
tcsetattr,
ioctl_TIOCGWINSZ,
ioctl_TIOCSWINSZ,
accept,
bind2,
connect2,
@@ -4350,6 +4354,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

@@ -260,14 +260,35 @@ devTest("hmr handles rapid consecutive edits", {
await Bun.sleep(1);
}
// Wait event-driven for "render 10" to appear. Intermediate renders may
// be skipped (watcher coalescing) and the final render may fire multiple
// times (duplicate reloads), so we just listen for any occurrence.
const finalRender = "render 10";
while (true) {
const message = await client.getStringMessage();
if (message === finalRender) break;
if (typeof message === "string" && message.includes("HMR_ERROR")) {
throw new Error("Unexpected HMR error message: " + message);
}
}
await new Promise<void>((resolve, reject) => {
const check = () => {
for (const msg of client.messages) {
if (typeof msg === "string" && msg.includes("HMR_ERROR")) {
cleanup();
reject(new Error("Unexpected HMR error message: " + msg));
return;
}
if (msg === finalRender) {
cleanup();
resolve();
return;
}
}
};
const cleanup = () => {
client.off("message", check);
};
client.on("message", check);
// Check messages already buffered.
check();
});
// Drain all buffered messages — intermediate renders and possible
// duplicates of the final render are expected and harmless.
client.messages.length = 0;
const hmrErrors = await client.js`return globalThis.__hmrErrors ? [...globalThis.__hmrErrors] : [];`;
if (hmrErrors.length > 0) {

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;
}
});
});

View File

@@ -0,0 +1,282 @@
/**
* demo-ai-chat.ts — Claude Code-style AI Chat (Inline, No Alt Screen)
*
* An inline chat interface inspired by Claude Code's terminal UI. Shows a
* conversation with streaming-style token output, tool use blocks, thinking
* indicators, and styled markdown-like formatting — all rendered inline
* without alt screen.
*
* Run: bun run test/js/bun/tui/demos/demo-ai-chat.ts
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const W = Math.min(writer.columns || 80, 90);
// --- Styles (reused across screens) ---
function makeStyles(screen: InstanceType<typeof Bun.TUIScreen>) {
return {
prompt: screen.style({ fg: 0x61afef, bold: true }),
userText: screen.style({ fg: 0xffffff, bold: true }),
assistantLabel: screen.style({ fg: 0xc678dd, bold: true }),
text: screen.style({ fg: 0xdcdcdc }),
dim: screen.style({ fg: 0x5c6370 }),
code: screen.style({ fg: 0x98c379, bg: 0x1e2127 }),
codeBorder: screen.style({ fg: 0x3e4451 }),
codeLabel: screen.style({ fg: 0x5c6370, bg: 0x1e2127 }),
toolName: screen.style({ fg: 0xe5c07b, bold: true }),
toolBorder: screen.style({ fg: 0x3e4451 }),
toolLabel: screen.style({ fg: 0x5c6370 }),
thinking: screen.style({ fg: 0x5c6370, italic: true }),
thinkDots: screen.style({ fg: 0xc678dd }),
bold: screen.style({ fg: 0xffffff, bold: true }),
bullet: screen.style({ fg: 0x61afef }),
separator: screen.style({ fg: 0x2c313a }),
cost: screen.style({ fg: 0x5c6370 }),
duration: screen.style({ fg: 0x98c379 }),
fileRef: screen.style({ fg: 0x61afef, underline: "single" }),
};
}
function renderBlock(
lines: string[],
styles: ReturnType<typeof makeStyles>,
drawFn: (screen: InstanceType<typeof Bun.TUIScreen>, st: typeof styles) => void,
) {
const h = lines.length;
const screen = new Bun.TUIScreen(W, h);
drawFn(screen, makeStyles(screen));
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- Helper: word wrap ---
function wrap(text: string, width: number): string[] {
const result: string[] = [];
for (const para of text.split("\n")) {
if (para.length === 0) {
result.push("");
continue;
}
const words = para.split(" ");
let cur = "";
for (const w of words) {
if (cur.length === 0) cur = w;
else if (cur.length + 1 + w.length <= width) cur += " " + w;
else {
result.push(cur);
cur = w;
}
}
if (cur) result.push(cur);
}
return result;
}
// ============================================================
// Render the conversation
// ============================================================
// --- 1. User prompt ---
{
const screen = new Bun.TUIScreen(W, 3);
const st = makeStyles(screen);
screen.setText(0, 0, "\u276f ", st.prompt);
screen.setText(2, 0, "Fix the bug in src/bun.js/api/tui/screen.zig where styles overflow", st.userText);
screen.setText(2, 1, " after 256 unique colors and add a style cache to prevent it", st.userText);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 2. Thinking indicator ---
{
const screen = new Bun.TUIScreen(W, 2);
const st = makeStyles(screen);
screen.setText(0, 0, "\u2728 ", st.assistantLabel);
screen.setText(2, 0, "Claude", st.assistantLabel);
screen.setText(0, 1, " \u25CF\u25CB\u25CB ", st.thinkDots);
screen.setText(6, 1, "Thinking...", st.thinking);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 3. Assistant response with markdown-like formatting ---
{
const responseText = [
"I found the issue. The Ghostty `StyleSet` is initialized with a capacity of",
"256, but the demo creates 500+ unique styles (216-color cube + gradients).",
"When the set is full, `styles.add()` returns an error, but `clearCells()`",
"then tries to `release()` styles that were never properly ref-counted,",
"hitting an assertion in the `RefCountedSet`.",
"",
"Here's my plan:",
"",
" 1. Increase style capacity from 256 to 4096",
" 2. Add a StyleCache to deduplicate and prevent ref-count overflow",
" 3. Fix clear() to zero cells directly instead of calling clearCells()",
" 4. Fix resize() to re-add cached styles to the new page",
];
const screen = new Bun.TUIScreen(W, responseText.length + 1);
const st = makeStyles(screen);
for (let i = 0; i < responseText.length; i++) {
const line = responseText[i];
if (line.startsWith(" ") && /^\s+\d\./.test(line)) {
// Numbered list
const numEnd = line.indexOf(".");
screen.setText(0, i, line.slice(0, numEnd + 1), st.bullet);
screen.setText(numEnd + 1, i, line.slice(numEnd + 1), st.text);
} else if (line.includes("`")) {
// Inline code
let x = 0;
let inCode = false;
for (const part of line.split("`")) {
screen.setText(x, i, part, inCode ? st.code : st.text);
x += part.length;
inCode = !inCode;
}
} else {
screen.setText(0, i, line, st.text);
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 4. Tool use: Read file ---
{
const screen = new Bun.TUIScreen(W, 4);
const st = makeStyles(screen);
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
screen.setText(5, 0, "Read", st.toolName);
screen.setText(10, 0, "src/bun.js/api/tui/screen.zig", st.fileRef);
screen.setText(0, 1, " \u2502", st.toolBorder);
screen.setText(4, 1, " 795 lines | Zig", st.toolLabel);
screen.setText(
0,
2,
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
st.toolBorder,
);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 5. Tool use: Edit file ---
{
const codeLines = [
" const page = Page.init(.{",
"- .cols = cols, .rows = rows, .styles = 256",
"+ .cols = cols, .rows = rows, .styles = 4096",
" }) catch {",
];
const screen = new Bun.TUIScreen(W, codeLines.length + 3);
const st = makeStyles(screen);
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
screen.setText(5, 0, "Edit", st.toolName);
screen.setText(10, 0, "src/bun.js/api/tui/screen.zig:118", st.fileRef);
for (let i = 0; i < codeLines.length; i++) {
const line = codeLines[i];
screen.setText(0, i + 1, " \u2502", st.toolBorder);
if (line.startsWith("-")) {
screen.setText(4, i + 1, line, screen.style({ fg: 0xe06c75, bg: 0x2a1517 }));
} else if (line.startsWith("+")) {
screen.setText(4, i + 1, line, screen.style({ fg: 0x98c379, bg: 0x152a17 }));
} else {
screen.setText(4, i + 1, line, st.code);
}
}
screen.setText(
0,
codeLines.length + 1,
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
st.toolBorder,
);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 6. Tool use: Run tests ---
{
const screen = new Bun.TUIScreen(W, 5);
const st = makeStyles(screen);
screen.setText(0, 0, " \u250C\u2500 ", st.toolBorder);
screen.setText(5, 0, "Bash", st.toolName);
screen.setText(10, 0, "bun bd test test/js/bun/tui/screen.test.ts", st.codeLabel);
screen.setText(0, 1, " \u2502", st.toolBorder);
screen.setText(4, 1, " 105 pass", screen.style({ fg: 0x98c379, bold: true }));
screen.setText(0, 2, " \u2502", st.toolBorder);
screen.setText(4, 2, " 0 fail", screen.style({ fg: 0x98c379 }));
screen.setText(0, 3, " \u2502", st.toolBorder);
screen.setText(4, 3, " Ran 105 tests across 1 file. [680ms]", st.toolLabel);
screen.setText(
0,
4,
" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
st.toolBorder,
);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 7. Completion summary ---
{
const summaryLines = [
"Fixed the style capacity overflow. Changes:",
"",
" \u2022 Increased StyleSet capacity: 256 \u2192 4096 (screen.zig, renderer.zig)",
" \u2022 Added StyleCache hash map to prevent ref-count overflow on repeated style() calls",
" \u2022 clear()/clearRect() now zero cells via @memset instead of clearCells()",
" \u2022 resize() re-adds cached styles sorted by ID to preserve stability",
"",
"All 105 tests pass.",
];
const screen = new Bun.TUIScreen(W, summaryLines.length + 1);
const st = makeStyles(screen);
for (let i = 0; i < summaryLines.length; i++) {
const line = summaryLines[i];
if (line.startsWith(" \u2022")) {
screen.setText(0, i, " \u2022", st.bullet);
// Find the colon to bold the label
const colonIdx = line.indexOf(":");
if (colonIdx > 4) {
screen.setText(4, i, line.slice(4, colonIdx + 1), st.bold);
screen.setText(4 + colonIdx - 3, i, line.slice(colonIdx + 1), st.text);
} else {
screen.setText(4, i, line.slice(4), st.text);
}
} else {
screen.setText(0, i, line, st.text);
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// --- 8. Footer with cost/duration ---
{
const screen = new Bun.TUIScreen(W, 2);
const st = makeStyles(screen);
const sep = "\u2500".repeat(W);
screen.setText(0, 0, sep, st.separator);
screen.setText(0, 1, "Cooked for 1m 6s", st.duration);
screen.setText(20, 1, "\u2022 3 tool uses", st.cost);
screen.setText(36, 1, "\u2022 $0.04", st.cost);
screen.setText(46, 1, "\u2022 4.2K tokens in, 1.8K out", st.cost);
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
writer.close();

View File

@@ -0,0 +1,438 @@
/**
* demo-animation.ts — Smooth Animations & Effects
*
* Showcases smooth terminal animations: bouncing ball, particle fountain,
* wave effect, and a matrix rain effect. Demonstrates the rendering
* performance of Bun's TUI diff renderer.
*
* Demonstrates: high-fps animation loops, mathematical animations, particle
* systems, color gradients, setText, fill, style (fg/bg), TUITerminalWriter,
* TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-animation.ts
* Controls: 1-4 switch effects, Space pause, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
tabActive: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
dim: screen.style({ fg: 0x5c6370 }),
fps: screen.style({ fg: 0x98c379, bold: true }),
};
// --- State ---
let activeEffect = 0;
let paused = false;
let frame = 0;
let lastTime = Date.now();
let fpsCounter = 0;
let displayFps = 0;
const effectNames = ["Bouncing Balls", "Particles", "Sine Wave", "Matrix Rain"];
// --- Effect 1: Bouncing Balls ---
interface Ball {
x: number;
y: number;
vx: number;
vy: number;
color: number;
char: string;
}
const balls: Ball[] = [];
const ballColors = [0xff5555, 0x55ff55, 0x5555ff, 0xffff55, 0xff55ff, 0x55ffff, 0xffffff, 0xff8800];
const ballChars = ["\u25cf", "\u2b24", "\u25c9", "\u25ce", "\u2022", "\u25cb"];
function initBalls() {
balls.length = 0;
for (let i = 0; i < 12; i++) {
balls.push({
x: Math.random() * (cols - 4) + 2,
y: Math.random() * (rows - 6) + 3,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 1.5,
color: ballColors[i % ballColors.length],
char: ballChars[i % ballChars.length],
});
}
}
function tickBalls() {
const minX = 1,
maxX = cols - 2,
minY = 2,
maxY = rows - 2;
for (const ball of balls) {
ball.x += ball.vx;
ball.y += ball.vy;
ball.vy += 0.05; // gravity
if (ball.x <= minX || ball.x >= maxX) {
ball.vx *= -0.95;
ball.x = Math.max(minX, Math.min(maxX, ball.x));
}
if (ball.y <= minY || ball.y >= maxY) {
ball.vy *= -0.85;
ball.y = Math.max(minY, Math.min(maxY, ball.y));
if (Math.abs(ball.vy) < 0.3) ball.vy = -(2 + Math.random() * 2);
}
}
}
function renderBalls() {
for (const ball of balls) {
const ix = Math.round(ball.x);
const iy = Math.round(ball.y);
if (ix >= 0 && ix < cols && iy >= 2 && iy < rows - 1) {
screen.setText(ix, iy, ball.char, screen.style({ fg: ball.color, bold: true }));
}
}
}
// --- Effect 2: Particles ---
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
color: number;
}
const particles: Particle[] = [];
function spawnParticles(count: number) {
const cx = cols / 2;
const cy = rows / 2;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 0.5 + Math.random() * 2;
const hue = (frame * 2 + Math.random() * 60) % 360;
particles.push({
x: cx,
y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed * 0.5 - 0.5,
life: 0,
maxLife: 20 + Math.floor(Math.random() * 40),
color: hslToRgb(hue, 1, 0.6),
});
}
}
function tickParticles() {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.03; // gravity
p.life++;
if (p.life >= p.maxLife || p.x < 0 || p.x >= cols || p.y < 2 || p.y >= rows - 1) {
particles.splice(i, 1);
}
}
if (!paused) spawnParticles(3);
}
function renderParticles() {
for (const p of particles) {
const ix = Math.round(p.x);
const iy = Math.round(p.y);
if (ix >= 0 && ix < cols && iy >= 2 && iy < rows - 1) {
const fade = 1 - p.life / p.maxLife;
const chars = ["\u2588", "\u2593", "\u2592", "\u2591", "\u00b7"];
const ci = Math.min(chars.length - 1, Math.floor((1 - fade) * chars.length));
const r = (p.color >> 16) & 0xff;
const g = (p.color >> 8) & 0xff;
const b = p.color & 0xff;
const fr = Math.round(r * fade);
const fg = Math.round(g * fade);
const fb = Math.round(b * fade);
screen.setText(ix, iy, chars[ci], screen.style({ fg: (fr << 16) | (fg << 8) | fb }));
}
}
}
// --- Effect 3: Sine Wave ---
function renderSineWave() {
const centerY = Math.floor((rows - 3) / 2) + 2;
const amplitude = Math.min((rows - 5) / 2, 8);
for (let x = 0; x < cols; x++) {
// Multiple overlapping waves
const t = frame * 0.08;
const y1 = Math.sin(x * 0.08 + t) * amplitude;
const y2 = Math.sin(x * 0.12 + t * 1.3) * amplitude * 0.6;
const y3 = Math.sin(x * 0.05 + t * 0.7) * amplitude * 0.4;
// Wave 1 (blue)
const wy1 = Math.round(centerY + y1);
if (wy1 >= 2 && wy1 < rows - 1) {
const hue = (x * 3 + frame * 2) % 360;
screen.setText(x, wy1, "\u2588", screen.style({ fg: hslToRgb(hue, 0.8, 0.5) }));
}
// Wave 2 (trail)
const wy2 = Math.round(centerY + y1 + y2);
if (wy2 >= 2 && wy2 < rows - 1) {
screen.setText(x, wy2, "\u2593", screen.style({ fg: hslToRgb((x * 3 + frame * 2 + 120) % 360, 0.6, 0.4) }));
}
// Wave 3 (subtle)
const wy3 = Math.round(centerY + y3);
if (wy3 >= 2 && wy3 < rows - 1) {
screen.setText(x, wy3, "\u2591", screen.style({ fg: hslToRgb((x * 3 + frame * 2 + 240) % 360, 0.4, 0.3) }));
}
}
}
// --- Effect 4: Matrix Rain ---
interface RainDrop {
x: number;
y: number;
speed: number;
length: number;
chars: number[];
}
const rainDrops: RainDrop[] = [];
function initRain() {
rainDrops.length = 0;
for (let x = 0; x < cols; x += 2) {
if (Math.random() < 0.4) {
rainDrops.push(makeRainDrop(x));
}
}
}
function makeRainDrop(x: number): RainDrop {
const length = 5 + Math.floor(Math.random() * 15);
const chars: number[] = [];
for (let i = 0; i < length; i++) {
chars.push(0x30a0 + Math.floor(Math.random() * 96)); // Katakana
}
return {
x,
y: -length - Math.floor(Math.random() * rows),
speed: 0.3 + Math.random() * 0.7,
length,
chars,
};
}
function tickRain() {
for (let i = rainDrops.length - 1; i >= 0; i--) {
const drop = rainDrops[i];
drop.y += drop.speed;
// Randomize chars occasionally
if (Math.random() < 0.1) {
const ci = Math.floor(Math.random() * drop.chars.length);
drop.chars[ci] = 0x30a0 + Math.floor(Math.random() * 96);
}
if (drop.y > rows + drop.length) {
rainDrops[i] = makeRainDrop(drop.x);
}
}
// Spawn new drops
if (rainDrops.length < cols / 2 && Math.random() < 0.1) {
const x = Math.floor(Math.random() * cols);
rainDrops.push(makeRainDrop(x));
}
}
function renderRain() {
for (const drop of rainDrops) {
for (let i = 0; i < drop.length; i++) {
const y = Math.floor(drop.y) - i;
if (y >= 2 && y < rows - 1) {
const brightness = i === 0 ? 1.0 : Math.max(0.1, 1.0 - (i / drop.length) * 0.8);
const g = Math.round(255 * brightness);
const r = i === 0 ? 200 : 0;
const b = i === 0 ? 200 : 0;
const color = (r << 16) | (g << 8) | b;
const ch = String.fromCodePoint(drop.chars[i % drop.chars.length]);
screen.setText(drop.x, y, ch, screen.style({ fg: color }));
}
}
}
}
// --- HSL to RGB ---
function hslToRgb(h: number, s: number, l: number): number {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return (Math.round((r + m) * 255) << 16) | (Math.round((g + m) * 255) << 8) | Math.round((b + m) * 255);
}
// --- Main render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Animations ", st.titleBar);
// Tabs
let tx = 2;
for (let i = 0; i < effectNames.length; i++) {
const label = ` ${i + 1}:${effectNames[i]} `;
screen.setText(tx, 1, label, i === activeEffect ? st.tabActive : st.tabInactive);
tx += label.length + 1;
}
// FPS
screen.setText(cols - 10, 1, `${displayFps} fps`, st.fps);
// Render active effect
switch (activeEffect) {
case 0:
renderBalls();
break;
case 1:
renderParticles();
break;
case 2:
renderSineWave();
break;
case 3:
renderRain();
break;
}
// Footer
const footerText = paused ? " PAUSED | 1-4:Effect | Space:Resume | Q:Quit " : " 1-4:Effect | Space:Pause | Q:Quit ";
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "1":
activeEffect = 0;
initBalls();
break;
case "2":
activeEffect = 1;
particles.length = 0;
break;
case "3":
activeEffect = 2;
break;
case "4":
activeEffect = 3;
initRain();
break;
case " ":
paused = !paused;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
if (activeEffect === 0) initBalls();
if (activeEffect === 3) initRain();
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Animation loop ---
initBalls();
initRain();
const timer = setInterval(() => {
if (!paused) {
frame++;
switch (activeEffect) {
case 0:
tickBalls();
break;
case 1:
tickParticles();
break;
case 3:
tickRain();
break;
}
}
// FPS counter
fpsCounter++;
const now = Date.now();
if (now - lastTime >= 1000) {
displayFps = fpsCounter;
fpsCounter = 0;
lastTime = now;
}
render();
}, 33); // ~30fps
render();

View File

@@ -0,0 +1,83 @@
/**
* demo-banner.ts — Large ASCII art banner with rainbow gradient colors.
* Renders "BUN" in big block letters using style() with different fg colors per column.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = writer.columns || 80;
const height = 12;
const screen = new Bun.TUIScreen(width, height);
// "BUN" in 5-row block letters (each char is 6 columns wide + 1 gap)
const letters: Record<string, string[]> = {
B: ["█████ ", "█ █ ", "█████ ", "█ █ ", "█████ "],
U: ["█ █ ", "█ █ ", "█ █ ", "█ █ ", "█████ "],
N: ["█ █ ", "██ █ ", "█ █ █ ", "█ ██ ", "█ █ "],
};
const word = "BUN";
const letterWidth = 6;
const totalWidth = word.length * (letterWidth + 1);
const startX = Math.max(0, Math.floor((width - totalWidth) / 2));
const startY = 2;
// Rainbow colors for the gradient
const rainbow = [
0xff0000, // red
0xff4400, // orange-red
0xff8800, // orange
0xffcc00, // yellow-orange
0xffff00, // yellow
0x88ff00, // yellow-green
0x00ff00, // green
0x00ff88, // green-cyan
0x00ffff, // cyan
0x0088ff, // blue-cyan
0x0044ff, // blue
0x4400ff, // blue-violet
0x8800ff, // violet
0xcc00ff, // magenta-violet
0xff00ff, // magenta
0xff0088, // magenta-red
0xff0044, // red-magenta
0xff0000, // red again
];
// Title line
const titleStyle = screen.style({ fg: 0xffffff, bold: true, faint: true });
const titleText = "~ Bun TUI Demo: Rainbow Banner ~";
const titleX = Math.max(0, Math.floor((width - titleText.length) / 2));
screen.setText(titleX, 0, titleText, titleStyle);
// Draw the block letters with per-column rainbow colors
for (let ci = 0; ci < word.length; ci++) {
const char = word[ci];
const rows = letters[char];
for (let row = 0; row < rows.length; row++) {
const line = rows[row];
for (let col = 0; col < line.length; col++) {
if (line[col] === " ") continue;
const globalCol = ci * (letterWidth + 1) + col;
const colorIdx = Math.floor((globalCol / totalWidth) * rainbow.length) % rainbow.length;
const s = screen.style({ fg: rainbow[colorIdx], bold: true });
screen.setText(startX + globalCol, startY + row, line[col], s);
}
}
}
// Tagline
const tagline = "JavaScript runtime & toolkit";
const tagStyle = screen.style({ fg: 0xaaaaaa, italic: true });
const tagX = Math.max(0, Math.floor((width - tagline.length) / 2));
screen.setText(tagX, startY + 6, tagline, tagStyle);
// Underline decoration
const underline = "\u2500".repeat(Math.min(totalWidth, width - 2));
const ulStyle = screen.style({ fg: 0x555555 });
const ulX = Math.max(0, Math.floor((width - underline.length) / 2));
screen.setText(ulX, startY + 8, underline, ulStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,109 @@
/**
* demo-barh.ts — Horizontal bar chart comparing JS runtimes (Bun, Node, Deno)
* on different benchmarks. Colored bars with labels and values.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 76);
const height = 24;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const bunStyle = screen.style({ fg: 0xfbf0df, bg: 0xfbf0df });
const bunLabel = screen.style({ fg: 0xfbf0df, bold: true });
const nodeStyle = screen.style({ fg: 0x68a063, bg: 0x68a063 });
const nodeLabel = screen.style({ fg: 0x68a063 });
const denoStyle = screen.style({ fg: 0x70ffaf, bg: 0x70ffaf });
const denoLabel = screen.style({ fg: 0x70ffaf });
const valStyle = screen.style({ fg: 0xabb2bf });
const unitStyle = screen.style({ fg: 0x5c6370 });
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
const benchmarks = [
{
name: "HTTP req/s",
unit: "req/s",
results: [
{ runtime: "Bun", value: 112400, style: bunStyle, label: bunLabel },
{ runtime: "Node", value: 47200, style: nodeStyle, label: nodeLabel },
{ runtime: "Deno", value: 68300, style: denoStyle, label: denoLabel },
],
},
{
name: "FFI calls/s",
unit: "calls/s",
results: [
{ runtime: "Bun", value: 320000, style: bunStyle, label: bunLabel },
{ runtime: "Node", value: 89000, style: nodeStyle, label: nodeLabel },
{ runtime: "Deno", value: 142000, style: denoStyle, label: denoLabel },
],
},
{
name: "File read MB/s",
unit: "MB/s",
results: [
{ runtime: "Bun", value: 3800, style: bunStyle, label: bunLabel },
{ runtime: "Node", value: 1200, style: nodeStyle, label: nodeLabel },
{ runtime: "Deno", value: 1900, style: denoStyle, label: denoLabel },
],
},
{
name: "Startup (ms)",
unit: "ms",
results: [
{ runtime: "Bun", value: 7, style: bunStyle, label: bunLabel },
{ runtime: "Node", value: 35, style: nodeStyle, label: nodeLabel },
{ runtime: "Deno", value: 24, style: denoStyle, label: denoLabel },
],
},
];
function formatNum(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return n.toString();
}
// Title
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: dimStyle, fill: true, fillChar: " " });
const title = "JavaScript Runtime Benchmarks";
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
// Legend
let ly = 3;
screen.setText(4, ly, "\u2588 Bun", bunLabel);
screen.setText(16, ly, "\u2588 Node.js", nodeLabel);
screen.setText(30, ly, "\u2588 Deno", denoLabel);
const labelCol = 2;
const barStart = 20;
const maxBarWidth = width - barStart - 12;
let y = 5;
for (const bench of benchmarks) {
screen.setText(labelCol, y, bench.name, headerStyle);
y++;
const maxVal = Math.max(...bench.results.map(r => r.value));
for (const result of bench.results) {
const barLen = Math.max(1, Math.round((result.value / maxVal) * maxBarWidth));
screen.setText(labelCol + 2, y, result.runtime.padEnd(6), result.label);
screen.fill(barStart, y, barLen, 1, "\u2588", result.style);
const valStr = ` ${formatNum(result.value)}`;
screen.setText(barStart + barLen, y, valStr, valStyle);
screen.setText(barStart + barLen + valStr.length, y, ` ${bench.unit}`, unitStyle);
y++;
}
y++; // gap between benchmarks
}
// Footer note
screen.setText(2, y, "* Lower is better for Startup", dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,63 @@
/**
* demo-box-styles.ts — Shows all 5 box drawing styles (single, double, rounded, heavy, ascii)
* side by side with labels inside each box.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 78);
const height = 12;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const boxStyles: { name: string; style: "single" | "double" | "rounded" | "heavy" | "ascii"; color: number }[] = [
{ name: "single", style: "single", color: 0x61afef },
{ name: "double", style: "double", color: 0x98c379 },
{ name: "rounded", style: "rounded", color: 0xe5c07b },
{ name: "heavy", style: "heavy", color: 0xe06c75 },
{ name: "ascii", style: "ascii", color: 0xc678dd },
];
screen.setText(2, 0, "Box Drawing Styles", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
const boxWidth = 14;
const boxHeight = 7;
const gap = 1;
const startY = 3;
// Calculate start X to center the boxes
const totalWidth = boxStyles.length * boxWidth + (boxStyles.length - 1) * gap;
const startX = Math.max(1, Math.floor((width - totalWidth) / 2));
for (let i = 0; i < boxStyles.length; i++) {
const bs = boxStyles[i];
const x = startX + i * (boxWidth + gap);
const borderStyle = screen.style({ fg: bs.color });
const labelStyle2 = screen.style({ fg: bs.color, bold: true });
screen.drawBox(x, startY, boxWidth, boxHeight, {
style: bs.style,
styleId: borderStyle,
fill: true,
fillChar: " ",
});
// Label centered inside the box
const labelX = x + Math.floor((boxWidth - bs.name.length) / 2);
screen.setText(labelX, startY + 2, bs.name, labelStyle2);
// Show a sample character
const sampleStyle = screen.style({ fg: bs.color, faint: true });
screen.setText(x + 2, startY + 4, "Hello!", sampleStyle);
}
// Footer note
screen.setText(2, startY + boxHeight + 1, "Each box uses a different border drawing style", dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,85 @@
/**
* demo-calendar.ts — Renders the current month as a calendar grid with
* today highlighted. Uses drawBox for the border, styled day numbers.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = 30;
const height = 12;
const screen = new Bun.TUIScreen(width, height);
// Styles
const borderStyle = screen.style({ fg: 0x3e4452 });
const titleStyle = screen.style({ fg: 0x61afef, bold: true });
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
const dayStyle = screen.style({ fg: 0xabb2bf });
const todayStyle = screen.style({ fg: 0x282c34, bg: 0x98c379, bold: true });
const weekendStyle = screen.style({ fg: 0x5c6370 });
const dimStyle = screen.style({ fg: 0x3e4452 });
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const today = now.getDate();
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
// First day of month (0=Sun, 6=Sat)
const firstDay = new Date(year, month, 1).getDay();
// Days in month
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Draw border
screen.drawBox(0, 0, width, height, { style: "rounded", styleId: borderStyle });
// Month + Year title
const title = `${monthNames[month]} ${year}`;
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
// Day headers
const dayHeaders = "Su Mo Tu We Th Fr Sa";
screen.setText(2, 3, dayHeaders, headerStyle);
// Separator
screen.setText(2, 4, "\u2500".repeat(width - 4), dimStyle);
// Render days
let row = 5;
let col = firstDay;
for (let d = 1; d <= daysInMonth; d++) {
const x = 2 + col * 3;
const str = String(d).padStart(2, " ");
let style = dayStyle;
if (d === today) {
style = todayStyle;
} else if (col === 0 || col === 6) {
style = weekendStyle;
}
screen.setText(x, row, str, style);
col++;
if (col > 6) {
col = 0;
row++;
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,369 @@
/**
* demo-canvas.ts — Pixel Canvas with Half-Block Rendering
*
* A drawing canvas that uses Unicode half-block characters (▀▄█) to achieve
* double vertical resolution — each terminal cell represents 2 pixels.
* Includes shape tools, a color picker, and undo support.
*
* Demonstrates: half-block pixel rendering, fg+bg color combination per cell,
* efficient cell updates, style interning for pixel colors, setText, fill,
* style (fg/bg), drawBox, TUITerminalWriter, TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-canvas.ts
* Controls: Arrow keys to move cursor, Space to plot pixel, 1-8 select color,
* F fill area, C clear, U undo, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
border: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
label: screen.style({ fg: 0xabb2bf }),
value: screen.style({ fg: 0xe5c07b, bold: true }),
cursor: screen.style({ fg: 0xffffff, bold: true }),
dim: screen.style({ fg: 0x5c6370 }),
swatch: screen.style({ fg: 0xe5c07b, bold: true }),
};
// --- Palette ---
const palette = [
0x000000, // Black
0xff5555, // Red
0x55ff55, // Green
0x5555ff, // Blue
0xffff55, // Yellow
0xff55ff, // Magenta
0x55ffff, // Cyan
0xffffff, // White
];
const paletteNames = ["Black", "Red", "Green", "Blue", "Yellow", "Magenta", "Cyan", "White"];
// --- Canvas state ---
const HEADER_H = 1;
const SIDEBAR_W = 16;
const FOOTER_H = 1;
function canvasCellW() {
return Math.max(4, cols - SIDEBAR_W - 2);
}
function canvasCellH() {
return Math.max(3, rows - HEADER_H - FOOTER_H - 2);
}
// Pixel dimensions (2x vertical resolution via half-blocks)
function canvasPixW() {
return canvasCellW();
}
function canvasPixH() {
return canvasCellH() * 2;
}
// Pixel buffer: 0 = transparent, 1-8 = palette index
let pixels: number[][] = [];
let cursorX = 0;
let cursorY = 0;
let selectedColor = 2; // Green
let undoStack: { x: number; y: number; oldColor: number }[][] = [];
let currentStroke: { x: number; y: number; oldColor: number }[] = [];
function initPixels() {
const w = canvasPixW();
const h = canvasPixH();
pixels = [];
for (let y = 0; y < h; y++) {
pixels.push(new Array(w).fill(0));
}
cursorX = Math.min(cursorX, w - 1);
cursorY = Math.min(cursorY, h - 1);
}
function getPixel(x: number, y: number): number {
if (y < 0 || y >= pixels.length || x < 0 || x >= (pixels[0]?.length ?? 0)) return 0;
return pixels[y][x];
}
function setPixel(x: number, y: number, color: number) {
if (y < 0 || y >= pixels.length || x < 0 || x >= (pixels[0]?.length ?? 0)) return;
const old = pixels[y][x];
if (old !== color) {
currentStroke.push({ x, y, oldColor: old });
pixels[y][x] = color;
}
}
function commitStroke() {
if (currentStroke.length > 0) {
undoStack.push(currentStroke);
if (undoStack.length > 100) undoStack.shift();
currentStroke = [];
}
}
function undo() {
const stroke = undoStack.pop();
if (!stroke) return;
for (const { x, y, oldColor } of stroke) {
if (y >= 0 && y < pixels.length && x >= 0 && x < pixels[0].length) {
pixels[y][x] = oldColor;
}
}
}
// Draw a built-in pattern
function drawPattern() {
const w = canvasPixW();
const h = canvasPixH();
// Draw a simple Bun logo-ish shape
const cx = Math.floor(w / 2);
const cy = Math.floor(h / 2);
const r = Math.min(Math.floor(w / 4), Math.floor(h / 4));
// Circle
for (let angle = 0; angle < 360; angle += 2) {
const rad = (angle * Math.PI) / 180;
const px = Math.round(cx + r * Math.cos(rad));
const py = Math.round(cy + r * Math.sin(rad));
setPixel(px, py, 5); // Yellow
}
// Fill center with a pattern
for (let dy = -r + 2; dy < r - 2; dy++) {
for (let dx = -r + 2; dx < r - 2; dx++) {
if (dx * dx + dy * dy < (r - 2) * (r - 2)) {
const px = cx + dx;
const py = cy + dy;
if ((px + py) % 3 === 0) setPixel(px, py, 3); // Green
}
}
}
// Eyes
setPixel(cx - 3, cy - 2, 1); // Black eye
setPixel(cx + 3, cy - 2, 1);
// Smile
for (let dx = -2; dx <= 2; dx++) {
setPixel(cx + dx, cy + 3, 1);
}
commitStroke();
}
// --- Render ---
function render() {
screen.clear();
const cellW = canvasCellW();
const cellH = canvasCellH();
const ox = 1; // canvas offset X
const oy = HEADER_H + 1; // canvas offset Y
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Pixel Canvas ", st.titleBar);
// Border around canvas
screen.drawBox(ox - 1, oy - 1, cellW + 2, cellH + 2, { style: "rounded", styleId: st.border });
// Render pixels using half-block characters
// Each cell row represents 2 pixel rows: top pixel = fg, bottom pixel = bg
for (let cy = 0; cy < cellH; cy++) {
const py0 = cy * 2; // top pixel row
const py1 = cy * 2 + 1; // bottom pixel row
for (let cx = 0; cx < cellW; cx++) {
const top = getPixel(cx, py0);
const bot = getPixel(cx, py1);
if (top === 0 && bot === 0) {
// Both transparent — skip (already cleared)
continue;
}
let ch: string;
let fg: number;
let bg: number;
if (top !== 0 && bot !== 0) {
// Both pixels set: use upper half block, fg=top color, bg=bottom color
ch = "\u2580"; // ▀
fg = palette[top - 1] ?? 0xffffff;
bg = palette[bot - 1] ?? 0xffffff;
} else if (top !== 0) {
// Only top pixel: use upper half block with fg=top, bg=transparent(black)
ch = "\u2580"; // ▀
fg = palette[top - 1] ?? 0xffffff;
bg = 0x1a1a1a;
} else {
// Only bottom pixel: use lower half block with fg=bottom
ch = "\u2584"; // ▄
fg = palette[bot - 1] ?? 0xffffff;
bg = 0x1a1a1a;
}
const sid = screen.style({ fg, bg });
screen.setText(ox + cx, oy + cy, ch, sid);
}
}
// Draw cursor
const cursorCellX = cursorX;
const cursorCellY = Math.floor(cursorY / 2);
const isTopHalf = cursorY % 2 === 0;
if (cursorCellX >= 0 && cursorCellX < cellW && cursorCellY >= 0 && cursorCellY < cellH) {
// Use a crosshair indicator
const cursorChar = isTopHalf ? "\u2580" : "\u2584"; // ▀ or ▄
const curColor = palette[selectedColor - 1] ?? 0xffffff;
screen.setText(ox + cursorCellX, oy + cursorCellY, cursorChar, screen.style({ fg: curColor, bg: 0x333333 }));
}
// --- Sidebar ---
const sx = ox + cellW + 2;
if (sx + SIDEBAR_W <= cols) {
let sy = oy;
screen.setText(sx, sy, "Cursor", st.label);
sy++;
screen.setText(sx, sy, `(${cursorX}, ${cursorY})`, st.value);
sy += 2;
screen.setText(sx, sy, "Color", st.label);
sy++;
// Color palette
for (let i = 0; i < palette.length; i++) {
const c = palette[i];
const isActive = i + 1 === selectedColor;
const indicator = isActive ? "\u25b6" : " ";
const indStyle = isActive ? st.swatch : st.dim;
screen.setText(sx, sy, indicator, indStyle);
screen.fill(sx + 2, sy, 2, 1, " ", screen.style({ bg: c }));
const label = `${i + 1}:${paletteNames[i]}`;
screen.setText(sx + 5, sy, label.slice(0, SIDEBAR_W - 6), isActive ? st.value : st.dim);
sy++;
}
sy++;
screen.setText(sx, sy, "Canvas", st.label);
sy++;
screen.setText(sx, sy, `${canvasPixW()}x${canvasPixH()}px`, st.value);
sy++;
const pixCount = pixels.flat().filter(p => p !== 0).length;
screen.setText(sx, sy, `${pixCount} pixels`, st.dim);
sy++;
screen.setText(sx, sy, `${undoStack.length} undos`, st.dim);
}
// Footer
const footerText = " Arrows:Move Space:Draw 1-8:Color F:Fill C:Clear U:Undo D:Demo q:Quit ";
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
const pw = canvasPixW();
const ph = canvasPixH();
switch (name) {
case "up":
case "k":
cursorY = Math.max(0, cursorY - 1);
break;
case "down":
case "j":
cursorY = Math.min(ph - 1, cursorY + 1);
break;
case "left":
case "h":
cursorX = Math.max(0, cursorX - 1);
break;
case "right":
case "l":
cursorX = Math.min(pw - 1, cursorX + 1);
break;
case " ":
setPixel(cursorX, cursorY, selectedColor);
commitStroke();
break;
case "x":
// Erase pixel
setPixel(cursorX, cursorY, 0);
commitStroke();
break;
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
selectedColor = parseInt(name);
break;
case "f":
// Fill entire canvas with selected color
for (let py = 0; py < ph; py++) {
for (let px = 0; px < pw; px++) {
setPixel(px, py, selectedColor);
}
}
commitStroke();
break;
case "c":
// Clear canvas
for (let py = 0; py < ph; py++) {
for (let px = 0; px < pw; px++) {
setPixel(px, py, 0);
}
}
commitStroke();
break;
case "u":
undo();
break;
case "d":
drawPattern();
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
initPixels();
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
initPixels();
render();

View File

@@ -0,0 +1,109 @@
/**
* demo-changelog.ts — Styled changelog output with version headers,
* categorized entries (Added, Fixed, Changed, Removed) with colored bullets.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 68);
const height = 36;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const versionStyle = screen.style({ fg: 0x61afef, bold: true });
const dateStyle = screen.style({ fg: 0x5c6370 });
const dimStyle = screen.style({ fg: 0x3e4452 });
const addedTag = screen.style({ fg: 0x282c34, bg: 0x98c379, bold: true });
const addedBullet = screen.style({ fg: 0x98c379 });
const fixedTag = screen.style({ fg: 0x282c34, bg: 0x61afef, bold: true });
const fixedBullet = screen.style({ fg: 0x61afef });
const changedTag = screen.style({ fg: 0x282c34, bg: 0xe5c07b, bold: true });
const changedBullet = screen.style({ fg: 0xe5c07b });
const removedTag = screen.style({ fg: 0x282c34, bg: 0xe06c75, bold: true });
const removedBullet = screen.style({ fg: 0xe06c75 });
const textStyle = screen.style({ fg: 0xabb2bf });
interface ChangeEntry {
category: "Added" | "Fixed" | "Changed" | "Removed";
text: string;
}
interface VersionEntry {
version: string;
date: string;
changes: ChangeEntry[];
}
const changelog: VersionEntry[] = [
{
version: "v1.2.0",
date: "2025-01-15",
changes: [
{ category: "Added", text: "TUI screen rendering API" },
{ category: "Added", text: "True color support in terminal writer" },
{ category: "Fixed", text: "Wide character handling in fill()" },
{ category: "Changed", text: "Style capacity increased to 4096" },
],
},
{
version: "v1.1.5",
date: "2024-12-20",
changes: [
{ category: "Fixed", text: "ZWJ emoji clustering in setText()" },
{ category: "Fixed", text: "BufferedWriter double-free on close" },
{ category: "Removed", text: "Deprecated Screen.render() method" },
],
},
{
version: "v1.1.0",
date: "2024-11-30",
changes: [
{ category: "Added", text: "Box drawing with 5 border styles" },
{ category: "Added", text: "Clipping rectangle stack" },
{ category: "Changed", text: "Renamed Bun.Screen to Bun.TUIScreen" },
{ category: "Fixed", text: "Resize preserves existing content" },
],
},
];
const tagStyles: Record<string, { tag: number; bullet: number }> = {
Added: { tag: addedTag, bullet: addedBullet },
Fixed: { tag: fixedTag, bullet: fixedBullet },
Changed: { tag: changedTag, bullet: changedBullet },
Removed: { tag: removedTag, bullet: removedBullet },
};
// Title
screen.setText(2, 0, "CHANGELOG", titleStyle);
screen.setText(2, 1, "\u2550".repeat(width - 4), dimStyle);
let y = 3;
for (const ver of changelog) {
// Version header
screen.setText(2, y, ver.version, versionStyle);
screen.setText(2 + ver.version.length + 1, y, `(${ver.date})`, dateStyle);
y++;
screen.setText(2, y, "\u2500".repeat(width - 4), dimStyle);
y++;
for (const change of ver.changes) {
const styles = tagStyles[change.category];
// Category tag
const tag = ` ${change.category} `;
screen.setText(4, y, tag, styles.tag);
// Bullet and text
const textX = 4 + tag.length + 1;
screen.setText(textX, y, "\u2022", styles.bullet);
screen.setText(textX + 2, y, change.text, textStyle);
y++;
}
y++; // gap between versions
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,336 @@
/**
* demo-chart.ts — Terminal Charts & Sparklines
*
* Live-updating bar charts, sparklines, and a horizontal histogram
* with simulated real-time data feeds.
*
* Demonstrates: setInterval animation, dynamic data, fill, setText,
* style (fg/bg/bold), drawBox, mathematical layout, TUITerminalWriter,
* TUIKeyReader, alt screen, resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-chart.ts
* Controls: 1-3 to switch views, Space to pause, R to reset, Q / Ctrl+C to quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
header: screen.style({ fg: 0x61afef, bold: true }),
label: screen.style({ fg: 0xabb2bf }),
value: screen.style({ fg: 0xe5c07b, bold: true }),
dim: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
border: screen.style({ fg: 0x5c6370 }),
axis: screen.style({ fg: 0x5c6370 }),
barGreen: screen.style({ bg: 0x98c379 }),
barBlue: screen.style({ bg: 0x61afef }),
barYellow: screen.style({ bg: 0xe5c07b }),
barRed: screen.style({ bg: 0xe06c75 }),
barCyan: screen.style({ bg: 0x56b6c2 }),
barMagenta: screen.style({ bg: 0xc678dd }),
sparkHigh: screen.style({ fg: 0x98c379 }),
sparkMid: screen.style({ fg: 0xe5c07b }),
sparkLow: screen.style({ fg: 0xe06c75 }),
tabActive: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
};
const barColors = [st.barGreen, st.barBlue, st.barYellow, st.barRed, st.barCyan, st.barMagenta];
// --- Sparkline characters (Unicode block elements, 8 levels) ---
const SPARK = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
// --- Data ---
const MAX_HISTORY = 120;
const series: { name: string; data: number[]; color: number }[] = [
{ name: "CPU", data: [], color: 0 },
{ name: "Memory", data: [], color: 1 },
{ name: "Network", data: [], color: 2 },
{ name: "Disk I/O", data: [], color: 3 },
{ name: "Requests", data: [], color: 4 },
{ name: "Latency", data: [], color: 5 },
];
// Simulated data generators
function genCPU(prev: number): number {
return Math.max(0, Math.min(100, prev + (Math.random() - 0.48) * 15));
}
function genMemory(prev: number): number {
return Math.max(20, Math.min(95, prev + (Math.random() - 0.5) * 3));
}
function genNetwork(prev: number): number {
return Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 20));
}
function genDiskIO(prev: number): number {
return Math.max(0, Math.min(100, prev + (Math.random() - 0.5) * 8));
}
function genRequests(prev: number): number {
return Math.max(0, Math.min(100, prev + (Math.random() - 0.47) * 25));
}
function genLatency(prev: number): number {
return Math.max(5, Math.min(100, prev + (Math.random() - 0.5) * 12));
}
const generators = [genCPU, genMemory, genNetwork, genDiskIO, genRequests, genLatency];
// --- State ---
let paused = false;
let activeView = 0; // 0=sparklines, 1=bar chart, 2=histogram
let tickCount = 0;
function addDataPoint() {
for (let i = 0; i < series.length; i++) {
const prev = series[i].data.length > 0 ? series[i].data[series[i].data.length - 1] : 50;
series[i].data.push(generators[i](prev));
if (series[i].data.length > MAX_HISTORY) series[i].data.shift();
}
}
function resetData() {
for (const s of series) s.data = [];
tickCount = 0;
}
// Initialize with some data
for (let i = 0; i < 60; i++) addDataPoint();
// --- Render helpers ---
function drawSparkline(x: number, y: number, width: number, data: number[], min: number, max: number) {
const range = max - min || 1;
const start = Math.max(0, data.length - width);
for (let i = 0; i < width; i++) {
const di = start + i;
if (di >= data.length) break;
const normalized = (data[di] - min) / range;
const level = Math.round(normalized * 8);
const ch = SPARK[Math.max(0, Math.min(8, level))];
const style = normalized > 0.7 ? st.sparkHigh : normalized > 0.3 ? st.sparkMid : st.sparkLow;
screen.setText(x + i, y, ch, style);
}
}
function drawBarChart(x: number, y: number, width: number, height: number) {
const barW = Math.max(1, Math.floor((width - 2) / series.length) - 1);
const gap = 1;
// Y-axis
for (let row = 0; row < height; row++) {
const val = Math.round(100 - (row / (height - 1)) * 100);
if (row === 0 || row === height - 1 || row === Math.floor(height / 2)) {
const label = String(val).padStart(3);
screen.setText(x, y + row, label, st.axis);
}
screen.setText(x + 4, y + row, "\u2502", st.axis); // │
}
// X-axis
for (let i = 0; i < width - 5; i++) {
screen.setText(x + 5 + i, y + height, "\u2500", st.axis); // ─
}
screen.setText(x + 4, y + height, "\u2514", st.axis); // └
// Bars
for (let si = 0; si < series.length; si++) {
const s = series[si];
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
const barH = Math.round((val / 100) * (height - 1));
const bx = x + 5 + si * (barW + gap);
// Fill bar from bottom
for (let row = 0; row < barH; row++) {
const by = y + height - 1 - row;
screen.fill(bx, by, barW, 1, " ", barColors[si % barColors.length]);
}
// Label below
const label = s.name.slice(0, barW + gap);
screen.setText(bx, y + height + 1, label, st.label);
// Value on top
const valStr = Math.round(val).toString();
screen.setText(bx, y + Math.max(0, height - 1 - barH - 1), valStr, st.value);
}
}
function drawHistogram(x: number, y: number, width: number, height: number) {
const barMaxW = width - 14; // leave room for labels and values
for (let si = 0; si < series.length && si < height; si++) {
const s = series[si];
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
const barW = Math.round((val / 100) * barMaxW);
const by = y + si * 2;
// Label
screen.setText(x, by, s.name.padEnd(10).slice(0, 10), st.label);
// Bar
if (barW > 0) {
screen.fill(x + 10, by, barW, 1, " ", barColors[si % barColors.length]);
}
// Value
const valStr = `${Math.round(val)}%`;
screen.setText(x + 10 + barMaxW + 1, by, valStr.padStart(4), st.value);
}
}
// --- Main render ---
function render() {
screen.clear();
// Title
screen.fill(0, 0, cols, 1, " ", st.titleBar);
const title = " System Monitor ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, st.titleBar);
// Tabs
const tabs = ["Sparklines", "Bar Chart", "Histogram"];
let tx = 2;
for (let i = 0; i < tabs.length; i++) {
const label = ` ${i + 1}:${tabs[i]} `;
const style = i === activeView ? st.tabActive : st.tabInactive;
screen.setText(tx, 1, label, style);
tx += label.length + 1;
}
// Status
const statusText = paused ? "PAUSED" : `Tick: ${tickCount}`;
screen.setText(cols - statusText.length - 2, 1, statusText, paused ? st.sparkLow : st.dim);
const contentY = 3;
const contentH = rows - contentY - 2;
const contentW = cols - 4;
if (activeView === 0) {
// --- Sparklines view ---
const sparkW = Math.min(contentW - 16, MAX_HISTORY);
for (let si = 0; si < series.length; si++) {
const s = series[si];
const sy = contentY + si * 4;
if (sy + 2 >= rows - 2) break;
// Label and current value
const val = s.data.length > 0 ? s.data[s.data.length - 1] : 0;
screen.setText(2, sy, s.name.padEnd(10), st.label);
screen.setText(12, sy, `${Math.round(val)}%`, st.value);
// Min/max
if (s.data.length > 0) {
const min = Math.round(Math.min(...s.data));
const max = Math.round(Math.max(...s.data));
const avg = Math.round(s.data.reduce((a, b) => a + b, 0) / s.data.length);
screen.setText(18, sy, `min:${min} avg:${avg} max:${max}`, st.dim);
}
// Sparkline
drawSparkline(2, sy + 1, sparkW, s.data, 0, 100);
// Separator
if (si < series.length - 1) {
for (let i = 0; i < sparkW + 2; i++) {
screen.setText(1 + i, sy + 2, "\u2500", st.dim);
}
}
}
} else if (activeView === 1) {
// --- Bar chart view ---
screen.setText(2, contentY, "Current Values", st.header);
drawBarChart(2, contentY + 1, contentW, Math.min(contentH - 4, 20));
} else {
// --- Histogram view ---
screen.setText(2, contentY, "Horizontal Bars", st.header);
drawHistogram(2, contentY + 1, contentW, Math.min(contentH - 2, series.length * 2));
// Add a mini sparkline section below
const histH = series.length * 2 + 2;
if (contentY + histH + 6 < rows - 2) {
screen.setText(2, contentY + histH, "Trend (last 60s)", st.header);
for (let si = 0; si < Math.min(series.length, 3); si++) {
const s = series[si];
const sy = contentY + histH + 1 + si * 2;
screen.setText(2, sy, s.name.padEnd(10), st.label);
drawSparkline(12, sy, Math.min(contentW - 14, 60), s.data, 0, 100);
}
}
}
// Footer
const footerY = rows - 1;
const footerText = " 1-3: Switch view | Space: Pause | R: Reset | Q: Quit ";
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "1":
activeView = 0;
break;
case "2":
activeView = 1;
break;
case "3":
activeView = 2;
break;
case " ":
paused = !paused;
break;
case "r":
resetData();
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Animation ---
const timer = setInterval(() => {
if (!paused) {
addDataPoint();
tickCount++;
}
render();
}, 200); // 5 fps
render();

View File

@@ -0,0 +1,362 @@
/**
* demo-chat.ts — Chat Interface
*
* A polished chat interface with message bubbles, timestamps, an input field
* with a blinking cursor, typing indicators, and a simulated bot.
*
* Run: bun run test/js/bun/tui/demos/demo-chat.ts
* Controls: Type to compose, Enter to send, Up/Down to scroll, Ctrl+C to quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
writer.enableBracketedPaste();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
myBg: screen.style({ fg: 0xffffff, bg: 0x2d5a9e }),
myName: screen.style({ fg: 0xa8d0ff, bg: 0x2d5a9e, bold: true }),
myTime: screen.style({ fg: 0x7cacd6, bg: 0x2d5a9e }),
theirBg: screen.style({ fg: 0xdcdcdc, bg: 0x383838 }),
theirName: screen.style({ fg: 0x98c379, bg: 0x383838, bold: true }),
theirTime: screen.style({ fg: 0x777777, bg: 0x383838 }),
systemMsg: screen.style({ fg: 0x5c6370, italic: true }),
inputBorder: screen.style({ fg: 0x61afef }),
inputText: screen.style({ fg: 0xffffff }),
inputPlaceholder: screen.style({ fg: 0x5c6370, italic: true }),
online: screen.style({ fg: 0x98c379, bold: true }),
typing: screen.style({ fg: 0xe5c07b, italic: true }),
};
// --- Data ---
interface Message {
sender: string;
text: string;
time: Date;
isMe: boolean;
isSystem?: boolean;
}
const messages: Message[] = [
{ sender: "", text: "Welcome to Bun TUI Chat!", time: new Date(), isMe: false, isSystem: true },
{
sender: "Bun Bot",
text: "Hey! I'm a demo bot. Try sending me a message!",
time: new Date(Date.now() - 60000),
isMe: false,
},
{
sender: "Bun Bot",
text: "This chat is built with Bun.TUIScreen and Bun.TUIKeyReader. The message bubbles use fill() for backgrounds and setText() for content.",
time: new Date(Date.now() - 30000),
isMe: false,
},
];
let inputText = "";
let inputCursor = 0;
let scrollOffset = 0;
let typingTimer: ReturnType<typeof setTimeout> | null = null;
let showTyping = false;
const botReplies = [
"That's cool! The TUI library uses Ghostty's cell grid internally.",
"Try resizing the terminal — the layout adapts automatically!",
"The diff renderer only updates changed cells. Super efficient!",
"Each style is interned with a numeric ID. Up to 4096 unique styles.",
"Did you know? The renderer uses synchronized update markers to prevent flicker.",
"Mouse tracking is supported too — check out demo-mouse!",
"You can use hyperlinks in the terminal with screen.hyperlink()!",
"Nice message! The word wrapping handles long text gracefully.",
];
function formatTime(d: Date): string {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function wrapText(text: string, width: number): string[] {
if (width <= 0) return [text];
const lines: string[] = [];
for (const paragraph of text.split("\n")) {
const words = paragraph.split(" ");
let cur = "";
for (const word of words) {
if (cur.length === 0) {
cur = word;
} else if (cur.length + 1 + word.length <= width) {
cur += " " + word;
} else {
lines.push(cur);
cur = word;
}
}
if (cur.length > 0) lines.push(cur);
}
return lines.length > 0 ? lines : [""];
}
function sendMessage(text: string) {
if (text.trim().length === 0) return;
messages.push({ sender: "You", text: text.trim(), time: new Date(), isMe: true });
inputText = "";
inputCursor = 0;
scrollOffset = 0;
showTyping = true;
render();
typingTimer = setTimeout(
() => {
showTyping = false;
messages.push({
sender: "Bun Bot",
text: botReplies[Math.floor(Math.random() * botReplies.length)],
time: new Date(),
isMe: false,
});
scrollOffset = 0;
render();
},
600 + Math.random() * 1000,
);
}
// --- Render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Chat ", st.titleBar);
const onlineText = "\u25cf Online";
screen.setText(cols - onlineText.length - 2, 0, onlineText, st.online);
// Input box (bottom 3 rows)
const inputY = rows - 3;
screen.drawBox(0, inputY, cols, 3, { style: "rounded", styleId: st.inputBorder });
if (inputText.length > 0) {
screen.setText(2, inputY + 1, inputText.slice(0, cols - 4), st.inputText);
} else {
screen.setText(2, inputY + 1, "Type a message...", st.inputPlaceholder);
}
// Typing indicator
const typingY = showTyping ? inputY - 1 : inputY;
if (showTyping) {
screen.setText(2, inputY - 1, "Bun Bot is typing...", st.typing);
}
// Message area
const msgTop = 1;
const msgBot = typingY;
const msgH = msgBot - msgTop;
if (msgH <= 0) {
writer.render(screen, {
cursorVisible: true,
cursorX: 2 + inputCursor,
cursorY: inputY + 1,
cursorStyle: "line",
cursorBlinking: true,
});
return;
}
const bubbleMaxW = Math.min(Math.floor(cols * 0.7), cols - 6);
// Build rendered lines for all messages
interface RenderedLine {
x: number;
width: number;
text: string;
style: number;
}
const allLines: RenderedLine[][] = []; // each message = array of rendered lines
for (const msg of messages) {
const msgLines: RenderedLine[] = [];
if (msg.isSystem) {
const line = `\u2500\u2500 ${msg.text} \u2500\u2500`;
msgLines.push({
x: Math.max(0, Math.floor((cols - line.length) / 2)),
width: line.length,
text: line,
style: st.systemMsg,
});
} else {
const textW = bubbleMaxW - 4;
const wrapped = wrapText(msg.text, textW);
const contentW = Math.max(...wrapped.map(l => l.length), msg.sender.length + formatTime(msg.time).length + 3);
const bubbleW = Math.min(bubbleMaxW, contentW + 4);
const bx = msg.isMe ? cols - bubbleW - 1 : 1;
const bgSt = msg.isMe ? st.myBg : st.theirBg;
const nameSt = msg.isMe ? st.myName : st.theirName;
const timeSt = msg.isMe ? st.myTime : st.theirTime;
// Name + time line (with bg fill)
const timeStr = formatTime(msg.time);
msgLines.push({ x: bx, width: bubbleW, text: `__BG__`, style: bgSt }); // background marker
msgLines[msgLines.length - 1] = { x: bx, width: bubbleW, text: "", style: bgSt }; // fill bg
// We'll render name and time separately
msgLines.push({ x: bx + 2, width: 0, text: msg.sender, style: nameSt });
msgLines.push({ x: bx + bubbleW - timeStr.length - 2, width: 0, text: timeStr, style: timeSt });
// Text lines
for (const line of wrapped) {
msgLines.push({ x: bx, width: bubbleW, text: "", style: bgSt }); // bg
msgLines.push({ x: bx + 2, width: 0, text: line, style: bgSt }); // text
}
}
allLines.push(msgLines);
}
// Calculate per-message rendered heights
const msgRenderedH: number[] = messages.map((msg, i) => {
if (msg.isSystem) return 1;
const textW = bubbleMaxW - 4;
return wrapText(msg.text, textW).length + 1; // +1 for name row
});
// Render from bottom up with scroll
let drawY = msgBot - 1 + scrollOffset;
for (let mi = messages.length - 1; mi >= 0; mi--) {
const msg = messages[mi];
const h = msgRenderedH[mi];
const topRow = drawY - h + 1;
if (topRow < msgBot && drawY >= msgTop) {
if (msg.isSystem) {
if (drawY >= msgTop && drawY < msgBot) {
const line = `\u2500\u2500 ${msg.text} \u2500\u2500`;
screen.setText(Math.max(0, Math.floor((cols - line.length) / 2)), drawY, line, st.systemMsg);
}
} else {
const textW = bubbleMaxW - 4;
const wrapped = wrapText(msg.text, textW);
const contentW = Math.max(...wrapped.map(l => l.length), msg.sender.length + formatTime(msg.time).length + 3);
const bubbleW = Math.min(bubbleMaxW, contentW + 4);
const bx = msg.isMe ? cols - bubbleW - 1 : 1;
const bgSt = msg.isMe ? st.myBg : st.theirBg;
const nameSt = msg.isMe ? st.myName : st.theirName;
const timeSt = msg.isMe ? st.myTime : st.theirTime;
// Fill bubble background
for (let r = 0; r < h; r++) {
const ry = topRow + r;
if (ry >= msgTop && ry < msgBot) {
screen.fill(bx, ry, bubbleW, 1, " ", bgSt);
}
}
// Name + time
if (topRow >= msgTop && topRow < msgBot) {
screen.setText(bx + 2, topRow, msg.sender, nameSt);
const timeStr = formatTime(msg.time);
screen.setText(bx + bubbleW - timeStr.length - 2, topRow, timeStr, timeSt);
}
// Text
for (let li = 0; li < wrapped.length; li++) {
const ry = topRow + 1 + li;
if (ry >= msgTop && ry < msgBot) {
screen.setText(bx + 2, ry, wrapped[li], bgSt);
}
}
}
}
drawY = topRow - 1; // 1-line gap between messages
}
writer.render(screen, {
cursorX: 2 + Math.min(inputCursor, cols - 4),
cursorY: inputY + 1,
cursorVisible: true,
cursorStyle: "line",
cursorBlinking: true,
});
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
const { name, ctrl, alt } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
switch (name) {
case "enter":
sendMessage(inputText);
break;
case "backspace":
if (inputCursor > 0) {
inputText = inputText.slice(0, inputCursor - 1) + inputText.slice(inputCursor);
inputCursor--;
}
break;
case "delete":
if (inputCursor < inputText.length)
inputText = inputText.slice(0, inputCursor) + inputText.slice(inputCursor + 1);
break;
case "left":
if (inputCursor > 0) inputCursor--;
break;
case "right":
if (inputCursor < inputText.length) inputCursor++;
break;
case "home":
inputCursor = 0;
break;
case "end":
inputCursor = inputText.length;
break;
case "up":
scrollOffset += 2;
break;
case "down":
scrollOffset = Math.max(0, scrollOffset - 2);
break;
default:
if (!ctrl && !alt && name.length === 1) {
inputText = inputText.slice(0, inputCursor) + name + inputText.slice(inputCursor);
inputCursor++;
}
break;
}
render();
};
reader.onpaste = (text: string) => {
const line = text.split("\n")[0];
inputText = inputText.slice(0, inputCursor) + line + inputText.slice(inputCursor);
inputCursor += line.length;
render();
};
writer.onresize = (c: number, r: number) => {
cols = c;
rows = r;
screen.resize(cols, rows);
render();
};
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
if (typingTimer) clearTimeout(typingTimer);
writer.disableBracketedPaste();
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
render();

View File

@@ -0,0 +1,473 @@
/**
* demo-colors.ts — Color Palette Showcase
*
* Displays the full range of terminal styling capabilities:
* - All 256 indexed-style colors (via true color approximation)
* - True color gradients (RGB ramps)
* - All text attributes (bold, italic, underline variants, strikethrough, etc.)
* - Box drawing styles (single, double, rounded, heavy)
*
* Demonstrates: style (fg, bg, bold, italic, faint, blink, inverse, invisible,
* strikethrough, overline, underline variants, underlineColor), fill, setText,
* drawBox, TUITerminalWriter, TUIKeyReader.
*
* Run: bun run test/js/bun/tui/demos/demo-colors.ts
* Exit: Press 'q' or Ctrl+C
*/
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
let scrollY = 0;
writer.enterAltScreen();
// --- Styles ---
const headerStyle = screen.style({ fg: 0x61afef, bold: true });
const subheaderStyle = screen.style({ fg: 0xe5c07b, bold: true });
const labelStyle = screen.style({ fg: 0xabb2bf });
const dimStyle = screen.style({ fg: 0x5c6370 });
const footerStyle = screen.style({ fg: 0x5c6370, italic: true });
// --- Standard 16-color palette (approximate RGB values) ---
const ansi16: number[] = [
0x000000,
0xcc0000,
0x00cc00,
0xcccc00,
0x0000cc,
0xcc00cc,
0x00cccc,
0xcccccc, // normal
0x555555,
0xff5555,
0x55ff55,
0xffff55,
0x5555ff,
0xff55ff,
0x55ffff,
0xffffff, // bright
];
// --- Build the "virtual canvas" content, then render the visible window ---
// We build an array of draw commands so we can scroll through them.
interface Section {
title: string;
height: number;
draw: (startX: number, startY: number, width: number) => void;
}
const sections: Section[] = [];
// --- Section 1: Text Attributes ---
sections.push({
title: "Text Attributes",
height: 14,
draw(x, y, _w) {
const attrs: { label: string; opts: Record<string, any> }[] = [
{ label: "Bold", opts: { bold: true } },
{ label: "Italic", opts: { italic: true } },
{ label: "Faint (Dim)", opts: { faint: true } },
{ label: "Underline (single)", opts: { underline: "single" } },
{ label: "Underline (double)", opts: { underline: "double" } },
{ label: "Underline (curly)", opts: { underline: "curly" } },
{ label: "Underline (dotted)", opts: { underline: "dotted" } },
{ label: "Underline (dashed)", opts: { underline: "dashed" } },
{ label: "Strikethrough", opts: { strikethrough: true } },
{ label: "Overline", opts: { overline: true } },
{ label: "Inverse", opts: { inverse: true } },
{ label: "Blink", opts: { blink: true } },
{ label: "Bold + Italic + Underline", opts: { bold: true, italic: true, underline: "single" } },
];
for (let i = 0; i < attrs.length; i++) {
const { label, opts } = attrs[i];
const sid = screen.style(opts as any);
screen.setText(x, y + i, ` ${label.padEnd(30)}`, labelStyle);
screen.setText(x + 32, y + i, "The quick brown fox", sid);
}
},
});
// --- Section 2: Underline with Color ---
sections.push({
title: "Colored Underlines",
height: 6,
draw(x, y, _w) {
const colors = [
{ label: "Red underline", color: 0xff0000 },
{ label: "Green underline", color: 0x00ff00 },
{ label: "Blue underline", color: 0x0000ff },
{ label: "Yellow curly", color: 0xffff00 },
{ label: "Magenta dashed", color: 0xff00ff },
];
const ulTypes: Array<"single" | "single" | "single" | "curly" | "dashed"> = [
"single",
"single",
"single",
"curly",
"dashed",
];
for (let i = 0; i < colors.length; i++) {
const { label, color } = colors[i];
const sid = screen.style({
underline: ulTypes[i],
underlineColor: color,
fg: 0xffffff,
});
screen.setText(x, y + i, ` ${label.padEnd(22)}`, labelStyle);
screen.setText(x + 24, y + i, "Styled underline text", sid);
}
},
});
// --- Section 3: 16 Standard Colors ---
sections.push({
title: "Standard 16 Colors (Foreground)",
height: 3,
draw(x, y, _w) {
for (let i = 0; i < 16; i++) {
const sid = screen.style({ fg: ansi16[i] });
const colX = x + 2 + i * 4;
screen.setText(colX, y, `${String(i).padStart(2)} `, labelStyle);
screen.setText(colX, y + 1, "\u2588\u2588", sid); // full block characters
}
},
});
sections.push({
title: "Standard 16 Colors (Background)",
height: 3,
draw(x, y, _w) {
for (let i = 0; i < 16; i++) {
const sid = screen.style({ bg: ansi16[i], fg: i < 8 ? 0xffffff : 0x000000 });
const colX = x + 2 + i * 4;
screen.setText(colX, y, `${String(i).padStart(2)} `, labelStyle);
screen.fill(colX, y + 1, 3, 1, " ", sid);
screen.setText(colX, y + 1, `${String(i).padStart(2)}`, sid);
}
},
});
// --- Section 4: 6x6x6 Color Cube (216 colors, indices 16-231) ---
sections.push({
title: "216 Color Cube (6x6x6)",
height: 8,
draw(x, y, w) {
// Display as 6 rows of 36 colors
for (let row = 0; row < 6; row++) {
for (let col = 0; col < 36; col++) {
const idx = row * 36 + col;
// Convert 216-index to RGB
const r = Math.floor(idx / 36);
const g = Math.floor((idx % 36) / 6);
const b = idx % 6;
const rVal = r === 0 ? 0 : 55 + r * 40;
const gVal = g === 0 ? 0 : 55 + g * 40;
const bVal = b === 0 ? 0 : 55 + b * 40;
const rgb = (rVal << 16) | (gVal << 8) | bVal;
const sid = screen.style({ bg: rgb });
const colX = x + 2 + col * 2;
if (colX + 1 < x + w) {
screen.fill(colX, y + row, 2, 1, " ", sid);
}
}
}
// Label
screen.setText(x + 2, y + 7, "Rows: R(0-5) | Columns: G*6+B", dimStyle);
},
});
// --- Section 5: Grayscale Ramp (24 shades, indices 232-255) ---
sections.push({
title: "24-Step Grayscale Ramp",
height: 3,
draw(x, y, w) {
for (let i = 0; i < 24; i++) {
const v = 8 + i * 10; // 8, 18, 28, ... 238
const rgb = (v << 16) | (v << 8) | v;
const sid = screen.style({ bg: rgb });
const colX = x + 2 + i * 3;
if (colX + 2 < x + w) {
screen.fill(colX, y, 3, 1, " ", sid);
// Show hex value below for some
if (i % 4 === 0) {
const hex = v.toString(16).padStart(2, "0");
screen.setText(colX, y + 1, hex, dimStyle);
}
}
}
},
});
// --- Section 6: True Color Gradients ---
sections.push({
title: "True Color Gradients",
height: 8,
draw(x, y, w) {
const barWidth = Math.min(64, w - 4);
// Red gradient
screen.setText(x + 2, y, "R:", labelStyle);
for (let i = 0; i < barWidth; i++) {
const v = Math.round((i / (barWidth - 1)) * 255);
const sid = screen.style({ bg: v << 16 });
screen.fill(x + 4 + i, y, 1, 1, " ", sid);
}
// Green gradient
screen.setText(x + 2, y + 1, "G:", labelStyle);
for (let i = 0; i < barWidth; i++) {
const v = Math.round((i / (barWidth - 1)) * 255);
const sid = screen.style({ bg: v << 8 });
screen.fill(x + 4 + i, y + 1, 1, 1, " ", sid);
}
// Blue gradient
screen.setText(x + 2, y + 2, "B:", labelStyle);
for (let i = 0; i < barWidth; i++) {
const v = Math.round((i / (barWidth - 1)) * 255);
const sid = screen.style({ bg: v });
screen.fill(x + 4 + i, y + 2, 1, 1, " ", sid);
}
// Rainbow gradient (hue sweep)
screen.setText(x + 2, y + 4, "Rainbow:", labelStyle);
for (let i = 0; i < barWidth; i++) {
const hue = (i / barWidth) * 360;
const rgb = hslToRgb(hue, 1.0, 0.5);
const sid = screen.style({ bg: rgb });
screen.fill(x + 4 + i, y + 4, 1, 1, " ", sid);
}
// Foreground rainbow text
screen.setText(x + 2, y + 6, "Text:", labelStyle);
const sampleText = "The quick brown fox jumps over the lazy dog";
for (let i = 0; i < Math.min(sampleText.length, barWidth); i++) {
const hue = (i / sampleText.length) * 360;
const rgb = hslToRgb(hue, 1.0, 0.5);
const sid = screen.style({ fg: rgb });
screen.setText(x + 8 + i, y + 6, sampleText[i], sid);
}
},
});
// --- Section 7: Box Drawing Styles ---
sections.push({
title: "Box Drawing Styles",
height: 8,
draw(x, y, w) {
const boxStyles: Array<{ name: string; style: string }> = [
{ name: "single", style: "single" },
{ name: "double", style: "double" },
{ name: "rounded", style: "rounded" },
{ name: "heavy", style: "heavy" },
];
const boxWidth = Math.min(16, Math.floor((w - 4) / boxStyles.length));
for (let i = 0; i < boxStyles.length; i++) {
const { name, style } = boxStyles[i];
const bx = x + 2 + i * (boxWidth + 1);
const borderColor = screen.style({ fg: 0x61afef });
screen.drawBox(bx, y, boxWidth, 5, {
style,
styleId: borderColor,
fill: true,
});
// Label inside the box
const labelX = bx + Math.max(1, Math.floor((boxWidth - name.length) / 2));
screen.setText(labelX, y + 2, name, labelStyle);
}
// Nested boxes demo
if (w > 40) {
const nestedX = x + 2;
const nestedY = y + 6;
const outerBorder = screen.style({ fg: 0xe06c75 });
const innerBorder = screen.style({ fg: 0x98c379 });
screen.drawBox(nestedX, nestedY, 20, 2, { style: "double", styleId: outerBorder });
// Note: nested box needs at least 2x2 to render
}
},
});
// --- Section 8: Combined Styles ---
sections.push({
title: "Combined Foreground + Background",
height: 6,
draw(x, y, _w) {
const combos: Array<{ label: string; fg: number; bg: number; extra?: Record<string, any> }> = [
{ label: "White on Red", fg: 0xffffff, bg: 0xcc0000 },
{ label: "Black on Yellow", fg: 0x000000, bg: 0xcccc00 },
{ label: "Cyan on Blue", fg: 0x00ffff, bg: 0x000088 },
{ label: "Bold Green on Black", fg: 0x00ff00, bg: 0x000000, extra: { bold: true } },
{ label: "Italic White on Purple", fg: 0xffffff, bg: 0x880088, extra: { italic: true } },
];
for (let i = 0; i < combos.length; i++) {
const { label, fg, bg, extra } = combos[i];
const sid = screen.style({ fg, bg, ...extra } as any);
screen.setText(x + 2, y + i, ` ${label.padEnd(30)}`, labelStyle);
screen.setText(x + 34, y + i, ` ${label} `, sid);
}
},
});
// --- HSL to RGB helper ---
function hslToRgb(h: number, s: number, l: number): number {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
const ri = Math.round((r + m) * 255);
const gi = Math.round((g + m) * 255);
const bi = Math.round((b + m) * 255);
return (ri << 16) | (gi << 8) | bi;
}
// --- Calculate total virtual height ---
function totalHeight(): number {
let h = 1; // top padding
for (const section of sections) {
h += 1 + section.height + 1; // header + content + spacing
}
return h;
}
// --- Render ---
function render() {
screen.clear();
// Title bar
const titleBar = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
screen.fill(0, 0, cols, 1, " ", titleBar);
const title = " Bun TUI Color Palette ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, titleBar);
// Scrollable content area
const contentStartY = 1;
const contentHeight = rows - 2; // leave room for footer
let virtualY = 1 - scrollY; // current y position in virtual space
for (const section of sections) {
// Section header
if (virtualY >= contentStartY - 1 && virtualY < contentStartY + contentHeight) {
const drawY = virtualY;
if (drawY >= contentStartY && drawY < contentStartY + contentHeight) {
screen.setText(1, drawY, `\u2500\u2500 ${section.title} `, headerStyle);
// Fill rest of line with thin rule
const ruleStart = 5 + section.title.length;
for (let rx = ruleStart; rx < cols - 1; rx++) {
screen.setText(rx, drawY, "\u2500", dimStyle);
}
}
}
virtualY++;
// Section content
const contentDrawY = virtualY;
if (contentDrawY + section.height > contentStartY && contentDrawY < contentStartY + contentHeight) {
// Clip to visible area
screen.clip(0, contentStartY, cols, contentStartY + contentHeight);
section.draw(0, contentDrawY, cols);
screen.unclip();
}
virtualY += section.height + 1; // content + spacing
}
// Footer
const footerY = rows - 1;
screen.fill(0, footerY, cols, 1, " ", dimStyle);
const total = totalHeight();
const scrollPct = total > contentHeight ? Math.round((scrollY / (total - contentHeight)) * 100) : 0;
const footerText = ` Scroll: \u2191\u2193/PgUp/PgDn | ${scrollPct}% | q/Ctrl+C: Quit `;
screen.setText(0, footerY, footerText.slice(0, cols), footerStyle);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
const maxScroll = Math.max(0, totalHeight() - (rows - 2));
switch (name) {
case "up":
case "k":
scrollY = Math.max(0, scrollY - 1);
break;
case "down":
case "j":
scrollY = Math.min(maxScroll, scrollY + 1);
break;
case "pageup":
scrollY = Math.max(0, scrollY - (rows - 3));
break;
case "pagedown":
scrollY = Math.min(maxScroll, scrollY + (rows - 3));
break;
case "home":
scrollY = 0;
break;
case "end":
scrollY = maxScroll;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render ---
render();

View File

@@ -0,0 +1,263 @@
/**
* demo-dashboard.ts — System Dashboard
*
* A full-screen dashboard showing system information in styled panels.
* Demonstrates: drawBox, setText, style (fg/bg/bold/italic), fill, alt screen,
* TUITerminalWriter, TUIKeyReader, and resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-dashboard.ts
* Exit: Press 'q' or Ctrl+C
*/
import { arch, cpus, freemem, homedir, hostname, platform, tmpdir, totalmem, uptime } from "os";
// --- Setup writer and key reader ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Style palette ---
const styles = {
// Title bar
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
// Section headers
header: screen.style({ fg: 0x61afef, bold: true }),
// Labels (left column in panels)
label: screen.style({ fg: 0xabb2bf }),
// Values (right column in panels)
value: screen.style({ fg: 0xe5c07b }),
// Highlighted values
valueHigh: screen.style({ fg: 0x98c379, bold: true }),
valueLow: screen.style({ fg: 0xe06c75, bold: true }),
// Box borders
border: screen.style({ fg: 0x5c6370 }),
borderAccent: screen.style({ fg: 0x61afef }),
// Footer / help text
footer: screen.style({ fg: 0x5c6370, italic: true }),
// Separator
dim: screen.style({ fg: 0x3e4451 }),
// Progress bar fill
barFill: screen.style({ fg: 0x000000, bg: 0x98c379 }),
barEmpty: screen.style({ fg: 0x3e4451 }),
// Warning
warning: screen.style({ fg: 0xe06c75, bold: true }),
};
// --- Helper functions ---
/** Format bytes into a human-readable string. */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/** Format seconds into a human-readable uptime string. */
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (mins > 0) parts.push(`${mins}m`);
parts.push(`${secs}s`);
return parts.join(" ");
}
/** Draw a labeled key-value pair inside a panel. */
function drawKV(x: number, y: number, maxWidth: number, label: string, value: string, valueStyle?: number) {
screen.setText(x, y, label, styles.label);
const valX = x + label.length;
const remaining = maxWidth - label.length;
if (remaining > 0) {
screen.setText(valX, y, value.slice(0, remaining), valueStyle ?? styles.value);
}
}
/** Draw a horizontal progress bar. */
function drawProgressBar(x: number, y: number, width: number, ratio: number, label: string) {
screen.setText(x, y, label, styles.label);
const barX = x + label.length + 1;
const barWidth = width - label.length - 1 - 7; // leave room for percentage
if (barWidth < 3) return;
const filledCount = Math.round(ratio * barWidth);
screen.fill(barX, y, filledCount, 1, " ", styles.barFill);
if (barWidth - filledCount > 0) {
screen.fill(barX + filledCount, y, barWidth - filledCount, 1, " ", styles.barEmpty);
}
const pct = `${Math.round(ratio * 100)}%`;
screen.setText(barX + barWidth + 1, y, pct.padStart(4), ratio > 0.85 ? styles.valueLow : styles.valueHigh);
}
// --- Main render function ---
function render() {
screen.clear();
// Title bar — full width
screen.fill(0, 0, cols, 1, " ", styles.titleBar);
const title = " Bun TUI Dashboard ";
const titleX = Math.max(0, Math.floor((cols - title.length) / 2));
screen.setText(titleX, 0, title, styles.titleBar);
// Timestamp on right
const now = new Date().toLocaleTimeString();
if (cols > now.length + 2) {
screen.setText(cols - now.length - 1, 0, now, styles.titleBar);
}
// Calculate panel layout
const contentY = 2;
const panelHeight = Math.min(10, rows - contentY - 3);
if (panelHeight < 4) {
screen.setText(0, 2, "Terminal too small!", styles.warning);
writer.render(screen, { cursorVisible: false });
return;
}
const halfWidth = Math.floor((cols - 3) / 2);
const leftX = 1;
const rightX = leftX + halfWidth + 1;
// --- Panel 1: System Info (top-left) ---
screen.drawBox(leftX, contentY, halfWidth, panelHeight, {
style: "rounded",
styleId: styles.borderAccent,
fill: true,
});
screen.setText(leftX + 2, contentY, " System Info ", styles.header);
const infoX = leftX + 2;
const infoW = halfWidth - 4;
let infoY = contentY + 1;
drawKV(infoX, infoY++, infoW, "Hostname: ", hostname());
drawKV(infoX, infoY++, infoW, "Platform: ", `${platform()} (${arch()})`);
drawKV(infoX, infoY++, infoW, "Bun: ", Bun.version, styles.valueHigh);
drawKV(infoX, infoY++, infoW, "Home: ", homedir());
drawKV(infoX, infoY++, infoW, "Uptime: ", formatUptime(uptime()));
drawKV(infoX, infoY++, infoW, "Terminal: ", `${cols}x${rows}`);
// --- Panel 2: CPU Info (top-right) ---
screen.drawBox(rightX, contentY, halfWidth, panelHeight, {
style: "rounded",
styleId: styles.borderAccent,
fill: true,
});
screen.setText(rightX + 2, contentY, " CPU Info ", styles.header);
const cpuInfo = cpus();
const cpuX = rightX + 2;
const cpuW = halfWidth - 4;
let cpuY = contentY + 1;
drawKV(cpuX, cpuY++, cpuW, "Model: ", cpuInfo.length > 0 ? cpuInfo[0].model : "Unknown");
drawKV(cpuX, cpuY++, cpuW, "Cores: ", `${cpuInfo.length}`);
if (cpuInfo.length > 0) {
drawKV(cpuX, cpuY++, cpuW, "Speed: ", `${cpuInfo[0].speed} MHz`);
}
// Show per-core load as mini bars
const maxCoresToShow = Math.min(cpuInfo.length, panelHeight - 5);
for (let i = 0; i < maxCoresToShow; i++) {
const core = cpuInfo[i];
const total = core.times.user + core.times.nice + core.times.sys + core.times.idle + core.times.irq;
const busy = total > 0 ? 1 - core.times.idle / total : 0;
drawProgressBar(cpuX, cpuY++, cpuW, busy, `Core ${i}: `);
}
// --- Panel 3: Memory (bottom-left) ---
const memY = contentY + panelHeight + 1;
const memHeight = Math.min(6, rows - memY - 2);
if (memHeight >= 4) {
screen.drawBox(leftX, memY, halfWidth, memHeight, {
style: "rounded",
styleId: styles.border,
fill: true,
});
screen.setText(leftX + 2, memY, " Memory ", styles.header);
const total = totalmem();
const free = freemem();
const used = total - free;
const ratio = total > 0 ? used / total : 0;
const memX = leftX + 2;
const memW = halfWidth - 4;
let my = memY + 1;
drawKV(memX, my++, memW, "Total: ", formatBytes(total));
drawKV(memX, my++, memW, "Used: ", formatBytes(used), ratio > 0.85 ? styles.valueLow : styles.value);
drawKV(memX, my++, memW, "Free: ", formatBytes(free), styles.valueHigh);
if (my < memY + memHeight - 1) {
drawProgressBar(memX, my++, memW, ratio, "Usage: ");
}
}
// --- Panel 4: Bun Runtime Info (bottom-right) ---
if (memHeight >= 4) {
screen.drawBox(rightX, memY, halfWidth, memHeight, {
style: "rounded",
styleId: styles.border,
fill: true,
});
screen.setText(rightX + 2, memY, " Bun Runtime ", styles.header);
const bunX = rightX + 2;
const bunW = halfWidth - 4;
let by = memY + 1;
drawKV(bunX, by++, bunW, "Version: ", Bun.version);
drawKV(bunX, by++, bunW, "Revision: ", Bun.revision.slice(0, 8));
drawKV(bunX, by++, bunW, "Main: ", Bun.main.split("/").pop() ?? Bun.main);
if (by < memY + memHeight - 1) {
drawKV(bunX, by++, bunW, "Tmp Dir: ", tmpdir());
}
}
// --- Footer ---
const footerY = rows - 1;
screen.fill(0, footerY, cols, 1, " ", styles.dim);
const footerText = " Press 'q' or Ctrl+C to exit | Auto-refreshes every second ";
screen.setText(Math.max(0, Math.floor((cols - footerText.length) / 2)), footerY, footerText, styles.footer);
// Render to terminal
writer.render(screen, { cursorVisible: false });
}
// --- Handle resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Handle input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
if (event.name === "q" || (event.name === "c" && event.ctrl)) {
cleanup();
}
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render and refresh loop ---
render();
const timer = setInterval(render, 1000);

View File

@@ -0,0 +1,98 @@
/**
* demo-deps.ts — Dependency audit report showing packages with version,
* latest version, severity indicators, and a summary.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 76);
const height = 24;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
const pkgStyle = screen.style({ fg: 0xabb2bf });
const versionStyle = screen.style({ fg: 0x61afef });
const okStyle = screen.style({ fg: 0x98c379, bold: true });
const warnStyle = screen.style({ fg: 0xe5c07b, bold: true });
const critStyle = screen.style({ fg: 0xe06c75, bold: true });
const borderStyle = screen.style({ fg: 0x3e4452 });
const summaryLabel = screen.style({ fg: 0xabb2bf });
interface Dep {
name: string;
current: string;
latest: string;
severity: "ok" | "warn" | "critical";
}
const deps: Dep[] = [
{ name: "typescript", current: "5.3.3", latest: "5.3.3", severity: "ok" },
{ name: "esbuild", current: "0.19.8", latest: "0.19.11", severity: "warn" },
{ name: "react", current: "18.2.0", latest: "18.2.0", severity: "ok" },
{ name: "next", current: "14.0.1", latest: "14.0.4", severity: "warn" },
{ name: "lodash", current: "4.17.19", latest: "4.17.21", severity: "critical" },
{ name: "express", current: "4.18.2", latest: "4.18.2", severity: "ok" },
{ name: "axios", current: "1.4.0", latest: "1.6.2", severity: "warn" },
{ name: "zod", current: "3.22.4", latest: "3.22.4", severity: "ok" },
{ name: "prisma", current: "5.5.0", latest: "5.7.1", severity: "warn" },
{ name: "jsonwebtoken", current: "8.5.1", latest: "9.0.2", severity: "critical" },
{ name: "ws", current: "8.14.2", latest: "8.16.0", severity: "warn" },
{ name: "dotenv", current: "16.3.1", latest: "16.3.1", severity: "ok" },
];
const severityIcons: Record<string, { icon: string; style: number }> = {
ok: { icon: "\u2714 OK", style: okStyle },
warn: { icon: "\u26A0 Update", style: warnStyle },
critical: { icon: "\u2718 Critical", style: critStyle },
};
// Title box
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
const title = "Dependency Audit Report";
screen.setText(Math.floor((width - title.length) / 2), 1, title, titleStyle);
// Column headers
const nameCol = 2;
const curCol = 22;
const latCol = 34;
const sevCol = 46;
let y = 4;
screen.setText(nameCol, y, "Package", headerStyle);
screen.setText(curCol, y, "Current", headerStyle);
screen.setText(latCol, y, "Latest", headerStyle);
screen.setText(sevCol, y, "Status", headerStyle);
y++;
screen.setText(1, y, "\u2500".repeat(width - 2), dimStyle);
y++;
for (const dep of deps) {
screen.setText(nameCol, y, dep.name, pkgStyle);
screen.setText(curCol, y, dep.current, versionStyle);
screen.setText(latCol, y, dep.latest, versionStyle);
const sev = severityIcons[dep.severity];
screen.setText(sevCol, y, sev.icon, sev.style);
y++;
}
// Summary
y++;
screen.setText(1, y, "\u2500".repeat(width - 2), dimStyle);
y++;
const okCount = deps.filter(d => d.severity === "ok").length;
const warnCount = deps.filter(d => d.severity === "warn").length;
const critCount = deps.filter(d => d.severity === "critical").length;
screen.setText(2, y, `${deps.length} packages scanned`, summaryLabel);
y++;
screen.setText(4, y, `\u2714 ${okCount} up to date`, okStyle);
screen.setText(22, y, `\u26A0 ${warnCount} updates available`, warnStyle);
screen.setText(48, y, `\u2718 ${critCount} critical`, critStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,344 @@
/**
* demo-diff.ts — Side-by-Side Diff Viewer
*
* Displays two text buffers side-by-side with colored diff highlighting.
* Added, removed, and changed lines are styled differently. Uses screen.copy()
* to compose the split-pane layout from separate sub-screens.
*
* Demonstrates: copy() between TuiScreens, side-by-side layout, diff algorithm,
* line numbers, synchronized scrolling, setText, fill, style (fg/bg/bold/faint),
* drawBox, clip/unclip, TUITerminalWriter, TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-diff.ts
* Controls: j/k scroll, Tab switch active pane, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0xe06c75, bold: true }),
lineNum: screen.style({ fg: 0x5c6370 }),
lineNumActive: screen.style({ fg: 0xe5c07b }),
text: screen.style({ fg: 0xabb2bf }),
added: screen.style({ fg: 0x98c379, bg: 0x1a2e1a }),
addedGutter: screen.style({ fg: 0x98c379, bold: true }),
removed: screen.style({ fg: 0xe06c75, bg: 0x2e1a1a }),
removedGutter: screen.style({ fg: 0xe06c75, bold: true }),
changed: screen.style({ fg: 0xe5c07b, bg: 0x2e2a1a }),
changedGutter: screen.style({ fg: 0xe5c07b, bold: true }),
same: screen.style({ fg: 0xabb2bf }),
header: screen.style({ fg: 0x61afef, bold: true }),
headerBg: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
separator: screen.style({ fg: 0x3e4451 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
stats: screen.style({ fg: 0xe5c07b, bold: true }),
border: screen.style({ fg: 0x5c6370 }),
activeBorder: screen.style({ fg: 0x61afef }),
};
// --- Sample diff data ---
const leftLines = [
"import { serve } from 'bun';",
"",
"const server = serve({",
" port: 3000,",
" fetch(req) {",
" const url = new URL(req.url);",
"",
" if (url.pathname === '/') {",
" return new Response('Hello World');",
" }",
"",
" if (url.pathname === '/api/users') {",
" return Response.json([",
" { id: 1, name: 'Alice' },",
" { id: 2, name: 'Bob' },",
" ]);",
" }",
"",
" return new Response('Not Found', {",
" status: 404,",
" });",
" },",
"});",
"",
"console.log(`Server running on port ${server.port}`);",
];
const rightLines = [
"import { serve, file } from 'bun';",
"import { join } from 'path';",
"",
"const server = serve({",
" port: process.env.PORT || 3000,",
" fetch(req) {",
" const url = new URL(req.url);",
"",
" if (url.pathname === '/') {",
" return new Response('Hello, Bun!', {",
" headers: { 'Content-Type': 'text/plain' },",
" });",
" }",
"",
" if (url.pathname === '/api/users') {",
" const users = await getUsers();",
" return Response.json(users);",
" }",
"",
" if (url.pathname.startsWith('/static/')) {",
" return new Response(file(join('public', url.pathname)));",
" }",
"",
" return new Response('Not Found', {",
" status: 404,",
" headers: { 'X-Error': 'route-not-found' },",
" });",
" },",
"});",
"",
"console.log(`Server: http://localhost:${server.port}`);",
];
// --- Simple LCS-based diff ---
type DiffType = "same" | "added" | "removed" | "changed";
interface DiffLine {
left: string;
right: string;
leftNum: number;
rightNum: number;
type: DiffType;
}
function computeDiff(): DiffLine[] {
const result: DiffLine[] = [];
let li = 0,
ri = 0;
// Simple line-by-line comparison with basic alignment
while (li < leftLines.length || ri < rightLines.length) {
if (li >= leftLines.length) {
result.push({ left: "", right: rightLines[ri], leftNum: 0, rightNum: ri + 1, type: "added" });
ri++;
} else if (ri >= rightLines.length) {
result.push({ left: leftLines[li], right: "", leftNum: li + 1, rightNum: 0, type: "removed" });
li++;
} else if (leftLines[li] === rightLines[ri]) {
result.push({ left: leftLines[li], right: rightLines[ri], leftNum: li + 1, rightNum: ri + 1, type: "same" });
li++;
ri++;
} else {
// Check if next left matches current right (insertion)
const leftMatchesAhead = leftLines.indexOf(rightLines[ri], li + 1);
const rightMatchesAhead = rightLines.indexOf(leftLines[li], ri + 1);
if (rightMatchesAhead >= 0 && (leftMatchesAhead < 0 || rightMatchesAhead - ri < leftMatchesAhead - li)) {
// Lines added on right
while (ri < rightMatchesAhead) {
result.push({ left: "", right: rightLines[ri], leftNum: 0, rightNum: ri + 1, type: "added" });
ri++;
}
} else if (leftMatchesAhead >= 0) {
// Lines removed from left
while (li < leftMatchesAhead) {
result.push({ left: leftLines[li], right: "", leftNum: li + 1, rightNum: 0, type: "removed" });
li++;
}
} else {
// Changed line
result.push({ left: leftLines[li], right: rightLines[ri], leftNum: li + 1, rightNum: ri + 1, type: "changed" });
li++;
ri++;
}
}
}
return result;
}
const diffLines = computeDiff();
// --- State ---
let scrollOffset = 0;
let activePane = 0; // 0=left, 1=right
// Stats
const addedCount = diffLines.filter(d => d.type === "added").length;
const removedCount = diffLines.filter(d => d.type === "removed").length;
const changedCount = diffLines.filter(d => d.type === "changed").length;
// --- Render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Diff Viewer ", st.titleBar);
const statsText = `+${addedCount} -${removedCount} ~${changedCount}`;
screen.setText(cols - statsText.length - 2, 0, statsText, st.titleBar);
// File headers
const halfW = Math.floor((cols - 1) / 2);
screen.fill(0, 1, halfW, 1, " ", st.headerBg);
screen.fill(halfW + 1, 1, halfW, 1, " ", st.headerBg);
screen.setText(2, 1, "original.ts", activePane === 0 ? st.header : st.headerBg);
screen.setText(halfW + 3, 1, "modified.ts", activePane === 1 ? st.header : st.headerBg);
// Separator
for (let y = 1; y < rows - 1; y++) {
screen.setText(halfW, y, "\u2502", st.separator);
}
// Diff lines
const contentY = 2;
const contentH = rows - contentY - 1;
const gutterW = 4;
const textW = halfW - gutterW - 2;
// Ensure scroll is valid
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, diffLines.length - contentH)));
for (let vi = 0; vi < contentH; vi++) {
const di = scrollOffset + vi;
if (di >= diffLines.length) break;
const diff = diffLines[di];
const y = contentY + vi;
// Gutter indicator
let gutterChar = " ";
let gutterStyle = st.lineNum;
switch (diff.type) {
case "added":
gutterChar = "+";
gutterStyle = st.addedGutter;
break;
case "removed":
gutterChar = "-";
gutterStyle = st.removedGutter;
break;
case "changed":
gutterChar = "~";
gutterStyle = st.changedGutter;
break;
}
// Left pane
const leftBg = diff.type === "removed" ? st.removed : diff.type === "changed" ? st.changed : st.text;
if (diff.leftNum > 0) {
screen.setText(0, y, String(diff.leftNum).padStart(gutterW - 1), st.lineNum);
}
screen.setText(gutterW - 1, y, gutterChar, gutterStyle);
const leftText = diff.left.slice(0, textW);
if (leftText.length > 0) {
screen.setText(gutterW + 1, y, leftText, diff.type === "same" ? st.same : leftBg);
}
if (diff.type === "removed" || diff.type === "changed") {
// Fill background for visibility
for (let x = gutterW + 1 + leftText.length; x < halfW; x++) {
screen.setText(x, y, " ", leftBg);
}
}
// Right pane
const rightX = halfW + 1;
const rightBg = diff.type === "added" ? st.added : diff.type === "changed" ? st.changed : st.text;
if (diff.rightNum > 0) {
screen.setText(rightX, y, String(diff.rightNum).padStart(gutterW - 1), st.lineNum);
}
screen.setText(rightX + gutterW - 1, y, gutterChar, gutterStyle);
const rightText = diff.right.slice(0, textW);
if (rightText.length > 0) {
screen.setText(rightX + gutterW + 1, y, rightText, diff.type === "same" ? st.same : rightBg);
}
if (diff.type === "added" || diff.type === "changed") {
for (let x = rightX + gutterW + 1 + rightText.length; x < cols; x++) {
screen.setText(x, y, " ", rightBg);
}
}
}
// Scroll position
if (diffLines.length > contentH) {
const barH = Math.max(1, Math.floor((contentH * contentH) / diffLines.length));
const barPos = Math.floor((scrollOffset / Math.max(1, diffLines.length - contentH)) * (contentH - barH));
for (let i = 0; i < contentH; i++) {
const ch = i >= barPos && i < barPos + barH ? "\u2588" : "\u2502";
screen.setText(cols - 1, contentY + i, ch, i >= barPos && i < barPos + barH ? st.header : st.separator);
}
}
// Footer
const footerText = ` j/k:Scroll | Tab:Switch pane | ${diffLines.length} lines | q:Quit `;
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "up":
case "k":
scrollOffset = Math.max(0, scrollOffset - 1);
break;
case "down":
case "j":
scrollOffset++;
break;
case "pageup":
scrollOffset = Math.max(0, scrollOffset - (rows - 4));
break;
case "pagedown":
scrollOffset += rows - 4;
break;
case "home":
case "g":
scrollOffset = 0;
break;
case "end":
scrollOffset = diffLines.length;
break;
case "tab":
activePane = 1 - activePane;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
render();

View File

@@ -0,0 +1,463 @@
/**
* demo-file-browser.ts — File Browser
*
* A keyboard-navigable file browser with directory tree, file details,
* file type icons, and breadcrumb path display.
*
* Demonstrates: real filesystem integration (fs.readdirSync, fs.statSync),
* tree navigation, setText, fill, style (fg/bg/bold/italic), drawBox,
* TUITerminalWriter, TUIKeyReader, alt screen, resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-file-browser.ts
* Controls: j/k or arrows to navigate, Enter to open dir, Backspace to go up,
* / to filter, Q / Ctrl+C to quit
*/
import { readdirSync, statSync } from "fs";
import { basename, dirname, join, resolve } from "path";
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
pathBg: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
pathSegment: screen.style({ fg: 0x61afef, bg: 0x21252b, bold: true }),
pathSep: screen.style({ fg: 0x5c6370, bg: 0x21252b }),
dir: screen.style({ fg: 0x61afef, bold: true }),
dirSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
file: screen.style({ fg: 0xabb2bf }),
fileSelected: screen.style({ fg: 0x000000, bg: 0x61afef }),
symlink: screen.style({ fg: 0xc678dd, italic: true }),
symlinkSelected: screen.style({ fg: 0x000000, bg: 0xc678dd }),
executable: screen.style({ fg: 0x98c379, bold: true }),
execSelected: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
hidden: screen.style({ fg: 0x5c6370 }),
hiddenSelected: screen.style({ fg: 0x000000, bg: 0x5c6370 }),
icon: screen.style({ fg: 0xe5c07b }),
iconSelected: screen.style({ fg: 0x000000, bg: 0x61afef }),
selectedBg: screen.style({ bg: 0x61afef }),
border: screen.style({ fg: 0x5c6370 }),
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
detailLabel: screen.style({ fg: 0xabb2bf }),
detailValue: screen.style({ fg: 0xe5c07b }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
count: screen.style({ fg: 0xe5c07b }),
error: screen.style({ fg: 0xe06c75, italic: true }),
filterLabel: screen.style({ fg: 0xe5c07b, bold: true }),
filterText: screen.style({ fg: 0xffffff }),
};
// --- File type icons ---
function getIcon(name: string, isDir: boolean): string {
if (isDir) return "\u{1F4C1}"; // 📁
const ext = name.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "ts":
case "tsx":
return "\u{1F7E6}"; // 🟦
case "js":
case "jsx":
case "mjs":
case "cjs":
return "\u{1F7E8}"; // 🟨
case "json":
return "\u{1F4CB}"; // 📋
case "md":
case "txt":
return "\u{1F4DD}"; // 📝
case "zig":
return "\u26A1"; // ⚡
case "cpp":
case "c":
case "h":
return "\u2699"; // ⚙
case "rs":
return "\u{1F980}"; // 🦀
case "go":
return "\u{1F439}"; // 🐹
case "py":
return "\u{1F40D}"; // 🐍
case "toml":
case "yaml":
case "yml":
return "\u2699"; // ⚙
case "lock":
return "\u{1F512}"; // 🔒
case "gitignore":
return "\u{1F6AB}"; // 🚫
default:
return "\u{1F4C4}"; // 📄
}
}
// --- Entry type ---
interface Entry {
name: string;
isDir: boolean;
isSymlink: boolean;
isExecutable: boolean;
isHidden: boolean;
size: number;
mtime: Date;
}
// --- State ---
let currentPath = resolve(process.cwd());
let entries: Entry[] = [];
let filteredEntries: Entry[] = [];
let selectedIndex = 0;
let scrollOffset = 0;
let filterMode = false;
let filterText = "";
let errorMsg = "";
function loadDir(path: string) {
errorMsg = "";
try {
const names = readdirSync(path);
entries = [];
for (const name of names) {
try {
const full = join(path, name);
const stat = statSync(full, { throwIfNoEntry: false });
if (!stat) continue;
entries.push({
name,
isDir: stat.isDirectory(),
isSymlink: stat.isSymbolicLink(),
isExecutable: !stat.isDirectory() && (stat.mode & 0o111) !== 0,
isHidden: name.startsWith("."),
size: stat.size,
mtime: stat.mtime,
});
} catch {
// Skip entries we can't stat
}
}
// Sort: dirs first, then alphabetically
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
// Clear filter when navigating to a new directory
filterText = "";
filterMode = false;
applyFilter();
selectedIndex = 0;
scrollOffset = 0;
currentPath = path;
} catch (e: any) {
errorMsg = e.message || "Failed to read directory";
}
}
function applyFilter() {
if (filterText.length === 0) {
filteredEntries = entries;
} else {
const q = filterText.toLowerCase();
filteredEntries = entries.filter(e => e.name.toLowerCase().includes(q));
}
if (selectedIndex >= filteredEntries.length) {
selectedIndex = Math.max(0, filteredEntries.length - 1);
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(d: Date): string {
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// --- Render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(1, 0, " File Browser ", st.titleBar);
// Breadcrumb path
screen.fill(0, 1, cols, 1, " ", st.pathBg);
const pathParts = currentPath.split("/").filter(Boolean);
let px = 1;
screen.setText(px, 1, "/", st.pathSep);
px++;
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
if (px + part.length + 1 >= cols - 3) {
screen.setText(px, 1, "...", st.pathSep);
break;
}
screen.setText(px, 1, part, st.pathSegment);
px += part.length;
if (i < pathParts.length - 1) {
screen.setText(px, 1, "/", st.pathSep);
px++;
}
}
// Filter bar (if active)
const filterY = 2;
if (filterMode) {
screen.setText(1, filterY, "/ ", st.filterLabel);
screen.setText(3, filterY, filterText + "_", st.filterText);
} else if (filterText.length > 0) {
screen.setText(1, filterY, "Filter: ", st.detailLabel);
screen.setText(9, filterY, filterText, st.filterText);
screen.setText(9 + filterText.length + 1, filterY, `(${filteredEntries.length}/${entries.length})`, st.count);
}
// File list area
const listY = 3;
const listH = rows - listY - 1;
const detailW = 30;
const listW = cols - detailW - 3;
// Ensure selected is visible
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
// Error message
if (errorMsg.length > 0) {
screen.setText(2, listY + 1, errorMsg.slice(0, listW - 2), st.error);
writer.render(screen, { cursorVisible: false });
return;
}
// File entries
const visibleCount = Math.min(listH, filteredEntries.length - scrollOffset);
for (let vi = 0; vi < visibleCount; vi++) {
const idx = scrollOffset + vi;
const entry = filteredEntries[idx];
const y = listY + vi;
const isSelected = idx === selectedIndex;
if (isSelected) {
screen.fill(0, y, listW + 1, 1, " ", st.selectedBg);
}
// Icon (using 2 chars for wide emoji)
const icon = getIcon(entry.name, entry.isDir);
screen.setText(1, y, icon, isSelected ? st.iconSelected : st.icon);
// Name
let nameStyle: number;
if (entry.isDir) {
nameStyle = isSelected ? st.dirSelected : st.dir;
} else if (entry.isSymlink) {
nameStyle = isSelected ? st.symlinkSelected : st.symlink;
} else if (entry.isExecutable) {
nameStyle = isSelected ? st.execSelected : st.executable;
} else if (entry.isHidden) {
nameStyle = isSelected ? st.hiddenSelected : st.hidden;
} else {
nameStyle = isSelected ? st.fileSelected : st.file;
}
const nameX = 4; // after icon + space
const maxNameW = listW - nameX - 12;
let displayName = entry.name;
if (entry.isDir) displayName += "/";
if (displayName.length > maxNameW) displayName = displayName.slice(0, maxNameW - 1) + "\u2026";
screen.setText(nameX, y, displayName, nameStyle);
// Size (right-aligned)
if (!entry.isDir) {
const sizeStr = formatSize(entry.size).padStart(10);
screen.setText(listW - 10, y, sizeStr, isSelected ? st.fileSelected : st.detailLabel);
}
}
// Scroll indicators
if (scrollOffset > 0) {
screen.setText(listW, listY, "\u25b2", st.count); // ▲
}
if (scrollOffset + listH < filteredEntries.length) {
screen.setText(listW, listY + listH - 1, "\u25bc", st.count); // ▼
}
// --- Detail panel ---
const detailX = listW + 2;
if (detailW > 14 && filteredEntries.length > 0 && selectedIndex < filteredEntries.length) {
const sel = filteredEntries[selectedIndex];
screen.drawBox(detailX, listY, detailW, Math.min(12, listH), {
style: "rounded",
styleId: st.border,
fill: true,
});
screen.setText(detailX + 2, listY, " Details ", st.detailHeader);
let dy = listY + 1;
screen.setText(detailX + 2, dy, "Name:", st.detailLabel);
screen.setText(detailX + 9, dy, sel.name.slice(0, detailW - 11), st.detailValue);
dy++;
screen.setText(detailX + 2, dy, "Type:", st.detailLabel);
const typeStr = sel.isDir ? "Directory" : sel.isSymlink ? "Symlink" : "File";
screen.setText(detailX + 9, dy, typeStr, st.detailValue);
dy++;
if (!sel.isDir) {
screen.setText(detailX + 2, dy, "Size:", st.detailLabel);
screen.setText(detailX + 9, dy, formatSize(sel.size), st.detailValue);
dy++;
}
screen.setText(detailX + 2, dy, "Modified:", st.detailLabel);
dy++;
screen.setText(detailX + 2, dy, formatDate(sel.mtime).slice(0, detailW - 4), st.detailValue);
dy++;
if (sel.isExecutable) {
dy++;
screen.setText(detailX + 2, dy, "Executable", st.executable);
}
if (sel.isHidden) {
dy++;
screen.setText(detailX + 2, dy, "Hidden", st.hidden);
}
}
// Footer
const footerY = rows - 1;
const footerText = " \u2191\u2193/jk: Navigate | Enter: Open | Backspace: Up | /: Filter | q: Quit ";
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean; sequence: string }) => {
const { name, ctrl } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
if (filterMode) {
switch (name) {
case "escape":
filterMode = false;
filterText = "";
applyFilter();
break;
case "enter":
filterMode = false;
break;
case "backspace":
if (filterText.length > 0) {
filterText = filterText.slice(0, -1);
applyFilter();
} else {
filterMode = false;
}
break;
default:
if (!ctrl && !event.alt && name.length === 1) {
filterText += name;
applyFilter();
}
break;
}
render();
return;
}
switch (name) {
case "q":
cleanup();
return;
case "up":
case "k":
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
case "j":
if (selectedIndex < filteredEntries.length - 1) selectedIndex++;
break;
case "home":
case "g":
selectedIndex = 0;
break;
case "end":
selectedIndex = Math.max(0, filteredEntries.length - 1);
break;
case "pageup":
selectedIndex = Math.max(0, selectedIndex - (rows - 5));
break;
case "pagedown":
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + (rows - 5));
break;
case "enter": {
const sel = filteredEntries[selectedIndex];
if (sel?.isDir) {
loadDir(join(currentPath, sel.name));
}
break;
}
case "backspace": {
const parent = dirname(currentPath);
if (parent !== currentPath) {
const oldDir = basename(currentPath);
loadDir(parent);
// Try to select the directory we came from
const idx = filteredEntries.findIndex(e => e.name === oldDir);
if (idx >= 0) selectedIndex = idx;
}
break;
}
case "/":
filterMode = true;
filterText = "";
break;
case "escape":
if (filterText.length > 0) {
filterText = "";
applyFilter();
}
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
loadDir(currentPath);
render();

View File

@@ -0,0 +1,116 @@
/**
* demo-flags.ts — Renders several country flags using colored block characters.
* Shows France, Germany, Italy, Japan, and Ukraine using full and half blocks.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 72);
const height = 22;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const labelStyle = screen.style({ fg: 0xabb2bf });
// Flag dimensions
const flagW = 12;
const flagH = 4;
interface Flag {
name: string;
render: (sx: number, sy: number) => void;
}
function fillBlock(sx: number, sy: number, w: number, h: number, color: number) {
const s = screen.style({ fg: color, bg: color });
screen.fill(sx, sy, w, h, "\u2588", s);
}
// Use half-block technique: top half uses fg color, bottom half uses bg with upper-half block
function halfRow(sx: number, y: number, w: number, topColor: number, bottomColor: number) {
const s = screen.style({ fg: topColor, bg: bottomColor });
screen.fill(sx, y, w, 1, "\u2580", s);
}
const flags: Flag[] = [
{
name: "France",
render: (sx, sy) => {
const third = Math.floor(flagW / 3);
fillBlock(sx, sy, third, flagH, 0x002395); // Blue
fillBlock(sx + third, sy, third, flagH, 0xffffff); // White
fillBlock(sx + third * 2, sy, flagW - third * 2, flagH, 0xed2939); // Red
},
},
{
name: "Germany",
render: (sx, sy) => {
// 3 horizontal stripes - use half blocks for better resolution
halfRow(sx, sy, flagW, 0x000000, 0x000000); // Black top
halfRow(sx, sy + 1, flagW, 0x000000, 0xdd0000); // Black / Red
halfRow(sx, sy + 2, flagW, 0xdd0000, 0xffcc00); // Red / Gold
halfRow(sx, sy + 3, flagW, 0xffcc00, 0xffcc00); // Gold bottom
},
},
{
name: "Italy",
render: (sx, sy) => {
const third = Math.floor(flagW / 3);
fillBlock(sx, sy, third, flagH, 0x009246); // Green
fillBlock(sx + third, sy, third, flagH, 0xffffff); // White
fillBlock(sx + third * 2, sy, flagW - third * 2, flagH, 0xce2b37); // Red
},
},
{
name: "Japan",
render: (sx, sy) => {
// White background
fillBlock(sx, sy, flagW, flagH, 0xffffff);
// Red circle in center (approximated with blocks)
const cx = sx + Math.floor(flagW / 2);
const cy = sy + Math.floor(flagH / 2);
const redStyle = screen.style({ fg: 0xbc002d, bg: 0xbc002d });
// Draw a rough circle
screen.fill(cx - 1, cy - 1, 3, 1, "\u2588", redStyle);
screen.fill(cx - 2, cy, 5, 1, "\u2588", redStyle);
screen.fill(cx - 1, cy + 1, 3, 1, "\u2588", redStyle);
},
},
{
name: "Ukraine",
render: (sx, sy) => {
// Blue top half, yellow bottom half
fillBlock(sx, sy, flagW, 2, 0x0057b7); // Blue
fillBlock(sx, sy + 2, flagW, 2, 0xffd700); // Yellow
},
},
];
// Title
screen.setText(2, 0, "World Flags", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
// Layout: 3 flags per row
const gap = 3;
const colWidth = flagW + gap;
const flagsPerRow = 3;
let y = 3;
for (let i = 0; i < flags.length; i++) {
const col = i % flagsPerRow;
const row = Math.floor(i / flagsPerRow);
const x = 2 + col * (colWidth + 2);
const fy = y + row * (flagH + 3);
// Label
screen.setText(x, fy, flags[i].name, labelStyle);
// Draw flag
flags[i].render(x, fy + 1);
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,584 @@
/**
* demo-form.ts — Interactive Form
*
* A multi-field form with text inputs, radio buttons, checkboxes,
* a dropdown select, and form validation/submission.
*
* Demonstrates: multiple input types, field focus management, cursor in text
* fields, toggle states, dropdown menus, validation feedback, setText, fill,
* style (fg/bg/bold/italic/underline/inverse), drawBox, TUITerminalWriter,
* TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-form.ts
* Controls: Tab/Shift+Tab between fields, Space toggle, Enter submit/select,
* type in text fields, Q quit (when not in text field)
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
fieldLabel: screen.style({ fg: 0xabb2bf, bold: true }),
fieldLabelFocused: screen.style({ fg: 0x61afef, bold: true }),
inputBg: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
inputFocused: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
inputBorder: screen.style({ fg: 0x5c6370 }),
inputBorderFocused: screen.style({ fg: 0x61afef }),
placeholder: screen.style({ fg: 0x5c6370, bg: 0x2c313a, italic: true }),
radio: screen.style({ fg: 0xabb2bf }),
radioSelected: screen.style({ fg: 0x98c379, bold: true }),
radioFocused: screen.style({ fg: 0x61afef }),
radioSelectedFocused: screen.style({ fg: 0x98c379, bg: 0x2c313a, bold: true }),
checkbox: screen.style({ fg: 0xabb2bf }),
checkboxChecked: screen.style({ fg: 0x98c379, bold: true }),
checkboxFocused: screen.style({ fg: 0x61afef }),
checkboxCheckedFocused: screen.style({ fg: 0x98c379, bg: 0x2c313a, bold: true }),
dropdown: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
dropdownFocused: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
dropdownOpen: screen.style({ fg: 0xffffff, bg: 0x3e4451 }),
dropdownItem: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
dropdownItemSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
button: screen.style({ fg: 0xabb2bf, bg: 0x3e4451, bold: true }),
buttonFocused: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
buttonSubmit: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
error: screen.style({ fg: 0xe06c75, italic: true }),
success: screen.style({ fg: 0x98c379, bold: true }),
dim: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
required: screen.style({ fg: 0xe06c75, bold: true }),
sectionHeader: screen.style({ fg: 0xc678dd, bold: true }),
border: screen.style({ fg: 0x5c6370 }),
};
// --- Field types ---
type FieldType = "text" | "radio" | "checkbox" | "dropdown" | "button";
interface TextField {
type: "text";
label: string;
value: string;
placeholder: string;
required: boolean;
cursor: number;
error: string;
}
interface RadioField {
type: "radio";
label: string;
options: string[];
selected: number;
}
interface CheckboxField {
type: "checkbox";
label: string;
options: { label: string; checked: boolean }[];
}
interface DropdownField {
type: "dropdown";
label: string;
options: string[];
selected: number;
open: boolean;
highlightIdx: number;
}
interface ButtonField {
type: "button";
label: string;
action: string;
}
type Field = TextField | RadioField | CheckboxField | DropdownField | ButtonField;
// --- Form fields ---
const fields: Field[] = [
{ type: "text", label: "Name", value: "", placeholder: "Enter your name", required: true, cursor: 0, error: "" },
{ type: "text", label: "Email", value: "", placeholder: "you@example.com", required: true, cursor: 0, error: "" },
{
type: "radio",
label: "Role",
options: ["Developer", "Designer", "Manager", "Other"],
selected: 0,
},
{
type: "checkbox",
label: "Interests",
options: [
{ label: "Frontend", checked: false },
{ label: "Backend", checked: true },
{ label: "DevOps", checked: false },
{ label: "Mobile", checked: false },
],
},
{
type: "dropdown",
label: "Experience",
options: ["< 1 year", "1-3 years", "3-5 years", "5-10 years", "10+ years"],
selected: 0,
open: false,
highlightIdx: 0,
},
{
type: "text",
label: "Message",
value: "",
placeholder: "Optional message...",
required: false,
cursor: 0,
error: "",
},
{ type: "button", label: "Submit", action: "submit" },
{ type: "button", label: "Reset", action: "reset" },
];
// --- State ---
let focusedField = 0;
let submitted = false;
let submitResult = "";
let scrollY = 0;
// --- Validation ---
function validate(): boolean {
let valid = true;
for (const field of fields) {
if (field.type === "text") {
field.error = "";
if (field.required && field.value.trim().length === 0) {
field.error = `${field.label} is required`;
valid = false;
}
if (field.label === "Email" && field.value.length > 0 && !field.value.includes("@")) {
field.error = "Invalid email address";
valid = false;
}
}
}
return valid;
}
function resetForm() {
for (const field of fields) {
if (field.type === "text") {
field.value = "";
field.cursor = 0;
field.error = "";
} else if (field.type === "radio") {
field.selected = 0;
} else if (field.type === "checkbox") {
for (const opt of field.options) opt.checked = false;
} else if (field.type === "dropdown") {
field.selected = 0;
field.open = false;
}
}
submitted = false;
submitResult = "";
focusedField = 0;
}
// --- Render ---
function render() {
screen.clear();
// Title
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Registration Form ", st.titleBar);
const formX = 3;
const formW = Math.min(70, cols - 6);
let y = 2 - scrollY;
for (let fi = 0; fi < fields.length; fi++) {
const field = fields[fi];
const isFocused = fi === focusedField;
if (y < 1 || y >= rows - 2) {
y += fieldHeight(field);
continue;
}
switch (field.type) {
case "text": {
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
screen.setText(formX, y, field.label, labelStyle);
if (field.required) screen.setText(formX + field.label.length + 1, y, "*", st.required);
y++;
const inputW = Math.min(formW, 50);
const borderStyle = isFocused ? st.inputBorderFocused : st.inputBorder;
const inputStyle = isFocused ? st.inputFocused : st.inputBg;
// Input box
screen.fill(formX, y, inputW, 1, " ", inputStyle);
if (field.value.length > 0) {
screen.setText(formX + 1, y, field.value.slice(0, inputW - 2), inputStyle);
} else if (!isFocused) {
screen.setText(formX + 1, y, field.placeholder.slice(0, inputW - 2), st.placeholder);
}
// Borders
screen.setText(formX - 1, y, "[", borderStyle);
screen.setText(formX + inputW, y, "]", borderStyle);
y++;
// Error
if (field.error) {
screen.setText(formX, y, field.error, st.error);
}
y += 2;
break;
}
case "radio": {
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
screen.setText(formX, y, field.label, labelStyle);
y++;
for (let oi = 0; oi < field.options.length; oi++) {
const opt = field.options[oi];
const isSelected = oi === field.selected;
const bullet = isSelected ? "(\u25cf)" : "(\u25cb)";
const optStyle = isFocused
? isSelected
? st.radioSelectedFocused
: st.radioFocused
: isSelected
? st.radioSelected
: st.radio;
screen.setText(formX + 2, y, `${bullet} ${opt}`, optStyle);
y++;
}
y++;
break;
}
case "checkbox": {
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
screen.setText(formX, y, field.label, labelStyle);
y++;
for (let oi = 0; oi < field.options.length; oi++) {
const opt = field.options[oi];
const box = opt.checked ? "[\u2713]" : "[ ]";
const optStyle = isFocused
? opt.checked
? st.checkboxCheckedFocused
: st.checkboxFocused
: opt.checked
? st.checkboxChecked
: st.checkbox;
screen.setText(formX + 2, y, `${box} ${opt.label}`, optStyle);
y++;
}
y++;
break;
}
case "dropdown": {
const labelStyle = isFocused ? st.fieldLabelFocused : st.fieldLabel;
screen.setText(formX, y, field.label, labelStyle);
y++;
const ddW = 30;
const ddStyle = isFocused ? st.dropdownFocused : st.dropdown;
screen.fill(formX, y, ddW, 1, " ", ddStyle);
const selected = field.options[field.selected];
screen.setText(formX + 1, y, selected.slice(0, ddW - 4), ddStyle);
screen.setText(formX + ddW - 2, y, field.open ? "\u25b2" : "\u25bc", ddStyle);
y++;
// Dropdown items (when open)
if (field.open) {
for (let oi = 0; oi < field.options.length; oi++) {
const itemStyle = oi === field.highlightIdx ? st.dropdownItemSelected : st.dropdownItem;
screen.fill(formX, y, ddW, 1, " ", itemStyle);
screen.setText(formX + 1, y, field.options[oi].slice(0, ddW - 2), itemStyle);
y++;
}
}
y++;
break;
}
case "button": {
const btnStyle = isFocused ? (field.action === "submit" ? st.buttonSubmit : st.buttonFocused) : st.button;
const btnText = ` ${field.label} `;
screen.setText(formX, y, btnText, btnStyle);
// Put reset button next to submit
if (field.action === "submit" && fi + 1 < fields.length && fields[fi + 1].type === "button") {
// Skip — render both buttons on same line
}
if (field.action === "reset") {
// Already rendered alongside submit
}
y += 2;
break;
}
}
}
// Submit result
if (submitted) {
const resY = Math.min(y + 1, rows - 4);
if (submitResult.startsWith("Error")) {
screen.setText(formX, resY, submitResult, st.error);
} else {
screen.drawBox(formX - 1, resY - 1, formW, 4, { style: "rounded", styleId: st.border, fill: true });
screen.setText(formX, resY, "\u2713 " + submitResult, st.success);
}
}
// Footer
const footerText = " Tab:Next field | Shift+Tab:Prev | Space:Toggle | Enter:Submit/Select | Ctrl+C:Quit ";
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
// Cursor for text fields
const focused = fields[focusedField];
if (focused.type === "text") {
writer.render(screen, {
cursorVisible: true,
cursorX: 3 + 1 + focused.cursor,
cursorY: fieldY(focusedField) + 1 - scrollY,
cursorStyle: "line",
cursorBlinking: true,
});
} else {
writer.render(screen, { cursorVisible: false });
}
}
function fieldHeight(field: Field): number {
switch (field.type) {
case "text":
return 4;
case "radio":
return field.options.length + 2;
case "checkbox":
return field.options.length + 2;
case "dropdown": {
const base = 3;
return field.open ? base + field.options.length : base;
}
case "button":
return 2;
}
}
function fieldY(idx: number): number {
let y = 2;
for (let i = 0; i < idx; i++) {
y += fieldHeight(fields[i]);
}
return y;
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean }) => {
const { name, ctrl, shift } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
const focused = fields[focusedField];
// Close any open dropdown when leaving
const closeDropdowns = () => {
for (const f of fields) {
if (f.type === "dropdown") f.open = false;
}
};
// Handle open dropdown
if (focused.type === "dropdown" && focused.open) {
switch (name) {
case "up":
case "k":
focused.highlightIdx = Math.max(0, focused.highlightIdx - 1);
break;
case "down":
case "j":
focused.highlightIdx = Math.min(focused.options.length - 1, focused.highlightIdx + 1);
break;
case "enter":
case " ":
focused.selected = focused.highlightIdx;
focused.open = false;
break;
case "escape":
focused.open = false;
break;
}
render();
return;
}
// Text field input
if (focused.type === "text") {
switch (name) {
case "tab":
closeDropdowns();
if (shift) {
focusedField = (focusedField - 1 + fields.length) % fields.length;
} else {
focusedField = (focusedField + 1) % fields.length;
}
ensureVisible();
break;
case "backspace":
if (focused.cursor > 0) {
focused.value = focused.value.slice(0, focused.cursor - 1) + focused.value.slice(focused.cursor);
focused.cursor--;
}
break;
case "delete":
if (focused.cursor < focused.value.length) {
focused.value = focused.value.slice(0, focused.cursor) + focused.value.slice(focused.cursor + 1);
}
break;
case "left":
if (focused.cursor > 0) focused.cursor--;
break;
case "right":
if (focused.cursor < focused.value.length) focused.cursor++;
break;
case "home":
focused.cursor = 0;
break;
case "end":
focused.cursor = focused.value.length;
break;
case "enter":
closeDropdowns();
focusedField = (focusedField + 1) % fields.length;
ensureVisible();
break;
default:
if (!ctrl && !event.alt && name.length === 1) {
focused.value = focused.value.slice(0, focused.cursor) + name + focused.value.slice(focused.cursor);
focused.cursor++;
}
break;
}
render();
return;
}
// Non-text field input
switch (name) {
case "q":
cleanup();
return;
case "tab":
closeDropdowns();
if (shift) {
focusedField = (focusedField - 1 + fields.length) % fields.length;
} else {
focusedField = (focusedField + 1) % fields.length;
}
ensureVisible();
break;
case "up":
case "k":
if (focused.type === "radio") {
focused.selected = Math.max(0, focused.selected - 1);
}
break;
case "down":
case "j":
if (focused.type === "radio") {
focused.selected = Math.min(focused.options.length - 1, focused.selected + 1);
}
break;
case " ":
if (focused.type === "checkbox") {
// Toggle the first unchecked, or cycle through
const allChecked = focused.options.every(o => o.checked);
if (allChecked) {
for (const o of focused.options) o.checked = false;
} else {
// Toggle next unchecked
const nextUnchecked = focused.options.findIndex(o => !o.checked);
if (nextUnchecked >= 0) focused.options[nextUnchecked].checked = true;
}
} else if (focused.type === "dropdown") {
focused.open = !focused.open;
focused.highlightIdx = focused.selected;
}
break;
case "enter":
if (focused.type === "dropdown") {
focused.open = !focused.open;
focused.highlightIdx = focused.selected;
} else if (focused.type === "button") {
if (focused.action === "submit") {
submitted = true;
if (validate()) {
const textFields = fields.filter((f): f is TextField => f.type === "text");
submitResult = `Form submitted! Name: ${textFields[0].value}, Email: ${textFields[1].value}`;
} else {
submitResult = "Error: Please fix the highlighted fields";
}
} else if (focused.action === "reset") {
resetForm();
}
}
break;
}
render();
};
function ensureVisible() {
const fy = fieldY(focusedField);
const fh = fieldHeight(fields[focusedField]);
if (fy - scrollY < 2) {
scrollY = Math.max(0, fy - 2);
} else if (fy + fh - scrollY > rows - 2) {
scrollY = fy + fh - rows + 2;
}
}
// --- Paste ---
reader.onpaste = (text: string) => {
const focused = fields[focusedField];
if (focused.type === "text") {
const line = text.split("\n")[0];
focused.value = focused.value.slice(0, focused.cursor) + line + focused.value.slice(focused.cursor);
focused.cursor += line.length;
render();
}
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
render();

View File

@@ -0,0 +1,131 @@
/**
* demo-gradient.ts — Renders multiple color gradient strips (rainbow, warm, cool, grayscale)
* using true color. Each strip is one row of colored block characters.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 72);
const height = 16;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const labelStyle = screen.style({ fg: 0xabb2bf });
const dimStyle = screen.style({ fg: 0x5c6370 });
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t);
}
function rgbToHex(r: number, g: number, b: number): number {
return (r << 16) | (g << 8) | b;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
interface GradientDef {
name: string;
fn: (t: number) => number; // t: 0..1 -> rgb hex
}
const gradients: GradientDef[] = [
{
name: "Rainbow",
fn: t => {
const [r, g, b] = hslToRgb(t * 360, 1.0, 0.5);
return rgbToHex(r, g, b);
},
},
{
name: "Warm",
fn: t => {
const r = lerp(255, 255, t);
const g = lerp(50, 200, t);
const b = lerp(0, 50, t);
return rgbToHex(r, g, b);
},
},
{
name: "Cool",
fn: t => {
const r = lerp(0, 100, t);
const g = lerp(100, 200, t);
const b = lerp(200, 255, t);
return rgbToHex(r, g, b);
},
},
{
name: "Ocean",
fn: t => {
const r = lerp(0, 20, t);
const g = lerp(30, 180, t);
const b = lerp(80, 255, t);
return rgbToHex(r, g, b);
},
},
{
name: "Forest",
fn: t => {
const r = lerp(10, 80, t);
const g = lerp(40, 200, t);
const b = lerp(10, 60, t);
return rgbToHex(r, g, b);
},
},
{
name: "Sunset",
fn: t => {
if (t < 0.5) {
const t2 = t * 2;
return rgbToHex(lerp(255, 255, t2), lerp(60, 150, t2), lerp(0, 50, t2));
}
const t2 = (t - 0.5) * 2;
return rgbToHex(lerp(255, 100, t2), lerp(150, 50, t2), lerp(50, 150, t2));
},
},
{
name: "Grayscale",
fn: t => {
const v = Math.round(t * 255);
return rgbToHex(v, v, v);
},
},
];
// Title
screen.setText(2, 0, "True Color Gradients", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
const stripStart = 13;
const stripWidth = width - stripStart - 2;
let y = 3;
for (const grad of gradients) {
screen.setText(2, y, grad.name.padEnd(10), labelStyle);
for (let x = 0; x < stripWidth; x++) {
const t = x / (stripWidth - 1);
const color = grad.fn(t);
const s = screen.style({ fg: color });
screen.setText(stripStart + x, y, "\u2588", s);
}
y += 2;
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,98 @@
/**
* demo-heatmap.ts — GitHub-style contribution heatmap grid using block chars
* with green shading. Shows 52 weeks x 7 days with random data and month labels.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const weeks = 52;
const days = 7;
const width = Math.max(weeks + 8, 64);
const height = days + 6;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
// Green contribution levels
const levels = [
screen.style({ fg: 0x161b22 }), // 0: no contributions
screen.style({ fg: 0x0e4429 }), // 1: light
screen.style({ fg: 0x006d32 }), // 2: medium
screen.style({ fg: 0x26a641 }), // 3: good
screen.style({ fg: 0x39d353 }), // 4: max
];
const blockChar = "\u2588";
// Seeded pseudo-random
let seed = 42;
function rand() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
// Generate contribution data
const data: number[][] = [];
for (let w = 0; w < weeks; w++) {
const week: number[] = [];
for (let d = 0; d < days; d++) {
const r = rand();
// Weight towards lower values
if (r < 0.3) week.push(0);
else if (r < 0.55) week.push(1);
else if (r < 0.75) week.push(2);
else if (r < 0.9) week.push(3);
else week.push(4);
}
data.push(week);
}
// Title
screen.setText(2, 0, "Contribution Activity", titleStyle);
// Count total
let totalContribs = 0;
for (const week of data) for (const d of week) totalContribs += d;
screen.setText(2, 1, `${totalContribs} contributions in the last year`, dimStyle);
// Day labels
const dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const gridX = 6;
const gridY = 3;
for (let d = 0; d < days; d++) {
if (d % 2 === 0) {
screen.setText(1, gridY + d, dayLabels[d], dimStyle);
}
}
// Month labels (approximate positions)
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
for (let m = 0; m < 12; m++) {
const weekPos = Math.floor((m / 12) * weeks);
screen.setText(gridX + weekPos, gridY - 1, months[m], dimStyle);
}
// Draw heatmap grid
for (let w = 0; w < weeks; w++) {
for (let d = 0; d < days; d++) {
const level = data[w][d];
screen.setText(gridX + w, gridY + d, blockChar, levels[level]);
}
}
// Legend
const legendY = gridY + days + 1;
screen.setText(2, legendY, "Less", dimStyle);
let lx = 7;
for (let i = 0; i < levels.length; i++) {
screen.setText(lx, legendY, blockChar, levels[i]);
lx += 2;
}
screen.setText(lx, legendY, "More", dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,403 @@
/**
* demo-htop.ts — Process Viewer (htop-style)
*
* A real-time process viewer showing system processes with CPU/memory bars,
* sortable columns, process tree, and auto-refresh.
*
* Demonstrates: real system data (Bun.spawn + ps), dense tabular layout,
* progress bars inside table cells, auto-refresh, sort state, setText, fill,
* style (fg/bg/bold), TUITerminalWriter, TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-htop.ts
* Controls: j/k navigate, P sort by CPU, M sort by memory, N sort by name,
* / filter, Space pause, Q/Ctrl+C quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
headerBg: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
meterLabel: screen.style({ fg: 0xabb2bf, bold: true }),
meterBar: screen.style({ bg: 0x98c379 }),
meterBarMed: screen.style({ bg: 0xe5c07b }),
meterBarHigh: screen.style({ bg: 0xe06c75 }),
meterEmpty: screen.style({ bg: 0x2c313a }),
meterText: screen.style({ fg: 0xabb2bf }),
colHeader: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
colHeaderSort: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
row: screen.style({ fg: 0xabb2bf }),
rowAlt: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
rowSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
pid: screen.style({ fg: 0x56b6c2 }),
pidAlt: screen.style({ fg: 0x56b6c2, bg: 0x21252b }),
pidSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
cpuHigh: screen.style({ fg: 0xe06c75, bold: true }),
cpuMed: screen.style({ fg: 0xe5c07b }),
cpuLow: screen.style({ fg: 0x98c379 }),
cpuSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
memHigh: screen.style({ fg: 0xe06c75 }),
memLow: screen.style({ fg: 0xabb2bf }),
memSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
footer: screen.style({ fg: 0x000000, bg: 0x3e4451, bold: true }),
footerKey: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
footerLabel: screen.style({ fg: 0xabb2bf, bg: 0x3e4451 }),
filterBar: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
filterLabel: screen.style({ fg: 0xe5c07b, bg: 0x2c313a, bold: true }),
paused: screen.style({ fg: 0xe06c75, bold: true }),
uptime: screen.style({ fg: 0x98c379 }),
count: screen.style({ fg: 0xe5c07b, bold: true }),
};
// --- Process data ---
interface Process {
pid: number;
user: string;
cpu: number;
mem: number;
vsz: number;
rss: number;
command: string;
}
let processes: Process[] = [];
let selectedIndex = 0;
let scrollOffset = 0;
let sortBy: "cpu" | "mem" | "pid" | "name" = "cpu";
let sortAsc = false;
let paused = false;
let filterMode = false;
let filterText = "";
let totalCpu = 0;
let totalMem = 0;
let processCount = 0;
async function refreshProcesses() {
try {
await using proc = Bun.spawn({
cmd: ["ps", "aux"],
stdout: "pipe",
stderr: "ignore",
});
const output = await proc.stdout.text();
const lines = output.split("\n").slice(1); // skip header
processes = [];
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 11) continue;
const pid = parseInt(parts[1]);
if (isNaN(pid)) continue;
processes.push({
pid,
user: parts[0],
cpu: parseFloat(parts[2]) || 0,
mem: parseFloat(parts[3]) || 0,
vsz: parseInt(parts[4]) || 0,
rss: parseInt(parts[5]) || 0,
command: parts.slice(10).join(" "),
});
}
totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
totalMem = processes.reduce((s, p) => s + p.mem, 0);
processCount = processes.length;
sortProcesses();
} catch {
// silently ignore errors
}
}
function sortProcesses() {
processes.sort((a, b) => {
let cmp: number;
switch (sortBy) {
case "cpu":
cmp = a.cpu - b.cpu;
break;
case "mem":
cmp = a.mem - b.mem;
break;
case "pid":
cmp = a.pid - b.pid;
break;
case "name":
cmp = a.command.localeCompare(b.command);
break;
}
return sortAsc ? cmp : -cmp;
});
}
function getFilteredProcesses(): Process[] {
if (filterText.length === 0) return processes;
const q = filterText.toLowerCase();
return processes.filter(
p => p.command.toLowerCase().includes(q) || p.user.toLowerCase().includes(q) || String(p.pid).includes(q),
);
}
// --- Meter drawing ---
function drawMeter(x: number, y: number, width: number, value: number, max: number, label: string) {
screen.setText(x, y, label.padEnd(5), st.meterLabel);
const barX = x + 5;
const barW = width - 5 - 6;
const ratio = Math.min(value / max, 1);
const filled = Math.round(ratio * barW);
screen.setText(barX, y, "[", st.meterText);
for (let i = 0; i < barW; i++) {
if (i < filled) {
const barStyle = ratio > 0.8 ? st.meterBarHigh : ratio > 0.5 ? st.meterBarMed : st.meterBar;
screen.fill(barX + 1 + i, y, 1, 1, " ", barStyle);
} else {
screen.fill(barX + 1 + i, y, 1, 1, " ", st.meterEmpty);
}
}
screen.setText(barX + barW + 1, y, "]", st.meterText);
const pctText = `${value.toFixed(1)}%`;
screen.setText(barX + barW + 2, y, pctText, st.meterText);
}
// --- Render ---
function render() {
screen.clear();
const filtered = getFilteredProcesses();
// --- Top meters ---
const halfW = Math.floor((cols - 2) / 2);
drawMeter(1, 0, halfW, Math.min(totalCpu, 100), 100, "CPU:");
drawMeter(1 + halfW, 0, halfW, Math.min(totalMem, 100), 100, "MEM:");
// Process count and uptime
screen.setText(1, 1, `Tasks: `, st.meterLabel);
screen.setText(8, 1, `${processCount}`, st.count);
if (paused) {
screen.setText(8 + String(processCount).length + 2, 1, "PAUSED", st.paused);
}
// Filter bar
if (filterMode) {
screen.setText(1, 2, "Filter: ", st.filterLabel);
screen.setText(9, 2, filterText + "_", st.filterBar);
} else if (filterText.length > 0) {
screen.setText(1, 2, `Filter: ${filterText} (${filtered.length}/${processCount})`, st.meterText);
}
// --- Column headers ---
const tableY = 3;
screen.fill(0, tableY, cols, 1, " ", st.colHeader);
const colDefs = [
{ label: "PID", width: 7, key: "pid" as const },
{ label: "USER", width: 10, key: "name" as const },
{ label: "%CPU", width: 7, key: "cpu" as const },
{ label: "%MEM", width: 7, key: "mem" as const },
{ label: "RSS", width: 10, key: "mem" as const },
{ label: "COMMAND", width: 0, key: "name" as const }, // fill remaining
];
let cx = 0;
for (const col of colDefs) {
const w = col.width === 0 ? cols - cx : col.width;
const isSorted =
sortBy === col.key &&
(col.label === "%CPU" || col.label === "%MEM" || col.label === "PID" || col.label === "COMMAND");
const style = isSorted ? st.colHeaderSort : st.colHeader;
const arrow = isSorted ? (sortAsc ? "\u25b2" : "\u25bc") : "";
screen.setText(cx, tableY, ` ${col.label}${arrow}`.padEnd(w).slice(0, w), style);
cx += w;
}
// --- Process rows ---
const listY = tableY + 1;
const listH = rows - listY - 1;
if (selectedIndex >= filtered.length) selectedIndex = Math.max(0, filtered.length - 1);
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
const visibleCount = Math.min(listH, filtered.length - scrollOffset);
for (let vi = 0; vi < visibleCount; vi++) {
const idx = scrollOffset + vi;
const proc = filtered[idx];
const y = listY + vi;
const isSel = idx === selectedIndex;
const isAlt = vi % 2 === 1;
const rowStyle = isSel ? st.rowSel : isAlt ? st.rowAlt : st.row;
const pidStyle = isSel ? st.pidSel : isAlt ? st.pidAlt : st.pid;
const memStyle = isSel ? st.memSel : proc.mem > 5 ? st.memHigh : st.memLow;
const cpuStyle = isSel ? st.cpuSel : proc.cpu > 50 ? st.cpuHigh : proc.cpu > 10 ? st.cpuMed : st.cpuLow;
if (isSel || isAlt) {
screen.fill(0, y, cols, 1, " ", rowStyle);
}
let rx = 0;
// PID
screen.setText(rx, y, String(proc.pid).padStart(6), pidStyle);
rx += 7;
// USER
screen.setText(rx, y, proc.user.slice(0, 9).padEnd(10), rowStyle);
rx += 10;
// %CPU
screen.setText(rx, y, proc.cpu.toFixed(1).padStart(6), cpuStyle);
rx += 7;
// %MEM
screen.setText(rx, y, proc.mem.toFixed(1).padStart(6), memStyle);
rx += 7;
// RSS (in MB)
const rssMB = (proc.rss / 1024).toFixed(0);
screen.setText(rx, y, `${rssMB} MB`.padStart(9), rowStyle);
rx += 10;
// COMMAND
const cmdW = cols - rx - 1;
if (cmdW > 0) {
let cmd = proc.command;
if (cmd.length > cmdW) cmd = cmd.slice(0, cmdW - 1) + "\u2026";
screen.setText(rx, y, cmd, rowStyle);
}
}
// --- Footer ---
const footerY = rows - 1;
screen.fill(0, footerY, cols, 1, " ", st.footer);
const keys = [
["P", "CPU"],
["M", "Mem"],
["N", "Name"],
["/", "Filter"],
["Space", "Pause"],
["q", "Quit"],
];
let fx = 0;
for (const [key, label] of keys) {
screen.setText(fx, footerY, key, st.footerKey);
fx += key.length;
screen.setText(fx, footerY, label + " ", st.footerLabel);
fx += label.length + 1;
}
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
const { name, ctrl, alt } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
if (filterMode) {
switch (name) {
case "enter":
case "escape":
filterMode = false;
if (name === "escape") filterText = "";
break;
case "backspace":
if (filterText.length > 0) filterText = filterText.slice(0, -1);
else filterMode = false;
break;
default:
if (!ctrl && !alt && name.length === 1) filterText += name;
break;
}
selectedIndex = 0;
scrollOffset = 0;
render();
return;
}
switch (name) {
case "q":
cleanup();
return;
case "up":
case "k":
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
case "j":
if (selectedIndex < getFilteredProcesses().length - 1) selectedIndex++;
break;
case "pageup":
selectedIndex = Math.max(0, selectedIndex - (rows - 6));
break;
case "pagedown":
selectedIndex = Math.min(getFilteredProcesses().length - 1, selectedIndex + (rows - 6));
break;
case "home":
selectedIndex = 0;
break;
case "end":
selectedIndex = getFilteredProcesses().length - 1;
break;
case "p":
sortBy = "cpu";
sortAsc = false;
sortProcesses();
break;
case "m":
sortBy = "mem";
sortAsc = false;
sortProcesses();
break;
case "n":
sortBy = "name";
sortAsc = true;
sortProcesses();
break;
case "/":
filterMode = true;
filterText = "";
break;
case " ":
paused = !paused;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
await refreshProcesses();
render();
const timer = setInterval(async () => {
if (!paused) {
await refreshProcesses();
}
render();
}, 2000);

View File

@@ -0,0 +1,378 @@
/**
* demo-hyperlink.ts — Terminal Hyperlinks & Unicode Showcase
*
* Demonstrates OSC 8 terminal hyperlinks (clickable URLs), CJK wide characters,
* emoji with ZWJ sequences, combining marks, and the full Unicode handling
* powered by Ghostty's grapheme clustering.
*
* Demonstrates: hyperlink(url), setHyperlink(x, y, id), setText with CJK/emoji,
* style (fg/bg/bold/italic/underline), drawBox, TUITerminalWriter, TUIKeyReader,
* alt screen, resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-hyperlink.ts
* Controls: j/k scroll, Tab switch section, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
header: screen.style({ fg: 0x61afef, bold: true }),
subheader: screen.style({ fg: 0xe5c07b, bold: true }),
text: screen.style({ fg: 0xabb2bf }),
link: screen.style({ fg: 0x61afef, underline: "single", bold: true }),
linkDesc: screen.style({ fg: 0xabb2bf }),
cjk: screen.style({ fg: 0xe5c07b }),
emoji: screen.style({ fg: 0xffffff }),
code: screen.style({ fg: 0x98c379, bg: 0x21252b }),
dim: screen.style({ fg: 0x5c6370 }),
border: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
tabActive: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
tabInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
accent: screen.style({ fg: 0xc678dd, bold: true }),
wide: screen.style({ fg: 0xe06c75, bg: 0x21252b }),
combining: screen.style({ fg: 0x98c379 }),
};
// --- Link data ---
const links = [
{ url: "https://bun.sh", text: "bun.sh", desc: "Bun - JavaScript runtime & toolkit" },
{ url: "https://bun.sh/docs", text: "bun.sh/docs", desc: "Bun Documentation" },
{ url: "https://github.com/oven-sh/bun", text: "github.com/oven-sh/bun", desc: "Bun on GitHub" },
{ url: "https://ghostty.org", text: "ghostty.org", desc: "Ghostty Terminal Emulator" },
{ url: "https://github.com/ghostty-org/ghostty", text: "github.com/ghostty-org/ghostty", desc: "Ghostty on GitHub" },
{ url: "https://ziglang.org", text: "ziglang.org", desc: "Zig Programming Language" },
{ url: "https://developer.mozilla.org/en-US/docs/Web/API", text: "MDN Web APIs", desc: "Mozilla Developer Network" },
{ url: "https://nodejs.org", text: "nodejs.org", desc: "Node.js Runtime" },
];
// --- Unicode showcase data ---
const cjkSamples = [
{ text: "\u5FEB\u901F", label: "Fast (Chinese)" },
{ text: "\u30D0\u30F3", label: "Bun (Japanese Katakana)" },
{ text: "\uD55C\uAD6D\uC5B4", label: "Korean" },
{ text: "\u6027\u80FD", label: "Performance (Chinese)" },
{ text: "\u30BF\u30FC\u30DF\u30CA\u30EB", label: "Terminal (Japanese)" },
{ text: "\uC548\uB155", label: "Hello (Korean)" },
];
const emojiSamples = [
{ text: "\u{1F680}", label: "Rocket" },
{ text: "\u{1F525}", label: "Fire" },
{ text: "\u26A1", label: "Lightning" },
{ text: "\u{1F4E6}", label: "Package" },
{ text: "\u{1F3AF}", label: "Bullseye" },
{ text: "\u{2728}", label: "Sparkles" },
{ text: "\u{1F40D}", label: "Snake" },
{ text: "\u{1F980}", label: "Crab" },
{ text: "\u{1F439}", label: "Hamster" },
{ text: "\u2615", label: "Coffee" },
{ text: "\u{1F4BB}", label: "Laptop" },
{ text: "\u{1F310}", label: "Globe" },
];
const zwjSamples = [
{ text: "\u{1F468}\u200D\u{1F4BB}", label: "Man Technologist (ZWJ)" },
{ text: "\u{1F469}\u200D\u{1F52C}", label: "Woman Scientist (ZWJ)" },
{ text: "\u{1F3F3}\uFE0F\u200D\u{1F308}", label: "Rainbow Flag (ZWJ)" },
{ text: "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}", label: "Family (ZWJ)" },
];
const boxDrawing = [
{ chars: "\u250C\u2500\u2510\u2502\u2514\u2518", label: "Single box" },
{ chars: "\u2554\u2550\u2557\u2551\u255A\u255D", label: "Double box" },
{ chars: "\u256D\u2500\u256E\u2502\u2570\u256F", label: "Rounded box" },
{ chars: "\u2501\u2503\u250F\u2513\u2517\u251B", label: "Heavy box" },
{ chars: "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588", label: "Block elements" },
{ chars: "\u2591\u2592\u2593\u2588", label: "Shade blocks" },
];
// --- State ---
let activeTab = 0;
let scrollY = 0;
const tabs = ["Hyperlinks", "CJK & Emoji", "Box Drawing"];
// --- Render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Hyperlinks & Unicode ", st.titleBar);
// Tabs
let tx = 2;
for (let i = 0; i < tabs.length; i++) {
const label = ` ${i + 1}:${tabs[i]} `;
screen.setText(tx, 1, label, i === activeTab ? st.tabActive : st.tabInactive);
tx += label.length + 1;
}
const contentY = 3;
const contentH = rows - contentY - 1;
if (activeTab === 0) {
renderHyperlinks(contentY, contentH);
} else if (activeTab === 1) {
renderUnicode(contentY, contentH);
} else {
renderBoxDrawing(contentY, contentH);
}
// Footer
const footerText = " j/k:Scroll | 1-3:Section | Tab:Next | q:Quit ";
screen.setText(0, rows - 1, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
function renderHyperlinks(startY: number, height: number) {
let y = startY - scrollY;
// Section: Clickable Hyperlinks
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Clickable Terminal Hyperlinks (OSC 8)", st.header);
}
y += 2;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Click any link below in a supporting terminal:", st.dim);
}
y += 2;
for (const link of links) {
if (y >= startY && y < startY + height) {
// Register hyperlink
const hid = screen.hyperlink(link.url);
// Draw the link text
const linkLen = screen.setText(4, y, link.text, st.link);
// Apply hyperlink to the cells
for (let x = 0; x < linkLen; x++) {
screen.setHyperlink(4 + x, y, hid);
}
// Description
screen.setText(4 + linkLen + 2, y, link.desc, st.linkDesc);
}
y += 2;
}
// Code example
y += 1;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Usage in code:", st.subheader);
}
y += 1;
const codeLines = [
"const hid = screen.hyperlink('https://bun.sh');",
"screen.setText(0, 0, 'Click me!', linkStyle);",
"for (let x = 0; x < 9; x++)",
" screen.setHyperlink(x, 0, hid);",
];
for (const line of codeLines) {
if (y >= startY && y < startY + height) {
screen.setText(4, y, line, st.code);
}
y++;
}
}
function renderUnicode(startY: number, height: number) {
let y = startY - scrollY;
// CJK
if (y >= startY && y < startY + height) {
screen.setText(2, y, "CJK Wide Characters (2 cells each)", st.header);
}
y += 2;
for (const sample of cjkSamples) {
if (y >= startY && y < startY + height) {
const w = screen.setText(4, y, sample.text, st.cjk);
screen.setText(4 + w + 2, y, sample.label, st.dim);
}
y++;
}
// Emoji
y += 1;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Emoji (wide characters)", st.header);
}
y += 2;
let ex = 4;
let ey = y;
for (const sample of emojiSamples) {
if (ex + 14 > cols - 4) {
ex = 4;
ey += 2;
}
if (ey >= startY && ey < startY + height) {
const w = screen.setText(ex, ey, sample.text, st.emoji);
screen.setText(ex + w, ey, ` ${sample.label}`, st.dim);
}
ex += sample.label.length + 5;
}
y = ey + 3;
// ZWJ sequences
if (y >= startY && y < startY + height) {
screen.setText(2, y, "ZWJ Sequences (Grapheme Clustering)", st.header);
}
y += 2;
for (const sample of zwjSamples) {
if (y >= startY && y < startY + height) {
const w = screen.setText(4, y, sample.text, st.emoji);
screen.setText(4 + w + 2, y, sample.label, st.dim);
}
y++;
}
y += 1;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Powered by Ghostty's grapheme clustering engine", st.accent);
}
}
function renderBoxDrawing(startY: number, height: number) {
let y = startY - scrollY;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Box Drawing Characters", st.header);
}
y += 2;
for (const sample of boxDrawing) {
if (y >= startY && y < startY + height) {
screen.setText(4, y, sample.chars, st.text);
screen.setText(4 + sample.chars.length + 2, y, sample.label, st.dim);
}
y += 2;
}
// Live box drawing examples
y += 1;
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Live Box Styles", st.header);
}
y += 1;
const boxStyles = ["single", "double", "rounded", "heavy", "ascii"];
let bx = 2;
for (const style of boxStyles) {
if (bx + 14 < cols && y + 4 < startY + height) {
screen.drawBox(bx, y, 14, 5, { style, styleId: st.border, fill: true });
const labelX = bx + Math.floor((14 - style.length) / 2);
screen.setText(labelX, y + 2, style, st.accent);
}
bx += 16;
}
y += 6;
// Braille patterns
if (y >= startY && y < startY + height) {
screen.setText(2, y, "Braille Patterns (U+2800-U+28FF)", st.header);
}
y += 2;
if (y >= startY && y < startY + height) {
let bpx = 4;
for (let i = 0; i < 64 && bpx < cols - 4; i++) {
const cp = 0x2800 + i;
screen.setText(bpx, y, String.fromCodePoint(cp), st.text);
bpx += 2;
}
}
y++;
if (y >= startY && y < startY + height) {
let bpx = 4;
for (let i = 64; i < 128 && bpx < cols - 4; i++) {
const cp = 0x2800 + i;
screen.setText(bpx, y, String.fromCodePoint(cp), st.text);
bpx += 2;
}
}
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "up":
case "k":
scrollY = Math.max(0, scrollY - 1);
break;
case "down":
case "j":
scrollY++;
break;
case "pageup":
scrollY = Math.max(0, scrollY - (rows - 5));
break;
case "pagedown":
scrollY += rows - 5;
break;
case "1":
activeTab = 0;
scrollY = 0;
break;
case "2":
activeTab = 1;
scrollY = 0;
break;
case "3":
activeTab = 2;
scrollY = 0;
break;
case "tab":
activeTab = (activeTab + 1) % tabs.length;
scrollY = 0;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
render();

View File

@@ -0,0 +1,178 @@
/**
* demo-inline.ts — Inline TUI Rendering (No Alt Screen)
*
* Renders styled content directly into the terminal's scrollback buffer
* without entering alt screen mode. Useful for CLI tools that want to
* output rich formatted content (tables, trees, status bars) inline.
*
* Demonstrates: rendering without alt screen, multiple sequential renders
* to the same region, writer.clear() to reset diff state, small fixed-size
* screens, setText, fill, style, drawBox, TUITerminalWriter, TUIScreen.
*
* Run: bun run test/js/bun/tui/demos/demo-inline.ts
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
// --- Inline render: small screens rendered directly into scrollback ---
// 1. Styled banner
{
const screen = new Bun.TUIScreen(60, 3);
const bannerBg = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
screen.fill(0, 0, 60, 3, " ", bannerBg);
screen.setText(4, 1, "\u26A1 Bun TUI — Inline Rendering Demo \u26A1", bannerBg);
writer.render(screen);
writer.clear();
// Print a blank line after
writer.write("\r\n");
}
// 2. Dependency tree
{
const w = 50;
const screen = new Bun.TUIScreen(w, 10);
const header = screen.style({ fg: 0x61afef, bold: true });
const pkg = screen.style({ fg: 0x98c379 });
const ver = screen.style({ fg: 0xe5c07b });
const tree = screen.style({ fg: 0x5c6370 });
screen.setText(0, 0, "Dependencies:", header);
screen.setText(0, 1, "\u251C\u2500\u2500 ", tree);
screen.setText(4, 1, "typescript", pkg);
screen.setText(15, 1, "^5.7.2", ver);
screen.setText(0, 2, "\u251C\u2500\u2500 ", tree);
screen.setText(4, 2, "esbuild", pkg);
screen.setText(15, 2, "^0.24.0", ver);
screen.setText(0, 3, "\u251C\u2500\u2500 ", tree);
screen.setText(4, 3, "@types/node", pkg);
screen.setText(15, 3, "^22.10.0", ver);
screen.setText(0, 4, "\u2502 \u2514\u2500\u2500 ", tree);
screen.setText(8, 4, "undici-types", pkg);
screen.setText(21, 4, "~6.20.0", ver);
screen.setText(0, 5, "\u251C\u2500\u2500 ", tree);
screen.setText(4, 5, "prettier", pkg);
screen.setText(15, 5, "^3.4.2", ver);
screen.setText(0, 6, "\u2514\u2500\u2500 ", tree);
screen.setText(4, 6, "vitest", pkg);
screen.setText(15, 6, "^2.1.8", ver);
screen.setText(0, 8, "5 dependencies, 1 nested", screen.style({ fg: 0x5c6370, italic: true }));
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// 3. Inline table
{
const w = 65;
const screen = new Bun.TUIScreen(w, 8);
const headerBg = screen.style({ fg: 0x000000, bg: 0x3e4451, bold: true });
const row = screen.style({ fg: 0xabb2bf });
const rowAlt = screen.style({ fg: 0xabb2bf, bg: 0x21252b });
const num = screen.style({ fg: 0xe5c07b });
const numAlt = screen.style({ fg: 0xe5c07b, bg: 0x21252b });
const pass = screen.style({ fg: 0x98c379, bold: true });
const fail = screen.style({ fg: 0xe06c75, bold: true });
const border = screen.style({ fg: 0x5c6370 });
screen.drawBox(0, 0, w, 8, { style: "rounded", styleId: border });
screen.setText(2, 0, " Test Results ", screen.style({ fg: 0x61afef, bold: true }));
// Header
screen.fill(1, 1, w - 2, 1, " ", headerBg);
screen.setText(2, 1, "Suite", headerBg);
screen.setText(22, 1, "Tests", headerBg);
screen.setText(32, 1, "Pass", headerBg);
screen.setText(42, 1, "Fail", headerBg);
screen.setText(52, 1, "Time", headerBg);
// Rows
const data = [
["screen.test.ts", "105", "105", "0", "680ms"],
["writer.test.ts", "83", "83", "0", "643ms"],
["key-reader.test.ts", "28", "28", "0", "12.3s"],
["e2e.test.ts", "27", "27", "0", "1.16s"],
["bench.test.ts", "14", "14", "0", "657ms"],
];
for (let i = 0; i < data.length; i++) {
const d = data[i];
const y = 2 + i;
const isAlt = i % 2 === 1;
const rs = isAlt ? rowAlt : row;
const ns = isAlt ? numAlt : num;
if (isAlt) screen.fill(1, y, w - 2, 1, " ", rowAlt);
screen.setText(2, y, d[0], rs);
screen.setText(22, y, d[1], ns);
screen.setText(32, y, d[2], pass);
screen.setText(42, y, d[3], parseInt(d[3]) > 0 ? fail : pass);
screen.setText(52, y, d[4], rs);
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// 4. Status bars / progress
{
const w = 60;
const screen = new Bun.TUIScreen(w, 4);
const label = screen.style({ fg: 0xabb2bf });
const barFill = screen.style({ bg: 0x98c379 });
const barEmpty = screen.style({ bg: 0x2c313a });
const pct = screen.style({ fg: 0x98c379, bold: true });
const done = screen.style({ fg: 0x98c379, bold: true });
screen.setText(0, 0, "Build:", label);
screen.fill(8, 0, 40, 1, " ", barFill);
screen.setText(49, 0, "100%", pct);
screen.setText(54, 0, "\u2713", done);
screen.setText(0, 1, "Tests:", label);
screen.fill(8, 1, 34, 1, " ", barFill);
screen.fill(42, 1, 6, 1, " ", barEmpty);
screen.setText(49, 1, " 85%", screen.style({ fg: 0xe5c07b, bold: true }));
screen.setText(0, 2, "Lint:", label);
screen.fill(8, 2, 20, 1, " ", barFill);
screen.fill(28, 2, 20, 1, " ", barEmpty);
screen.setText(49, 2, " 50%", screen.style({ fg: 0xe5c07b }));
screen.setText(0, 3, "\u2714 Build complete \u25cf 257 tests \u26A0 3 warnings", screen.style({ fg: 0x5c6370 }));
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// 5. Colored text art
{
const screen = new Bun.TUIScreen(40, 5);
const colors = [0xff5555, 0xff8800, 0xffff55, 0x55ff55, 0x55ffff, 0x5555ff, 0xff55ff];
const text = "Bun TUI";
const artLines = [
" ____ _____ _ _ ___ ",
"| __ ) _ _ _ __ |_ _|| | | ||_ _| ",
"| _ \\| | | | '_ \\ | | | | | | | | ",
"| |_) | |_| | | | | | | | |_| | | | ",
"|____/ \\__,_|_| |_| |_| \\___/ |___| ",
];
for (let y = 0; y < artLines.length; y++) {
const line = artLines[y];
for (let x = 0; x < line.length && x < 40; x++) {
const colorIdx = Math.floor((x / 40) * colors.length);
const color = colors[colorIdx % colors.length];
screen.setText(x, y, line[x], screen.style({ fg: color, bold: true }));
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
}
// Clean up
writer.close();

View File

@@ -0,0 +1,389 @@
/**
* demo-interactive-list.ts — Interactive Scrollable List
*
* A filterable, scrollable list with keyboard navigation.
* Demonstrates: TUIKeyReader (arrows, enter, escape, typing), style
* (fg/bg/bold/inverse), setText, fill, clearRect, drawBox, alt screen,
* and search filtering.
*
* Run: bun run test/js/bun/tui/demos/demo-interactive-list.ts
* Exit: Escape or Ctrl+C
*/
// --- Item data ---
interface ListItem {
name: string;
description: string;
category: string;
}
const ALL_ITEMS: ListItem[] = [
{ name: "Bun.serve()", description: "Start an HTTP server", category: "HTTP" },
{ name: "Bun.file()", description: "Reference a file on disk", category: "File I/O" },
{ name: "Bun.write()", description: "Write data to a file", category: "File I/O" },
{ name: "Bun.spawn()", description: "Spawn a child process", category: "Process" },
{ name: "Bun.build()", description: "Bundle JavaScript/TypeScript", category: "Bundler" },
{ name: "Bun.password", description: "Hash and verify passwords", category: "Crypto" },
{ name: "Bun.hash()", description: "Fast non-cryptographic hashing", category: "Crypto" },
{ name: "Bun.Transpiler", description: "JavaScript/TypeScript transpiler", category: "Bundler" },
{ name: "Bun.sleep()", description: "Async sleep for given duration", category: "Utilities" },
{ name: "Bun.which()", description: "Find an executable in PATH", category: "Process" },
{ name: "Bun.peek()", description: "Read a promise without awaiting", category: "Utilities" },
{ name: "Bun.gzipSync()", description: "Synchronous gzip compression", category: "Compression" },
{ name: "Bun.deflateSync()", description: "Synchronous deflate compression", category: "Compression" },
{ name: "Bun.inflateSync()", description: "Synchronous inflate decompression", category: "Compression" },
{ name: "Bun.gunzipSync()", description: "Synchronous gunzip decompression", category: "Compression" },
{ name: "Bun.color()", description: "Parse and convert colors", category: "Utilities" },
{ name: "Bun.semver", description: "Semantic versioning utilities", category: "Utilities" },
{ name: "Bun.dns", description: "DNS resolution", category: "Network" },
{ name: "Bun.connect()", description: "TCP/TLS client connection", category: "Network" },
{ name: "Bun.listen()", description: "TCP/TLS server listener", category: "Network" },
{ name: "Bun.udpSocket()", description: "UDP socket creation", category: "Network" },
{ name: "Bun.sql", description: "SQL database client (Postgres)", category: "Database" },
{ name: "Bun.redis", description: "Redis client", category: "Database" },
{ name: "Bun.s3()", description: "S3-compatible object storage", category: "Cloud" },
{ name: "Bun.Glob", description: "File pattern matching", category: "File I/O" },
{ name: "Bun.stringWidth()", description: "Display width of a string", category: "Utilities" },
{ name: "Bun.TUIScreen", description: "Terminal screen buffer", category: "TUI" },
{ name: "Bun.TUITerminalWriter", description: "Terminal ANSI writer", category: "TUI" },
{ name: "Bun.TUIKeyReader", description: "Terminal input reader", category: "TUI" },
{ name: "Bun.TUIBufferWriter", description: "Buffer-backed ANSI writer", category: "TUI" },
];
// --- State ---
let selectedIndex = 0;
let scrollOffset = 0;
let searchQuery = "";
let searchMode = false;
let filteredItems = [...ALL_ITEMS];
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const styles = {
title: screen.style({ fg: 0x61afef, bold: true }),
searchLabel: screen.style({ fg: 0xe5c07b, bold: true }),
searchText: screen.style({ fg: 0xffffff }),
searchPlaceholder: screen.style({ fg: 0x5c6370, italic: true }),
itemName: screen.style({ fg: 0xc678dd, bold: true }),
itemDesc: screen.style({ fg: 0xabb2bf }),
itemCategory: screen.style({ fg: 0x56b6c2, italic: true }),
selectedName: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
selectedDesc: screen.style({ fg: 0x000000, bg: 0x61afef }),
selectedCategory: screen.style({ fg: 0x000000, bg: 0x61afef, italic: true }),
selectedBg: screen.style({ bg: 0x61afef }),
border: screen.style({ fg: 0x5c6370 }),
scrollIndicator: screen.style({ fg: 0x61afef, bold: true }),
footer: screen.style({ fg: 0x5c6370 }),
count: screen.style({ fg: 0xe5c07b }),
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
detailLabel: screen.style({ fg: 0xabb2bf }),
detailValue: screen.style({ fg: 0xe5c07b }),
noResults: screen.style({ fg: 0xe06c75, italic: true }),
};
// --- Filter logic ---
function filterItems() {
if (searchQuery.length === 0) {
filteredItems = [...ALL_ITEMS];
} else {
const q = searchQuery.toLowerCase();
filteredItems = ALL_ITEMS.filter(
item =>
item.name.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.category.toLowerCase().includes(q),
);
}
// Reset selection if out of range
if (selectedIndex >= filteredItems.length) {
selectedIndex = Math.max(0, filteredItems.length - 1);
}
scrollOffset = 0;
}
// --- Render ---
function render() {
screen.clear();
const listWidth = Math.min(60, cols - 4);
const listX = 2;
const headerY = 0;
// Title
const titleText = "Bun API Explorer";
screen.setText(listX, headerY, titleText, styles.title);
// Search bar
const searchY = headerY + 2;
const searchLabel = searchMode ? "> " : "/ ";
screen.setText(listX, searchY, searchLabel, styles.searchLabel);
if (searchQuery.length > 0) {
screen.setText(listX + 2, searchY, searchQuery.slice(0, listWidth - 4), styles.searchText);
// Cursor indicator in search mode
if (searchMode) {
const curX = listX + 2 + searchQuery.length;
if (curX < listX + listWidth) {
screen.setText(curX, searchY, "_", styles.searchText);
}
}
} else if (!searchMode) {
screen.setText(listX + 2, searchY, "Type '/' to search...", styles.searchPlaceholder);
} else {
screen.setText(listX + 2, searchY, "Type to filter...", styles.searchPlaceholder);
}
// Item count
const countText = `${filteredItems.length}/${ALL_ITEMS.length} items`;
if (cols > listWidth + countText.length + 4) {
screen.setText(cols - countText.length - 2, searchY, countText, styles.count);
}
// Separator
const sepY = searchY + 1;
const sepChar = "\u2500"; // horizontal line
for (let i = 0; i < listWidth; i++) {
screen.setText(listX + i, sepY, sepChar, styles.border);
}
// List area
const listY = sepY + 1;
const maxVisibleItems = rows - listY - 2; // leave room for footer
if (filteredItems.length === 0) {
screen.setText(listX + 2, listY + 1, "No matching items found.", styles.noResults);
} else {
// Ensure selected item is visible
if (selectedIndex < scrollOffset) {
scrollOffset = selectedIndex;
} else if (selectedIndex >= scrollOffset + maxVisibleItems) {
scrollOffset = selectedIndex - maxVisibleItems + 1;
}
const visibleCount = Math.min(maxVisibleItems, filteredItems.length - scrollOffset);
for (let i = 0; i < visibleCount; i++) {
const itemIdx = scrollOffset + i;
const item = filteredItems[itemIdx];
const y = listY + i;
const isSelected = itemIdx === selectedIndex;
if (isSelected) {
// Highlight entire row
screen.fill(listX, y, listWidth, 1, " ", styles.selectedBg);
}
// Arrow indicator for selected item
const arrow = isSelected ? "\u25b6 " : " ";
screen.setText(listX, y, arrow, isSelected ? styles.selectedName : styles.itemName);
// Item name
const nameWidth = Math.min(item.name.length, Math.floor(listWidth * 0.4));
screen.setText(listX + 2, y, item.name.slice(0, nameWidth), isSelected ? styles.selectedName : styles.itemName);
// Description
const descX = listX + 2 + nameWidth + 1;
const descWidth = listWidth - nameWidth - 3;
if (descWidth > 0) {
screen.setText(
descX,
y,
item.description.slice(0, descWidth),
isSelected ? styles.selectedDesc : styles.itemDesc,
);
}
}
// Scroll indicators
if (scrollOffset > 0) {
screen.setText(listX + listWidth - 1, listY, "\u25b2", styles.scrollIndicator); // up arrow
}
if (scrollOffset + maxVisibleItems < filteredItems.length) {
screen.setText(listX + listWidth - 1, listY + visibleCount - 1, "\u25bc", styles.scrollIndicator); // down arrow
}
}
// Detail panel (right side, if room)
const detailX = listX + listWidth + 2;
const detailWidth = cols - detailX - 2;
if (detailWidth > 20 && filteredItems.length > 0) {
const item = filteredItems[selectedIndex];
const detailY = listY;
screen.drawBox(detailX, detailY - 1, detailWidth, 8, {
style: "rounded",
styleId: styles.border,
fill: true,
});
screen.setText(detailX + 2, detailY - 1, " Details ", styles.detailHeader);
let dy = detailY;
screen.setText(detailX + 2, dy, "Name:", styles.detailLabel);
screen.setText(detailX + 12, dy, item.name.slice(0, detailWidth - 14), styles.detailValue);
dy++;
screen.setText(detailX + 2, dy, "Category:", styles.detailLabel);
screen.setText(detailX + 12, dy, item.category, styles.itemCategory);
dy++;
dy++;
screen.setText(detailX + 2, dy, "Description:", styles.detailLabel);
dy++;
// Word-wrap description
const words = item.description.split(" ");
let line = "";
for (const word of words) {
if (line.length + word.length + 1 > detailWidth - 4) {
screen.setText(detailX + 2, dy, line, styles.itemDesc);
dy++;
line = word;
} else {
line = line.length > 0 ? line + " " + word : word;
}
}
if (line.length > 0) {
screen.setText(detailX + 2, dy, line, styles.itemDesc);
}
}
// Footer
const footerY = rows - 1;
const footerParts = ["\u2191\u2193 Navigate", "Enter Select", "/ Search", "Esc Cancel", "Ctrl+C Quit"];
const footerText = " " + footerParts.join(" | ") + " ";
screen.setText(0, footerY, footerText.slice(0, cols), styles.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input handling ---
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean; sequence: string }) => {
const { name, ctrl } = event;
// Ctrl+C always quits
if (ctrl && name === "c") {
cleanup();
return;
}
if (searchMode) {
// Search mode input handling
switch (name) {
case "escape":
searchMode = false;
searchQuery = "";
filterItems();
break;
case "enter":
searchMode = false;
break;
case "backspace":
if (searchQuery.length > 0) {
searchQuery = searchQuery.slice(0, -1);
filterItems();
}
break;
case "up":
searchMode = false;
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
searchMode = false;
if (selectedIndex < filteredItems.length - 1) selectedIndex++;
break;
default:
// Printable character
if (!ctrl && !event.alt && name.length === 1) {
searchQuery += name;
filterItems();
}
break;
}
} else {
// Normal mode input handling
switch (name) {
case "up":
case "k":
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
case "j":
if (selectedIndex < filteredItems.length - 1) selectedIndex++;
break;
case "home":
selectedIndex = 0;
break;
case "end":
selectedIndex = Math.max(0, filteredItems.length - 1);
break;
case "pageup": {
const pageSize = rows - 6;
selectedIndex = Math.max(0, selectedIndex - pageSize);
break;
}
case "pagedown": {
const pageSize = rows - 6;
selectedIndex = Math.min(filteredItems.length - 1, selectedIndex + pageSize);
break;
}
case "/":
searchMode = true;
searchQuery = "";
break;
case "escape":
if (searchQuery.length > 0) {
searchQuery = "";
filterItems();
} else {
cleanup();
}
break;
case "enter":
// Could do something with selected item; for now just flash
break;
}
}
render();
};
// --- Handle paste into search ---
reader.onpaste = (text: string) => {
if (searchMode) {
// Add pasted text to search (first line only, no newlines)
const firstLine = text.split("\n")[0];
searchQuery += firstLine;
filterItems();
render();
}
};
// --- Handle resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render ---
render();

View File

@@ -0,0 +1,426 @@
/**
* demo-logs.ts — Real-time Log Viewer
*
* Simulated real-time log stream with color-coded log levels, timestamp
* formatting, level filtering, search, follow mode, and log count stats.
*
* Demonstrates: auto-scrolling append-only list, color-coded categories,
* level-based filtering, search highlighting, setText, fill, style
* (fg/bg/bold/italic/faint), TUITerminalWriter, TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-logs.ts
* Controls: j/k scroll, F follow, 1-5 filter level, / search, C clear, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x56b6c2, bold: true }),
timestamp: screen.style({ fg: 0x5c6370 }),
debug: screen.style({ fg: 0x5c6370 }),
debugLabel: screen.style({ fg: 0x5c6370, bold: true }),
info: screen.style({ fg: 0x61afef }),
infoLabel: screen.style({ fg: 0x61afef, bold: true }),
warn: screen.style({ fg: 0xe5c07b }),
warnLabel: screen.style({ fg: 0xe5c07b, bold: true }),
error: screen.style({ fg: 0xe06c75 }),
errorLabel: screen.style({ fg: 0xe06c75, bold: true }),
fatal: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
fatalLabel: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
statusBar: screen.style({ fg: 0x000000, bg: 0x3e4451 }),
statusLabel: screen.style({ fg: 0xabb2bf, bg: 0x3e4451 }),
statusValue: screen.style({ fg: 0xe5c07b, bg: 0x3e4451, bold: true }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
filterActive: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
filterInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
follow: screen.style({ fg: 0x98c379, bold: true }),
paused: screen.style({ fg: 0xe06c75, bold: true }),
searchBar: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
searchLabel: screen.style({ fg: 0xe5c07b, bg: 0x2c313a, bold: true }),
highlight: screen.style({ fg: 0x000000, bg: 0xe5c07b, bold: true }),
source: screen.style({ fg: 0xc678dd }),
dim: screen.style({ fg: 0x3e4451 }),
};
// --- Log levels ---
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL";
const LEVELS: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR", "FATAL"];
interface LogEntry {
time: Date;
level: LogLevel;
source: string;
message: string;
}
// --- State ---
const logs: LogEntry[] = [];
const MAX_LOGS = 5000;
let scrollOffset = 0;
let following = true;
let minLevel: LogLevel = "DEBUG";
let searchMode = false;
let searchText = "";
let paused = false;
// --- Log sources and message templates ---
const sources = ["http", "db", "auth", "cache", "worker", "scheduler", "api", "ws"];
const templates: Record<LogLevel, string[]> = {
DEBUG: [
"Connection pool size: {n} active, {n} idle",
"Cache hit for key: user:{n}",
"Query executed in {n}ms",
"Middleware chain: {n} handlers",
"GC pause: {n}ms",
"Event loop latency: {n}ms",
],
INFO: [
"Request completed: GET /api/users - 200 ({n}ms)",
"Request completed: POST /api/data - 201 ({n}ms)",
"User authenticated: user_{n}@example.com",
"WebSocket client connected from 10.0.{n}.{n}",
"Background job completed: sync_data_{n}",
"Server listening on port {n}",
"Database migration applied: v{n}",
],
WARN: [
"Slow query detected: SELECT * FROM users ({n}ms)",
"Rate limit approaching: {n}/1000 requests",
"Memory usage high: {n}% of allocated",
"Connection pool exhausted, queuing request",
"Deprecated API called: /v1/legacy/users",
"Certificate expires in {n} days",
],
ERROR: [
"Database connection failed: ETIMEDOUT after {n}ms",
"Request failed: GET /api/data - 500 Internal Server Error",
"Unhandled promise rejection in worker #{n}",
"Failed to parse JSON payload: Unexpected token at position {n}",
"Redis connection lost, reconnecting in {n}s",
],
FATAL: [
"Out of memory: heap limit reached ({n} MB)",
"Database cluster unreachable, all replicas down",
"CRITICAL: Data corruption detected in shard {n}",
],
};
function randomLog(): LogEntry {
// Weight towards lower severity
const r = Math.random();
let level: LogLevel;
if (r < 0.3) level = "DEBUG";
else if (r < 0.7) level = "INFO";
else if (r < 0.88) level = "WARN";
else if (r < 0.97) level = "ERROR";
else level = "FATAL";
const msgs = templates[level];
let msg = msgs[Math.floor(Math.random() * msgs.length)];
msg = msg.replace(/\{n\}/g, () => String(Math.floor(Math.random() * 999) + 1));
return {
time: new Date(),
level,
source: sources[Math.floor(Math.random() * sources.length)],
message: msg,
};
}
// Seed with some initial logs
for (let i = 0; i < 30; i++) {
const entry = randomLog();
entry.time = new Date(Date.now() - (30 - i) * 1000);
logs.push(entry);
}
// --- Filtering ---
function levelIndex(l: LogLevel): number {
return LEVELS.indexOf(l);
}
function getFilteredLogs(): LogEntry[] {
let filtered = logs.filter(l => levelIndex(l.level) >= levelIndex(minLevel));
if (searchText.length > 0) {
const q = searchText.toLowerCase();
filtered = filtered.filter(l => l.message.toLowerCase().includes(q) || l.source.toLowerCase().includes(q));
}
return filtered;
}
// --- Style helpers ---
function levelStyle(level: LogLevel): number {
switch (level) {
case "DEBUG":
return st.debug;
case "INFO":
return st.info;
case "WARN":
return st.warn;
case "ERROR":
return st.error;
case "FATAL":
return st.fatal;
}
}
function levelLabelStyle(level: LogLevel): number {
switch (level) {
case "DEBUG":
return st.debugLabel;
case "INFO":
return st.infoLabel;
case "WARN":
return st.warnLabel;
case "ERROR":
return st.errorLabel;
case "FATAL":
return st.fatalLabel;
}
}
function formatTs(d: Date): string {
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${h}:${m}:${s}.${ms}`;
}
// --- Render ---
function render() {
screen.clear();
const filtered = getFilteredLogs();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Log Viewer ", st.titleBar);
const modeText = following ? " FOLLOW " : paused ? " PAUSED " : "";
if (modeText) {
screen.setText(cols - modeText.length - 2, 0, modeText, following ? st.follow : st.paused);
}
// Level filter tabs
let tx = 1;
for (const level of LEVELS) {
const count = logs.filter(l => l.level === level).length;
const label = ` ${level}(${count}) `;
const isActive = levelIndex(level) >= levelIndex(minLevel);
screen.setText(tx, 1, label, isActive ? st.filterActive : st.filterInactive);
tx += label.length + 1;
}
// Search bar
if (searchMode) {
screen.setText(1, 2, "/ ", st.searchLabel);
screen.setText(3, 2, searchText + "_", st.searchBar);
} else if (searchText.length > 0) {
screen.setText(1, 2, `Search: "${searchText}" (${filtered.length} matches)`, st.searchBar);
}
// Log area
const logY = 3;
const logH = rows - logY - 2;
// Auto-scroll when following
if (following) {
scrollOffset = Math.max(0, filtered.length - logH);
}
// Clamp scroll
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, filtered.length - logH)));
const visibleCount = Math.min(logH, filtered.length - scrollOffset);
const tsW = 12; // HH:MM:SS.mmm
const levelW = 6; // [XXXXX]
const srcW = 10;
for (let vi = 0; vi < visibleCount; vi++) {
const entry = filtered[scrollOffset + vi];
const y = logY + vi;
let x = 1;
// Timestamp
screen.setText(x, y, formatTs(entry.time), st.timestamp);
x += tsW + 1;
// Level badge
const lvl = entry.level.padEnd(5);
screen.setText(x, y, lvl, levelLabelStyle(entry.level));
x += levelW;
// Source
screen.setText(x, y, entry.source.padEnd(srcW).slice(0, srcW), st.source);
x += srcW;
// Separator
screen.setText(x, y, "\u2502", st.dim);
x += 2;
// Message
const msgW = cols - x - 1;
const msg = entry.message.slice(0, msgW);
screen.setText(x, y, msg, levelStyle(entry.level));
}
// Scroll position indicator
if (filtered.length > logH) {
const barH = Math.max(1, Math.floor((logH * logH) / filtered.length));
const barPos = Math.floor((scrollOffset / Math.max(1, filtered.length - logH)) * (logH - barH));
for (let i = 0; i < logH; i++) {
const ch = i >= barPos && i < barPos + barH ? "\u2588" : "\u2502";
const style = i >= barPos && i < barPos + barH ? st.infoLabel : st.dim;
screen.setText(cols - 1, logY + i, ch, style);
}
}
// Status bar
const statusY = rows - 2;
screen.fill(0, statusY, cols, 1, " ", st.statusBar);
screen.setText(1, statusY, `Total: `, st.statusLabel);
screen.setText(8, statusY, `${logs.length}`, st.statusValue);
screen.setText(14, statusY, ` Showing: `, st.statusLabel);
screen.setText(24, statusY, `${filtered.length}`, st.statusValue);
screen.setText(30, statusY, ` Level: `, st.statusLabel);
screen.setText(38, statusY, `>=${minLevel}`, st.statusValue);
// Footer
const footerY = rows - 1;
const footerText = " j/k:Scroll F:Follow 1-5:Level /:Search C:Clear Space:Pause q:Quit ";
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
const { name, ctrl, alt } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
if (searchMode) {
switch (name) {
case "enter":
case "escape":
searchMode = false;
if (name === "escape") searchText = "";
break;
case "backspace":
if (searchText.length > 0) searchText = searchText.slice(0, -1);
else searchMode = false;
break;
default:
if (!ctrl && !alt && name.length === 1) searchText += name;
break;
}
render();
return;
}
switch (name) {
case "q":
cleanup();
return;
case "up":
case "k":
following = false;
scrollOffset = Math.max(0, scrollOffset - 1);
break;
case "down":
case "j":
scrollOffset++;
break;
case "pageup":
following = false;
scrollOffset = Math.max(0, scrollOffset - (rows - 6));
break;
case "pagedown":
scrollOffset += rows - 6;
break;
case "home":
following = false;
scrollOffset = 0;
break;
case "end":
case "f":
following = true;
break;
case "1":
minLevel = "DEBUG";
break;
case "2":
minLevel = "INFO";
break;
case "3":
minLevel = "WARN";
break;
case "4":
minLevel = "ERROR";
break;
case "5":
minLevel = "FATAL";
break;
case "/":
searchMode = true;
searchText = "";
break;
case "c":
logs.length = 0;
scrollOffset = 0;
break;
case " ":
paused = !paused;
following = !paused && following;
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Log generation loop ---
const timer = setInterval(() => {
if (!paused) {
// Add 1-3 log entries per tick
const count = 1 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
logs.push(randomLog());
if (logs.length > MAX_LOGS) logs.shift();
}
}
render();
}, 300);
render();

View File

@@ -0,0 +1,95 @@
/**
* demo-mandelbrot.ts — Render a section of the Mandelbrot set using Unicode
* half-block characters and true color for double vertical resolution.
* Computes the fractal mathematically.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const cols = Math.min(writer.columns || 80, 72);
const pixelRows = 40; // Pixel rows (doubled via half-blocks)
const screenRows = Math.ceil(pixelRows / 2) + 3;
const screen = new Bun.TUIScreen(cols, screenRows);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
// Mandelbrot viewport
const xMin = -2.2;
const xMax = 0.8;
const yMin = -1.2;
const yMax = 1.2;
const maxIter = 80;
function mandelbrot(cx: number, cy: number): number {
let zx = 0;
let zy = 0;
let i = 0;
while (i < maxIter && zx * zx + zy * zy < 4) {
const tmp = zx * zx - zy * zy + cx;
zy = 2 * zx * zy + cy;
zx = tmp;
i++;
}
return i;
}
// Color palette based on iteration count
function iterToColor(iter: number): number {
if (iter === maxIter) return 0x000000; // Inside the set: black
// Smooth coloring using sinusoidal palette
const t = iter / maxIter;
const r = Math.round(9 * (1 - t) * t * t * t * 255);
const g = Math.round(15 * (1 - t) * (1 - t) * t * t * 255);
const b = Math.round(8.5 * (1 - t) * (1 - t) * (1 - t) * t * 255);
return (Math.min(255, r) << 16) | (Math.min(255, g) << 8) | Math.min(255, b);
}
// Compute the fractal
const pixels: number[][] = []; // [row][col] = color
for (let py = 0; py < pixelRows; py++) {
const row: number[] = [];
const cy = yMin + (py / (pixelRows - 1)) * (yMax - yMin);
for (let px = 0; px < cols; px++) {
const cx = xMin + (px / (cols - 1)) * (xMax - xMin);
const iter = mandelbrot(cx, cy);
row.push(iterToColor(iter));
}
pixels.push(row);
}
// Title
screen.setText(2, 0, "Mandelbrot Set", titleStyle);
// Render using half-blocks for double vertical resolution
const renderY = 2;
for (let py = 0; py < pixelRows; py += 2) {
for (let px = 0; px < cols; px++) {
const topColor = pixels[py][px];
const bottomColor = py + 1 < pixelRows ? pixels[py + 1][px] : 0x000000;
const y = renderY + Math.floor(py / 2);
if (topColor === bottomColor) {
// Both same color: use full block with fg
const s = screen.style({ fg: topColor });
screen.setText(px, y, "\u2588", s);
} else {
// Upper half block: fg = top color, bg = bottom color
const s = screen.style({ fg: topColor, bg: bottomColor });
screen.setText(px, y, "\u2580", s);
}
}
}
// Info line
const infoY = renderY + Math.ceil(pixelRows / 2);
const info = `${cols}x${pixelRows}px x:[${xMin}, ${xMax}] y:[${yMin}, ${yMax}] max_iter:${maxIter}`;
screen.setText(2, infoY, info, dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,95 @@
/**
* demo-matrix-inline.ts — A single frame of matrix-style katakana rain
* rendered inline (just one static snapshot, not animated).
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 64);
const height = 20;
const screen = new Bun.TUIScreen(width, height);
// Katakana range: U+30A0 to U+30FF
const katakana: string[] = [];
for (let cp = 0x30a0; cp <= 0x30ff; cp++) {
katakana.push(String.fromCodePoint(cp));
}
// Also include some digits and symbols for variety
const extras = "0123456789@#$%&*=+<>".split("");
const chars = [...katakana, ...extras];
// Seeded pseudo-random
let seed = 42;
function rand() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
function randChar(): string {
return chars[Math.floor(rand() * chars.length)];
}
// Create columns with "streams" of different lengths and positions
interface Stream {
x: number;
startY: number;
length: number;
speed: number; // how bright the tail is
}
const streams: Stream[] = [];
for (let x = 0; x < width; x++) {
if (rand() < 0.7) {
// 70% chance of having a stream in this column
const numStreams = rand() < 0.3 ? 2 : 1;
for (let s = 0; s < numStreams; s++) {
streams.push({
x,
startY: Math.floor(rand() * height),
length: 3 + Math.floor(rand() * (height - 3)),
speed: 0.5 + rand() * 0.5,
});
}
}
}
// Fill the screen with the background
const bgStyle = screen.style({ fg: 0x003300 });
screen.fill(0, 0, width, height, " ", bgStyle);
// Render each stream
for (const stream of streams) {
for (let i = 0; i < stream.length; i++) {
const y = (stream.startY + i) % height;
const ch = randChar();
let style: number;
if (i === stream.length - 1) {
// Head of the stream: bright white-green
style = screen.style({ fg: 0xffffff, bold: true });
} else if (i >= stream.length - 3) {
// Near head: bright green
style = screen.style({ fg: 0x00ff00, bold: true });
} else {
// Tail: fading green based on position
const fade = (i / stream.length) * stream.speed;
const g = Math.max(30, Math.round(200 * (1 - fade)));
style = screen.style({ fg: (0 << 16) | (g << 8) | 0 });
}
screen.setText(stream.x, y, ch, style);
}
}
// Add a few bright "sparkle" characters
for (let i = 0; i < 8; i++) {
const x = Math.floor(rand() * width);
const y = Math.floor(rand() * height);
const sparkle = screen.style({ fg: 0xccffcc, bold: true });
screen.setText(x, y, randChar(), sparkle);
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,375 @@
/**
* demo-mouse.ts — Mouse Interaction Demo
*
* Enables mouse tracking and allows the user to:
* - Click to place colored markers on the screen
* - Click and drag to draw lines
* - See live mouse coordinates
* - Scroll to cycle through marker colors
* - Right-click to erase markers
*
* Demonstrates: enableMouseTracking, disableMouseTracking, onmouse,
* TUIKeyReader, TUITerminalWriter, style (fg/bg), fill, setText,
* drawBox, alt screen, and cursor options.
*
* Run: bun run test/js/bun/tui/demos/demo-mouse.ts
* Exit: Press 'q' or Ctrl+C
*/
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
writer.enableMouseTracking();
// --- State ---
// Canvas stores placed markers: key is "x,y", value is color index
const canvas = new Map<string, number>();
// Current mouse position
let mouseX = 0;
let mouseY = 0;
// Is the mouse button currently held down (for drag drawing)?
let isDragging = false;
// Color palette for markers
const markerColors = [
0xff5555, // red
0xff8800, // orange
0xffff55, // yellow
0x55ff55, // green
0x55ffff, // cyan
0x5555ff, // blue
0xff55ff, // magenta
0xffffff, // white
];
let colorIndex = 0;
// Event log (most recent events shown in the info panel)
const eventLog: string[] = [];
function logEvent(msg: string) {
eventLog.push(msg);
if (eventLog.length > 100) eventLog.shift();
}
// --- Styles ---
const titleStyle = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
const infoLabel = screen.style({ fg: 0xabb2bf });
const infoValue = screen.style({ fg: 0xe5c07b });
const coordsStyle = screen.style({ fg: 0x98c379, bold: true });
const borderStyle = screen.style({ fg: 0x5c6370 });
const footerStyle = screen.style({ fg: 0x5c6370, italic: true });
const logStyle = screen.style({ fg: 0x5c6370 });
const canvasBg = screen.style({ bg: 0x1e2127 });
const crosshairH = screen.style({ fg: 0x3e4451 });
const crosshairV = screen.style({ fg: 0x3e4451 });
// --- Layout ---
const HEADER_HEIGHT = 1;
const SIDEBAR_WIDTH = 28;
const FOOTER_HEIGHT = 1;
function canvasLeft() {
return 0;
}
function canvasTop() {
return HEADER_HEIGHT;
}
function canvasWidth() {
return Math.max(1, cols - SIDEBAR_WIDTH);
}
function canvasHeight() {
return Math.max(1, rows - HEADER_HEIGHT - FOOTER_HEIGHT);
}
// --- Drawing helpers ---
/** Place a marker on the canvas at the given position. */
function placeMarker(x: number, y: number) {
const cx = x - canvasLeft();
const cy = y - canvasTop();
if (cx >= 0 && cx < canvasWidth() && cy >= 0 && cy < canvasHeight()) {
canvas.set(`${cx},${cy}`, colorIndex);
}
}
/** Erase a marker from the canvas. */
function eraseMarker(x: number, y: number) {
const cx = x - canvasLeft();
const cy = y - canvasTop();
canvas.delete(`${cx},${cy}`);
}
/** Draw a line between two points using Bresenham's algorithm. */
function drawLine(x0: number, y0: number, x1: number, y1: number) {
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let cx = x0;
let cy = y0;
while (true) {
placeMarker(cx, cy);
if (cx === x1 && cy === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
cx += sx;
}
if (e2 < dx) {
err += dx;
cy += sy;
}
}
}
// Track last drag position for continuous line drawing
let lastDragX = -1;
let lastDragY = -1;
// --- Render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, HEADER_HEIGHT, " ", titleStyle);
const title = " Mouse Demo - Click to Draw ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, titleStyle);
// Canvas area background
const cw = canvasWidth();
const ch = canvasHeight();
const cl = canvasLeft();
const ct = canvasTop();
screen.fill(cl, ct, cw, ch, " ", canvasBg);
// Draw crosshairs at mouse position
const relMx = mouseX - cl;
const relMy = mouseY - ct;
if (relMx >= 0 && relMx < cw && relMy >= 0 && relMy < ch) {
// Horizontal crosshair
for (let x = 0; x < cw; x++) {
if (x !== relMx) {
screen.setText(cl + x, mouseY, "\u00b7", crosshairH); // middle dot
}
}
// Vertical crosshair
for (let y = 0; y < ch; y++) {
if (y !== relMy) {
screen.setText(mouseX, ct + y, "\u00b7", crosshairV); // middle dot
}
}
}
// Draw all markers
for (const [key, ci] of canvas) {
const [cx, cy] = key.split(",").map(Number);
if (cx < cw && cy < ch) {
const color = markerColors[ci % markerColors.length];
const sid = screen.style({ fg: color, bold: true });
screen.setText(cl + cx, ct + cy, "\u2588", sid); // full block
}
}
// --- Sidebar ---
const sx = cols - SIDEBAR_WIDTH;
screen.drawBox(sx, ct, SIDEBAR_WIDTH, ch, {
style: "rounded",
styleId: borderStyle,
fill: true,
});
screen.setText(sx + 2, ct, " Info ", screen.style({ fg: 0x61afef, bold: true }));
let sy = ct + 1;
// Mouse coordinates
screen.setText(sx + 2, sy, "Position:", infoLabel);
screen.setText(sx + 12, sy, `(${mouseX}, ${mouseY})`, coordsStyle);
sy++;
// Canvas-relative coordinates
screen.setText(sx + 2, sy, "Canvas:", infoLabel);
screen.setText(sx + 12, sy, `(${relMx}, ${relMy})`, infoValue);
sy++;
// Dragging state
screen.setText(sx + 2, sy, "Dragging:", infoLabel);
screen.setText(sx + 12, sy, isDragging ? "Yes" : "No", infoValue);
sy++;
// Marker count
screen.setText(sx + 2, sy, "Markers:", infoLabel);
screen.setText(sx + 12, sy, `${canvas.size}`, infoValue);
sy += 2;
// Current color
screen.setText(sx + 2, sy, "Color:", infoLabel);
const currentColor = markerColors[colorIndex];
const colorSwatch = screen.style({ bg: currentColor });
screen.fill(sx + 9, sy, 3, 1, " ", colorSwatch);
const hexStr = `#${currentColor.toString(16).padStart(6, "0")}`;
screen.setText(sx + 13, sy, hexStr, infoValue);
sy += 2;
// Color palette
screen.setText(sx + 2, sy, "Palette (scroll):", infoLabel);
sy++;
for (let i = 0; i < markerColors.length; i++) {
const c = markerColors[i];
const sw = screen.style({ bg: c });
const indicator = i === colorIndex ? "\u25b6" : " ";
const indStyle = i === colorIndex ? screen.style({ fg: 0xe5c07b, bold: true }) : infoLabel;
screen.setText(sx + 2, sy + i, indicator, indStyle);
screen.fill(sx + 4, sy + i, 2, 1, " ", sw);
const hex = `#${c.toString(16).padStart(6, "0")}`;
screen.setText(sx + 7, sy + i, hex, i === colorIndex ? infoValue : infoLabel);
}
sy += markerColors.length + 1;
// Recent events
if (sy + 2 < ct + ch - 1) {
screen.setText(sx + 2, sy, "Events:", infoLabel);
sy++;
const maxEvents = Math.min(eventLog.length, ct + ch - 1 - sy);
const startIdx = Math.max(0, eventLog.length - maxEvents);
for (let i = startIdx; i < eventLog.length; i++) {
if (sy >= ct + ch - 1) break;
const msg = eventLog[i].slice(0, SIDEBAR_WIDTH - 4);
screen.setText(sx + 2, sy, msg, logStyle);
sy++;
}
}
// Footer
const footerY = rows - 1;
const footerText = " Click: Draw | Drag: Line | Right-click: Erase | Scroll: Color | c: Clear | q: Quit ";
screen.setText(0, footerY, footerText.slice(0, cols), footerStyle);
writer.render(screen, { cursorVisible: false });
}
// --- Mouse event handling ---
reader.onmouse = (event: {
type: string;
button: number;
x: number;
y: number;
shift: boolean;
alt: boolean;
ctrl: boolean;
}) => {
mouseX = event.x;
mouseY = event.y;
switch (event.type) {
case "down":
if (event.button === 0) {
// Left click
isDragging = true;
lastDragX = mouseX;
lastDragY = mouseY;
placeMarker(mouseX, mouseY);
logEvent(`click (${mouseX},${mouseY})`);
} else if (event.button === 2) {
// Right click - erase
eraseMarker(mouseX, mouseY);
logEvent(`erase (${mouseX},${mouseY})`);
}
break;
case "up":
if (isDragging) {
isDragging = false;
lastDragX = -1;
lastDragY = -1;
logEvent(`release (${mouseX},${mouseY})`);
}
break;
case "drag":
if (isDragging) {
if (lastDragX >= 0 && lastDragY >= 0) {
drawLine(lastDragX, lastDragY, mouseX, mouseY);
}
lastDragX = mouseX;
lastDragY = mouseY;
}
break;
case "move":
// Just update coordinates
break;
case "scrollUp":
colorIndex = (colorIndex - 1 + markerColors.length) % markerColors.length;
logEvent(`color: ${colorIndex}`);
break;
case "scrollDown":
colorIndex = (colorIndex + 1) % markerColors.length;
logEvent(`color: ${colorIndex}`);
break;
}
render();
};
// --- Keyboard ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
if (name === "c" && !ctrl) {
// Clear canvas
canvas.clear();
logEvent("canvas cleared");
}
// Number keys 1-8 to select color directly
const num = parseInt(name);
if (num >= 1 && num <= markerColors.length) {
colorIndex = num - 1;
logEvent(`color: ${colorIndex}`);
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
writer.disableMouseTracking();
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render ---
render();

View File

@@ -0,0 +1,127 @@
/**
* demo-network.ts — Simulated network request waterfall.
* Shows GET/POST requests with status codes, timing bars, and response sizes.
* Color-coded by status (green 200, yellow 3xx, red 4xx/5xx).
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 78);
const height = 20;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const headerStyle = screen.style({ fg: 0xe5c07b, bold: true });
const getStyle = screen.style({ fg: 0x61afef, bold: true });
const postStyle = screen.style({ fg: 0xc678dd, bold: true });
const s200 = screen.style({ fg: 0x98c379 });
const s200bar = screen.style({ fg: 0x98c379, bg: 0x2d4a2d });
const s301 = screen.style({ fg: 0xe5c07b });
const s301bar = screen.style({ fg: 0xe5c07b, bg: 0x4a3d1a });
const s404 = screen.style({ fg: 0xe06c75 });
const s404bar = screen.style({ fg: 0xe06c75, bg: 0x4a2025 });
const s500 = screen.style({ fg: 0xe06c75, bold: true });
const s500bar = screen.style({ fg: 0xe06c75, bg: 0x4a2025 });
const sizeStyle = screen.style({ fg: 0xabb2bf });
const urlStyle = screen.style({ fg: 0xabb2bf });
interface Request {
method: string;
url: string;
status: number;
startMs: number;
durationMs: number;
size: string;
}
const requests: Request[] = [
{ method: "GET", url: "/", status: 200, startMs: 0, durationMs: 45, size: "4.2K" },
{ method: "GET", url: "/api/users", status: 200, startMs: 50, durationMs: 120, size: "12.8K" },
{ method: "GET", url: "/styles.css", status: 200, startMs: 55, durationMs: 30, size: "8.1K" },
{ method: "GET", url: "/app.js", status: 200, startMs: 60, durationMs: 85, size: "42.5K" },
{ method: "POST", url: "/api/auth", status: 200, startMs: 180, durationMs: 200, size: "0.3K" },
{ method: "GET", url: "/old-page", status: 301, startMs: 200, durationMs: 15, size: "0.1K" },
{ method: "GET", url: "/api/data", status: 200, startMs: 390, durationMs: 340, size: "98.4K" },
{ method: "GET", url: "/missing.png", status: 404, startMs: 400, durationMs: 25, size: "0.2K" },
{ method: "POST", url: "/api/submit", status: 500, startMs: 420, durationMs: 150, size: "0.5K" },
{ method: "GET", url: "/api/config", status: 200, startMs: 440, durationMs: 55, size: "1.1K" },
];
function statusStyle(s: number) {
if (s >= 500) return s500;
if (s >= 400) return s404;
if (s >= 300) return s301;
return s200;
}
function barStyle(s: number) {
if (s >= 500) return s500bar;
if (s >= 400) return s404bar;
if (s >= 300) return s301bar;
return s200bar;
}
// Title
screen.setText(1, 0, "Network Waterfall", titleStyle);
screen.setText(1, 1, "\u2500".repeat(width - 2), dimStyle);
// Column headers
const methodCol = 1;
const statusCol = 7;
const urlCol = 12;
const waterfallCol = 36;
const sizeCol = width - 8;
let y = 2;
screen.setText(methodCol, y, "Meth", headerStyle);
screen.setText(statusCol, y, "Code", headerStyle);
screen.setText(urlCol, y, "URL", headerStyle);
screen.setText(waterfallCol, y, "Timeline", headerStyle);
screen.setText(sizeCol, y, "Size", headerStyle);
y++;
// Compute waterfall scale
const maxEnd = Math.max(...requests.map(r => r.startMs + r.durationMs));
const waterfallWidth = sizeCol - waterfallCol - 2;
for (const req of requests) {
const ms = screen.style({ fg: req.method === "POST" ? 0xc678dd : 0x61afef, bold: true });
screen.setText(methodCol, y, req.method.padEnd(5), ms);
screen.setText(statusCol, y, String(req.status), statusStyle(req.status));
const shortUrl = req.url.length > 22 ? req.url.slice(0, 20) + ".." : req.url;
screen.setText(urlCol, y, shortUrl, urlStyle);
// Waterfall bar
const barOffset = Math.round((req.startMs / maxEnd) * waterfallWidth);
const barLen = Math.max(1, Math.round((req.durationMs / maxEnd) * waterfallWidth));
// Light background track
screen.fill(waterfallCol, y, waterfallWidth, 1, "\u2500", dimStyle);
// Active bar
screen.fill(waterfallCol + barOffset, y, barLen, 1, "\u2588", barStyle(req.status));
// Duration label
const durStr = `${req.durationMs}ms`;
if (barOffset + barLen + durStr.length + 1 < waterfallWidth) {
screen.setText(waterfallCol + barOffset + barLen + 1, y, durStr, dimStyle);
}
screen.setText(sizeCol, y, req.size.padStart(6), sizeStyle);
y++;
}
// Summary
y++;
const total200 = requests.filter(r => r.status < 300).length;
const total3xx = requests.filter(r => r.status >= 300 && r.status < 400).length;
const totalErr = requests.filter(r => r.status >= 400).length;
screen.setText(1, y, `${requests.length} requests`, dimStyle);
screen.setText(16, y, `\u2714 ${total200} OK`, s200);
screen.setText(24, y, `\u2192 ${total3xx} redirect`, s301);
screen.setText(38, y, `\u2718 ${totalErr} errors`, s404);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,109 @@
/**
* demo-periodic.ts — A section of the periodic table rendered with colored boxes
* per element group. Shows elements 1-18 with symbol, atomic number, colored by group.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 78);
const height = 18;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
// Element groups with colors
const groups = {
nonmetal: { color: 0x98c379, name: "Nonmetal" },
noble: { color: 0xc678dd, name: "Noble Gas" },
alkali: { color: 0xe06c75, name: "Alkali Metal" },
alkaline: { color: 0xe5c07b, name: "Alkaline Earth" },
metalloid: { color: 0x56b6c2, name: "Metalloid" },
post: { color: 0x61afef, name: "Post-Trans. Metal" },
halogen: { color: 0xd19a66, name: "Halogen" },
};
interface Element {
num: number;
sym: string;
group: keyof typeof groups;
col: number; // 0-indexed column in periodic table
row: number; // 0-indexed row (period - 1)
}
const elements: Element[] = [
// Period 1
{ num: 1, sym: "H", group: "nonmetal", col: 0, row: 0 },
{ num: 2, sym: "He", group: "noble", col: 17, row: 0 },
// Period 2
{ num: 3, sym: "Li", group: "alkali", col: 0, row: 1 },
{ num: 4, sym: "Be", group: "alkaline", col: 1, row: 1 },
{ num: 5, sym: "B", group: "metalloid", col: 12, row: 1 },
{ num: 6, sym: "C", group: "nonmetal", col: 13, row: 1 },
{ num: 7, sym: "N", group: "nonmetal", col: 14, row: 1 },
{ num: 8, sym: "O", group: "nonmetal", col: 15, row: 1 },
{ num: 9, sym: "F", group: "halogen", col: 16, row: 1 },
{ num: 10, sym: "Ne", group: "noble", col: 17, row: 1 },
// Period 3
{ num: 11, sym: "Na", group: "alkali", col: 0, row: 2 },
{ num: 12, sym: "Mg", group: "alkaline", col: 1, row: 2 },
{ num: 13, sym: "Al", group: "post", col: 12, row: 2 },
{ num: 14, sym: "Si", group: "metalloid", col: 13, row: 2 },
{ num: 15, sym: "P", group: "nonmetal", col: 14, row: 2 },
{ num: 16, sym: "S", group: "nonmetal", col: 15, row: 2 },
{ num: 17, sym: "Cl", group: "halogen", col: 16, row: 2 },
{ num: 18, sym: "Ar", group: "noble", col: 17, row: 2 },
];
// Each element cell is 4 chars wide, 3 rows tall
const cellW = 4;
const cellH = 3;
const gridX = 1;
const gridY = 3;
// Title
screen.setText(2, 0, "Periodic Table (Elements 1-18)", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
// Render elements
for (const el of elements) {
const grp = groups[el.group];
const x = gridX + el.col * cellW;
const y = gridY + el.row * cellH;
// Only render if it fits
if (x + cellW > width) continue;
const borderS = screen.style({ fg: grp.color });
const numS = screen.style({ fg: grp.color, faint: true });
const symS = screen.style({ fg: grp.color, bold: true });
// Draw mini box
screen.drawBox(x, y, cellW, cellH, { style: "single", styleId: borderS });
// Atomic number (top-left inside)
const numStr = String(el.num);
screen.setText(x + 1, y, numStr, numS);
// Symbol (center)
screen.setText(x + 1, y + 1, el.sym, symS);
}
// Legend
const legendY = gridY + 3 * cellH + 1;
let lx = 2;
for (const [key, grp] of Object.entries(groups)) {
const s = screen.style({ fg: grp.color });
screen.setText(lx, legendY, "\u2588", s);
screen.setText(lx + 2, legendY, grp.name, screen.style({ fg: grp.color, faint: true }));
lx += grp.name.length + 4;
if (lx > width - 15) {
// Wrap to next line if needed
break;
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,103 @@
/**
* demo-piano.ts — ASCII piano keyboard (2 octaves) with black and white keys
* using box-drawing characters and half-blocks. Labels the notes.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 72);
const height = 14;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const whiteKey = screen.style({ fg: 0xffffff, bg: 0xffffff });
const whiteKeyBorder = screen.style({ fg: 0x888888, bg: 0xffffff });
const blackKey = screen.style({ fg: 0x222222, bg: 0x222222 });
const labelStyle = screen.style({ fg: 0x333333, bg: 0xffffff });
const blackLabel = screen.style({ fg: 0x888888 });
const borderDark = screen.style({ fg: 0x444444 });
// Piano layout: 2 octaves
// White keys: C D E F G A B (7 per octave = 14 total)
// Black keys pattern: C# D# _ F# G# A# _ (5 per octave = 10 total)
const whiteNotes = ["C", "D", "E", "F", "G", "A", "B", "C", "D", "E", "F", "G", "A", "B"];
const octaveMarks = [4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5];
// Black key positions relative to white keys (0-indexed)
// After C(0): C#, After D(1): D#, skip E(2), After F(3): F#, After G(4): G#, After A(5): A#, skip B(6)
const blackKeyAfter = [0, 1, 3, 4, 5]; // Indices of white keys that have a sharp
const keyWidth = 4;
const totalKeys = whiteNotes.length;
const pianoWidth = totalKeys * keyWidth + 1;
const startX = Math.max(1, Math.floor((width - pianoWidth) / 2));
// Title
screen.setText(2, 0, "Piano Keyboard (2 Octaves)", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
const pianoTop = 3;
const whiteKeyHeight = 7;
const blackKeyHeight = 4;
// Draw white keys (background)
for (let i = 0; i < totalKeys; i++) {
const x = startX + i * keyWidth;
// Fill white key area
screen.fill(x, pianoTop, keyWidth, whiteKeyHeight, " ", whiteKey);
// Right border of each key
for (let row = 0; row < whiteKeyHeight; row++) {
screen.setText(x + keyWidth, pianoTop + row, "\u2502", borderDark);
}
}
// Left border
for (let row = 0; row < whiteKeyHeight; row++) {
screen.setText(startX, pianoTop + row, "\u2502", borderDark);
}
// Top border
screen.fill(startX, pianoTop, pianoWidth, 1, "\u2500", borderDark);
// Bottom border
screen.fill(startX, pianoTop + whiteKeyHeight, pianoWidth, 1, "\u2500", borderDark);
// White key note labels (at the bottom of keys)
for (let i = 0; i < totalKeys; i++) {
const x = startX + i * keyWidth + 1;
const label = whiteNotes[i] + octaveMarks[i];
screen.setText(x, pianoTop + whiteKeyHeight - 1, label, labelStyle);
}
// Draw black keys (on top of white keys)
for (let oct = 0; oct < 2; oct++) {
for (const bk of blackKeyAfter) {
const whiteIdx = oct * 7 + bk;
if (whiteIdx >= totalKeys - 1) continue;
// Black key sits between two white keys
const x = startX + whiteIdx * keyWidth + keyWidth - 1;
const bkWidth = keyWidth - 1;
// Draw black key body
screen.fill(x, pianoTop + 1, bkWidth, blackKeyHeight, "\u2588", blackKey);
}
}
// Black key labels above piano
const sharpNames = ["C#", "D#", "F#", "G#", "A#"];
for (let oct = 0; oct < 2; oct++) {
for (let bi = 0; bi < blackKeyAfter.length; bi++) {
const whiteIdx = oct * 7 + blackKeyAfter[bi];
if (whiteIdx >= totalKeys - 1) continue;
const x = startX + whiteIdx * keyWidth + keyWidth - 1;
const label = sharpNames[bi] + (oct + 4);
screen.setText(x, pianoTop + blackKeyHeight + 1, label, blackLabel);
}
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,466 @@
/**
* demo-progress.ts — Progress Bars and Spinners
*
* Multiple animated progress bars with different visual styles,
* a spinner, status text, and a task queue simulation.
* Demonstrates: setInterval animation loop, style (fg/bg/bold),
* setText, fill, drawBox, TUITerminalWriter, TUIKeyReader, alt screen.
*
* Run: bun run test/js/bun/tui/demos/demo-progress.ts
* Exit: Press 'q' or Ctrl+C
*/
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const styles = {
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
header: screen.style({ fg: 0x61afef, bold: true }),
label: screen.style({ fg: 0xabb2bf }),
labelBold: screen.style({ fg: 0xabb2bf, bold: true }),
dim: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
success: screen.style({ fg: 0x98c379, bold: true }),
error: screen.style({ fg: 0xe06c75, bold: true }),
warning: screen.style({ fg: 0xe5c07b, bold: true }),
info: screen.style({ fg: 0x61afef }),
border: screen.style({ fg: 0x5c6370 }),
// Progress bar styles
barGreen: screen.style({ bg: 0x98c379 }),
barBlue: screen.style({ bg: 0x61afef }),
barYellow: screen.style({ bg: 0xe5c07b }),
barRed: screen.style({ bg: 0xe06c75 }),
barCyan: screen.style({ bg: 0x56b6c2 }),
barMagenta: screen.style({ bg: 0xc678dd }),
barEmpty: screen.style({ fg: 0x3e4451 }),
barEmptyBg: screen.style({ bg: 0x2c313a }),
// Percent text
pctHigh: screen.style({ fg: 0x98c379, bold: true }),
pctMid: screen.style({ fg: 0xe5c07b, bold: true }),
pctLow: screen.style({ fg: 0xe06c75, bold: true }),
// Spinner
spinnerStyle: screen.style({ fg: 0xc678dd, bold: true }),
};
// --- Spinner frames ---
const spinnerFrames = [
"\u280b",
"\u2819",
"\u2839",
"\u2838",
"\u283c",
"\u2834",
"\u2826",
"\u2827",
"\u2807",
"\u280f",
];
// Alternative: ["\\", "|", "/", "-"]
let spinnerIdx = 0;
// --- Task simulation state ---
interface Task {
name: string;
progress: number; // 0.0 - 1.0
speed: number; // progress increment per tick
barStyle: number; // style ID for filled portion
status: "pending" | "running" | "complete" | "error";
statusText: string;
}
const tasks: Task[] = [
{
name: "Installing dependencies",
progress: 0,
speed: 0.008 + Math.random() * 0.005,
barStyle: styles.barGreen,
status: "running",
statusText: "Resolving packages...",
},
{
name: "Compiling TypeScript",
progress: 0,
speed: 0.012 + Math.random() * 0.005,
barStyle: styles.barBlue,
status: "pending",
statusText: "Waiting...",
},
{
name: "Running tests",
progress: 0,
speed: 0.006 + Math.random() * 0.003,
barStyle: styles.barCyan,
status: "pending",
statusText: "Waiting...",
},
{
name: "Building bundle",
progress: 0,
speed: 0.015 + Math.random() * 0.008,
barStyle: styles.barYellow,
status: "pending",
statusText: "Waiting...",
},
{
name: "Optimizing assets",
progress: 0,
speed: 0.005 + Math.random() * 0.003,
barStyle: styles.barMagenta,
status: "pending",
statusText: "Waiting...",
},
{
name: "Deploying to production",
progress: 0,
speed: 0.01 + Math.random() * 0.005,
barStyle: styles.barRed,
status: "pending",
statusText: "Waiting...",
},
];
// Status messages for each task (cycled during progress)
const statusMessages: Record<string, string[]> = {
"Installing dependencies": [
"Resolving packages...",
"Downloading modules...",
"Linking dependencies...",
"Verifying tree...",
],
"Compiling TypeScript": [
"Parsing source files...",
"Type checking...",
"Emitting declarations...",
"Generating output...",
],
"Running tests": ["test/unit/core...", "test/unit/api...", "test/integration...", "test/e2e..."],
"Building bundle": ["Scanning entry points...", "Tree shaking...", "Minifying...", "Writing output..."],
"Optimizing assets": ["Compressing images...", "Inlining CSS...", "Hashing filenames...", "Generating manifest..."],
"Deploying to production": [
"Uploading artifacts...",
"Updating DNS...",
"Invalidating cache...",
"Health checking...",
],
};
// Overall stats
let completedCount = 0;
let totalTicks = 0;
let paused = false;
let allDone = false;
// --- Animation tick ---
function tick() {
if (paused || allDone) return;
totalTicks++;
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
// Process tasks sequentially: start next when current is at a threshold
let activeFound = false;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
if (task.status === "complete" || task.status === "error") continue;
if (task.status === "pending") {
// Start this task if the previous one is at least 30% done or complete
if (i === 0 || tasks[i - 1].progress >= 0.3 || tasks[i - 1].status === "complete") {
task.status = "running";
} else {
continue;
}
}
if (task.status === "running") {
activeFound = true;
task.progress += task.speed * (0.8 + Math.random() * 0.4);
// Update status text based on progress
const msgs = statusMessages[task.name];
if (msgs) {
const msgIdx = Math.min(Math.floor(task.progress * msgs.length), msgs.length - 1);
task.statusText = msgs[msgIdx];
}
if (task.progress >= 1.0) {
task.progress = 1.0;
task.status = "complete";
task.statusText = "Done!";
completedCount++;
}
}
}
if (!activeFound && completedCount === tasks.length) {
allDone = true;
}
}
// --- Render helpers ---
function percentStyle(ratio: number): number {
if (ratio >= 0.8) return styles.pctHigh;
if (ratio >= 0.4) return styles.pctMid;
return styles.pctLow;
}
function statusIcon(status: string): { icon: string; style: number } {
switch (status) {
case "complete":
return { icon: "\u2714", style: styles.success }; // checkmark
case "error":
return { icon: "\u2718", style: styles.error }; // cross
case "running":
return { icon: spinnerFrames[spinnerIdx], style: styles.spinnerStyle };
default:
return { icon: "\u25cb", style: styles.dim }; // empty circle
}
}
/** Draw a progress bar with filled/empty sections. */
function drawProgressBar(x: number, y: number, width: number, ratio: number, fillStyle: number) {
const filled = Math.round(ratio * width);
const empty = width - filled;
if (filled > 0) {
screen.fill(x, y, filled, 1, " ", fillStyle);
}
if (empty > 0) {
screen.fill(x + filled, y, empty, 1, " ", styles.barEmptyBg);
}
// Overlay percentage text centered in the bar
const pctText = `${Math.round(ratio * 100)}%`;
const pctX = x + Math.floor((width - pctText.length) / 2);
const pctStyle =
ratio > 0.5
? screen.style({
fg: 0x000000,
bg: ratio >= 1.0 ? 0x98c379 : fillStyle === styles.barGreen ? 0x98c379 : 0x61afef,
bold: true,
})
: screen.style({ fg: 0xffffff, bg: 0x2c313a, bold: true });
// Just write the percentage without styling to keep it simple
screen.setText(pctX, y, pctText, percentStyle(ratio));
}
/** Draw a block-character progress bar (alternative style). */
function drawBlockBar(x: number, y: number, width: number, ratio: number) {
const blocks = [" ", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"];
const totalUnits = width * 8;
const filledUnits = Math.round(ratio * totalUnits);
const fullBlocks = Math.floor(filledUnits / 8);
const partialBlock = filledUnits % 8;
const barColor = screen.style({ fg: 0x61afef });
for (let i = 0; i < width; i++) {
if (i < fullBlocks) {
screen.setText(x + i, y, "\u2588", barColor);
} else if (i === fullBlocks && partialBlock > 0) {
screen.setText(x + i, y, blocks[partialBlock], barColor);
} else {
screen.setText(x + i, y, "\u2500", styles.barEmpty);
}
}
}
// --- Main render ---
function render() {
screen.clear();
// Title bar
screen.fill(0, 0, cols, 1, " ", styles.titleBar);
const title = " Progress Bars & Spinners ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, styles.titleBar);
let y = 2;
const leftMargin = 2;
const contentWidth = Math.min(cols - 4, 90);
// --- Overall progress ---
const overallProgress = tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length;
screen.setText(leftMargin, y, "Overall Progress", styles.header);
y++;
const overallBarWidth = Math.min(contentWidth - 10, 60);
drawBlockBar(leftMargin, y, overallBarWidth, overallProgress);
const overallPct = `${Math.round(overallProgress * 100)}%`;
screen.setText(leftMargin + overallBarWidth + 2, y, overallPct, percentStyle(overallProgress));
screen.setText(leftMargin + overallBarWidth + 8, y, `${completedCount}/${tasks.length} tasks`, styles.label);
y += 2;
// Separator
for (let i = 0; i < contentWidth; i++) {
screen.setText(leftMargin + i, y, "\u2500", styles.dim);
}
y++;
// --- Individual tasks ---
screen.setText(leftMargin, y, "Task Queue", styles.header);
y++;
const barWidth = Math.min(contentWidth - 40, 40);
const taskNameWidth = 26;
for (const task of tasks) {
if (y >= rows - 4) break;
// Status icon
const { icon, style: iconStyle } = statusIcon(task.status);
screen.setText(leftMargin, y, icon, iconStyle);
// Task name
const nameStyle =
task.status === "complete" ? styles.success : task.status === "running" ? styles.labelBold : styles.dim;
screen.setText(leftMargin + 2, y, task.name.padEnd(taskNameWidth).slice(0, taskNameWidth), nameStyle);
// Progress bar
const barX = leftMargin + 2 + taskNameWidth + 1;
if (task.status === "running" || task.status === "complete") {
drawProgressBar(barX, y, barWidth, task.progress, task.barStyle);
} else {
// Show empty bar for pending
screen.fill(barX, y, barWidth, 1, " ", styles.barEmptyBg);
}
// Status text
const statusX = barX + barWidth + 2;
const statusWidth = cols - statusX - 2;
if (statusWidth > 0) {
const st = task.statusText.slice(0, statusWidth);
const stStyle = task.status === "complete" ? styles.success : task.status === "error" ? styles.error : styles.dim;
screen.setText(statusX, y, st, stStyle);
}
y += 2; // spacing between tasks
}
// --- Spinner showcase ---
y = Math.max(y, rows - 8);
if (y < rows - 4) {
screen.setText(leftMargin, y, "Spinner Styles", styles.header);
y++;
const spinnerTypes = [
{
frames: ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
label: "Dots",
},
{ frames: ["|", "/", "-", "\\"], label: "Line" },
{ frames: ["\u25dc", "\u25dd", "\u25de", "\u25df"], label: "Arc" },
{ frames: ["\u2596", "\u2598", "\u259d", "\u2597"], label: "Block" },
{ frames: ["\u25a0", "\u25a1"], label: "Square" },
];
for (let i = 0; i < spinnerTypes.length; i++) {
const sp = spinnerTypes[i];
const frame = sp.frames[spinnerIdx % sp.frames.length];
const spX = leftMargin + i * 14;
if (spX + 12 < cols) {
screen.setText(spX, y, frame, styles.spinnerStyle);
screen.setText(spX + 2, y, sp.label, styles.label);
}
}
y += 2;
}
// --- Completion message ---
if (allDone) {
const doneMsg = " All tasks completed successfully! ";
const doneX = Math.max(leftMargin, Math.floor((cols - doneMsg.length) / 2));
const doneY = Math.min(y, rows - 3);
screen.setText(doneX, doneY, doneMsg, styles.success);
}
// --- Footer ---
const footerY = rows - 1;
const footerParts: string[] = [];
if (paused) {
footerParts.push("PAUSED");
}
footerParts.push("p: Pause/Resume");
footerParts.push("r: Restart");
footerParts.push("q: Quit");
const footerText = " " + footerParts.join(" | ") + " ";
screen.setText(0, footerY, footerText.slice(0, cols), paused ? styles.warning : styles.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Reset tasks ---
function resetTasks() {
for (const task of tasks) {
task.progress = 0;
task.speed = 0.005 + Math.random() * 0.01;
task.status = "pending";
task.statusText = "Waiting...";
}
tasks[0].status = "running";
tasks[0].statusText = statusMessages[tasks[0].name]?.[0] ?? "Starting...";
completedCount = 0;
totalTicks = 0;
allDone = false;
paused = false;
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "p":
paused = !paused;
break;
case "r":
resetTasks();
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Animation loop ---
const timer = setInterval(() => {
tick();
render();
}, 80); // ~12.5 fps
// --- Initial render ---
render();

View File

@@ -0,0 +1,123 @@
/**
* demo-qrcode.ts — A QR-code-like pattern rendered using block and space characters.
* Generates a deterministic pattern that visually resembles a QR code.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const qrSize = 25; // 25x25 modules (standard QR version 2 size)
const width = Math.max(qrSize + 4, 40);
const height = Math.ceil(qrSize / 2) + 5; // Half-block rendering doubles vertical resolution
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
// QR code data: true = dark module, false = light module
const grid: boolean[][] = Array.from({ length: qrSize }, () => Array(qrSize).fill(false));
// Finder patterns (7x7 squares in 3 corners)
function drawFinder(cx: number, cy: number) {
for (let y = 0; y < 7; y++) {
for (let x = 0; x < 7; x++) {
// Outer border
if (y === 0 || y === 6 || x === 0 || x === 6) {
grid[cy + y][cx + x] = true;
}
// Inner 3x3 block
else if (y >= 2 && y <= 4 && x >= 2 && x <= 4) {
grid[cy + y][cx + x] = true;
}
}
}
}
drawFinder(0, 0); // Top-left
drawFinder(qrSize - 7, 0); // Top-right
drawFinder(0, qrSize - 7); // Bottom-left
// Timing patterns (alternating dots between finders)
for (let i = 8; i < qrSize - 8; i++) {
grid[6][i] = i % 2 === 0;
grid[i][6] = i % 2 === 0;
}
// Alignment pattern (5x5 at position 18,18 for version 2+)
const apx = 18,
apy = 18;
for (let y = -2; y <= 2; y++) {
for (let x = -2; x <= 2; x++) {
if (Math.abs(x) === 2 || Math.abs(y) === 2 || (x === 0 && y === 0)) {
grid[apy + y][apx + x] = true;
}
}
}
// Separator patterns (white border around finders)
// Already false by default
// Fill data area with pseudo-random but deterministic pattern
let seed = 12345;
function prng() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
for (let y = 0; y < qrSize; y++) {
for (let x = 0; x < qrSize; x++) {
// Skip finder patterns and timing
if (x < 9 && y < 9) continue; // top-left finder + separator
if (x >= qrSize - 8 && y < 9) continue; // top-right finder + separator
if (x < 9 && y >= qrSize - 8) continue; // bottom-left finder + separator
if (y === 6 || x === 6) continue; // timing patterns
if (Math.abs(x - apx) <= 2 && Math.abs(y - apy) <= 2) continue; // alignment
grid[y][x] = prng() > 0.5;
}
}
// Title
screen.setText(2, 0, "QR Code Pattern", titleStyle);
// Render using half-blocks for double vertical resolution
// Upper-half block: top pixel dark, bottom pixel light = \u2580 (fg=dark)
// Lower-half block: top pixel light, bottom pixel dark = \u2584 (fg=dark)
// Full block: both dark = \u2588
// Space: both light = " "
const qrDark = 0x222222;
const qrLight = 0xffffff;
const startX = 2;
const startY = 2;
for (let row = 0; row < qrSize; row += 2) {
for (let col = 0; col < qrSize; col++) {
const top = grid[row][col];
const bottom = row + 1 < qrSize ? grid[row + 1][col] : false;
const x = startX + col;
const y = startY + Math.floor(row / 2);
if (top && bottom) {
const s = screen.style({ fg: qrDark });
screen.setText(x, y, "\u2588", s);
} else if (top && !bottom) {
const s = screen.style({ fg: qrDark, bg: qrLight });
screen.setText(x, y, "\u2580", s);
} else if (!top && bottom) {
const s = screen.style({ fg: qrDark, bg: qrLight });
screen.setText(x, y, "\u2584", s);
} else {
const s = screen.style({ fg: qrLight });
screen.setText(x, y, " ", s);
}
}
}
// Caption
const captionY = startY + Math.ceil(qrSize / 2) + 1;
screen.setText(2, captionY, "Scan me! (decorative only)", dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,102 @@
/**
* demo-report.ts — Test report summary with header box, pass/fail counts,
* mini bar chart of test durations, and a footer.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 72);
const height = 22;
const screen = new Bun.TUIScreen(width, height);
// Styles
const headerBg = screen.style({ fg: 0x000000, bg: 0x61afef, bold: true });
const passStyle = screen.style({ fg: 0x98c379, bold: true });
const failStyle = screen.style({ fg: 0xe06c75, bold: true });
const skipStyle = screen.style({ fg: 0xe5c07b });
const dimStyle = screen.style({ fg: 0x5c6370 });
const labelStyle = screen.style({ fg: 0xabb2bf });
const barPass = screen.style({ fg: 0x98c379 });
const barFail = screen.style({ fg: 0xe06c75 });
const borderStyle = screen.style({ fg: 0x3e4452 });
const totalStyle = screen.style({ fg: 0xffffff, bold: true });
// Header
screen.drawBox(0, 0, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
screen.fill(1, 1, width - 2, 1, " ", headerBg);
const title = " Test Report \u2014 my-project ";
screen.setText(Math.floor((width - title.length) / 2), 1, title, headerBg);
// Summary counts
const passed = 142;
const failed = 3;
const skipped = 7;
const total = passed + failed + skipped;
let y = 4;
screen.setText(2, y, "Results:", totalStyle);
y++;
screen.setText(4, y, `\u2714 Passed: ${passed}`, passStyle);
y++;
screen.setText(4, y, `\u2718 Failed: ${failed}`, failStyle);
y++;
screen.setText(4, y, `\u25CB Skipped: ${skipped}`, skipStyle);
y++;
screen.setText(4, y, ` Total: ${total}`, dimStyle);
// Summary bar
y += 2;
screen.setText(2, y, "Pass Rate:", labelStyle);
const barWidth = width - 16;
const passWidth = Math.round((passed / total) * barWidth);
const failWidth = Math.round((failed / total) * barWidth);
const skipWidth = barWidth - passWidth - failWidth;
let bx = 14;
screen.fill(bx, y, passWidth, 1, "\u2588", barPass);
bx += passWidth;
screen.fill(bx, y, failWidth, 1, "\u2588", barFail);
bx += failWidth;
screen.fill(bx, y, skipWidth, 1, "\u2591", skipStyle);
const pct = ((passed / total) * 100).toFixed(1);
screen.setText(bx + skipWidth + 1, y, `${pct}%`, passStyle);
// Test duration chart
y += 2;
screen.setText(2, y, "Test Durations (ms):", labelStyle);
y++;
const suites = [
{ name: "http/serve.test.ts", time: 1240, pass: true },
{ name: "crypto/hash.test.ts", time: 890, pass: true },
{ name: "fs/readFile.test.ts", time: 650, pass: true },
{ name: "shell/exec.test.ts", time: 2100, pass: false },
{ name: "fetch/client.test.ts", time: 430, pass: true },
{ name: "sqlite/query.test.ts", time: 780, pass: true },
];
const maxTime = Math.max(...suites.map(s => s.time));
const nameCol = 4;
const chartStart = 28;
const chartWidth = width - chartStart - 8;
for (const suite of suites) {
const nameStyle = suite.pass ? labelStyle : failStyle;
const shortName = suite.name.length > 22 ? suite.name.slice(0, 20) + ".." : suite.name;
screen.setText(nameCol, y, shortName, nameStyle);
const barLen = Math.max(1, Math.round((suite.time / maxTime) * chartWidth));
const bStyle = suite.pass ? barPass : barFail;
screen.fill(chartStart, y, barLen, 1, "\u2588", bStyle);
screen.setText(chartStart + barLen + 1, y, `${suite.time}`, dimStyle);
y++;
}
// Footer
y++;
screen.drawBox(0, y, width, 3, { style: "rounded", styleId: borderStyle, fill: true, fillChar: " " });
const footer = `Completed in 6.09s \u2022 ${new Date().toLocaleTimeString()}`;
screen.setText(Math.floor((width - footer.length) / 2), y + 1, footer, dimStyle);
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,355 @@
/**
* demo-snake.ts — Snake Game
*
* Classic snake game with food collection, growing tail, collision detection,
* speed increase, score tracking, and game over / restart.
*
* Demonstrates: setInterval game loop, keyboard input, dynamic cell rendering,
* fill, setText, style (fg/bg/bold), drawBox, alt screen, TUITerminalWriter,
* TUIKeyReader, and resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-snake.ts
* Controls: Arrow keys / WASD to move, R to restart, Q / Ctrl+C to quit
*/
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const styles = {
titleBar: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
border: screen.style({ fg: 0x5c6370 }),
snakeHead: screen.style({ fg: 0x000000, bg: 0x98c379, bold: true }),
snakeBody: screen.style({ fg: 0x000000, bg: 0x61afef }),
food: screen.style({ fg: 0xe06c75, bold: true }),
empty: screen.style({ bg: 0x1e2127 }),
score: screen.style({ fg: 0xe5c07b, bold: true }),
label: screen.style({ fg: 0xabb2bf }),
gameOver: screen.style({ fg: 0xe06c75, bold: true }),
gameOverBg: screen.style({ fg: 0xffffff, bg: 0xe06c75, bold: true }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
highScore: screen.style({ fg: 0xc678dd, bold: true }),
speed: screen.style({ fg: 0x56b6c2 }),
};
// --- Game constants ---
const HEADER_H = 1;
const FOOTER_H = 1;
const SIDEBAR_W = 20;
function fieldWidth() {
return Math.max(10, cols - SIDEBAR_W - 2);
}
function fieldHeight() {
return Math.max(6, rows - HEADER_H - FOOTER_H - 2);
}
function fieldX() {
return 1;
}
function fieldY() {
return HEADER_H + 1;
}
// --- Game state ---
type Point = { x: number; y: number };
type Direction = "up" | "down" | "left" | "right";
let snake: Point[] = [];
let direction: Direction = "right";
let nextDirection: Direction = "right";
let food: Point = { x: 0, y: 0 };
let score = 0;
let highScore = 0;
let gameOver = false;
let paused = false;
let tickCount = 0;
let baseInterval = 120; // ms per tick
let currentInterval = baseInterval;
function resetGame() {
const fw = fieldWidth();
const fh = fieldHeight();
const startX = Math.floor(fw / 2);
const startY = Math.floor(fh / 2);
snake = [
{ x: startX, y: startY },
{ x: startX - 1, y: startY },
{ x: startX - 2, y: startY },
];
direction = "right";
nextDirection = "right";
score = 0;
gameOver = false;
paused = false;
tickCount = 0;
currentInterval = baseInterval;
spawnFood();
}
function spawnFood() {
const fw = fieldWidth();
const fh = fieldHeight();
let attempts = 0;
do {
food = {
x: Math.floor(Math.random() * fw),
y: Math.floor(Math.random() * fh),
};
attempts++;
} while (snake.some(s => s.x === food.x && s.y === food.y) && attempts < 1000);
}
function tick() {
if (gameOver || paused) return;
tickCount++;
direction = nextDirection;
// Move head
const head = snake[0];
let nx = head.x;
let ny = head.y;
switch (direction) {
case "up":
ny--;
break;
case "down":
ny++;
break;
case "left":
nx--;
break;
case "right":
nx++;
break;
}
const fw = fieldWidth();
const fh = fieldHeight();
// Wall collision
if (nx < 0 || nx >= fw || ny < 0 || ny >= fh) {
gameOver = true;
if (score > highScore) highScore = score;
return;
}
// Self collision
if (snake.some(s => s.x === nx && s.y === ny)) {
gameOver = true;
if (score > highScore) highScore = score;
return;
}
snake.unshift({ x: nx, y: ny });
// Food collision
if (nx === food.x && ny === food.y) {
score += 10;
// Speed up every 50 points
if (score % 50 === 0 && currentInterval > 50) {
currentInterval = Math.max(50, currentInterval - 10);
restartTimer();
}
spawnFood();
} else {
snake.pop();
}
}
// --- Render ---
function render() {
screen.clear();
const fx = fieldX();
const fy = fieldY();
const fw = fieldWidth();
const fh = fieldHeight();
// Title bar
screen.fill(0, 0, cols, HEADER_H, " ", styles.titleBar);
const title = " Snake Game ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, styles.titleBar);
// Game field background
screen.fill(fx, fy, fw, fh, " ", styles.empty);
// Border around field
screen.drawBox(fx - 1, fy - 1, fw + 2, fh + 2, { style: "rounded", styleId: styles.border });
// Draw food
screen.setText(fx + food.x, fy + food.y, "\u2665", styles.food); // heart
// Draw snake
for (let i = snake.length - 1; i >= 0; i--) {
const seg = snake[i];
if (seg.x >= 0 && seg.x < fw && seg.y >= 0 && seg.y < fh) {
if (i === 0) {
// Head - directional character
let headChar = "\u25cf"; // filled circle
switch (direction) {
case "up":
headChar = "\u25b2";
break; // ▲
case "down":
headChar = "\u25bc";
break; // ▼
case "left":
headChar = "\u25c0";
break; // ◀
case "right":
headChar = "\u25b6";
break; // ▶
}
screen.setText(fx + seg.x, fy + seg.y, headChar, styles.snakeHead);
} else {
screen.setText(fx + seg.x, fy + seg.y, "\u2588", styles.snakeBody); // full block
}
}
}
// --- Sidebar ---
const sx = fx + fw + 2;
const sw = cols - sx - 1;
if (sw > 8) {
let sy = fy;
screen.setText(sx, sy, "Score", styles.label);
sy++;
screen.setText(sx, sy, `${score}`, styles.score);
sy += 2;
screen.setText(sx, sy, "High Score", styles.label);
sy++;
screen.setText(sx, sy, `${highScore}`, styles.highScore);
sy += 2;
screen.setText(sx, sy, "Length", styles.label);
sy++;
screen.setText(sx, sy, `${snake.length}`, styles.label);
sy += 2;
screen.setText(sx, sy, "Speed", styles.label);
sy++;
const speedPct = Math.round(((baseInterval - currentInterval) / (baseInterval - 50)) * 100);
screen.setText(sx, sy, `${speedPct}%`, styles.speed);
sy += 2;
if (paused) {
screen.setText(sx, sy, "PAUSED", styles.gameOver);
}
}
// Game over overlay
if (gameOver) {
const msgW = 24;
const msgH = 5;
const mx = Math.floor((fw - msgW) / 2) + fx;
const my = Math.floor((fh - msgH) / 2) + fy;
screen.drawBox(mx, my, msgW, msgH, { style: "double", styleId: styles.gameOver, fill: true });
screen.setText(mx + Math.floor((msgW - 9) / 2), my + 1, "GAME OVER", styles.gameOverBg);
const scoreText = `Score: ${score}`;
screen.setText(mx + Math.floor((msgW - scoreText.length) / 2), my + 2, scoreText, styles.score);
screen.setText(mx + Math.floor((msgW - 16) / 2), my + 3, "R to restart", styles.label);
}
// Footer
const footerY = rows - 1;
const footerText = " Arrows/WASD: Move | P: Pause | R: Restart | Q: Quit ";
screen.setText(0, footerY, footerText.slice(0, cols), styles.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
if (name === "r") {
resetGame();
render();
return;
}
if (name === "p" && !gameOver) {
paused = !paused;
render();
return;
}
if (gameOver || paused) return;
// Direction changes — prevent 180-degree reversal
switch (name) {
case "up":
case "w":
case "k":
if (direction !== "down") nextDirection = "up";
break;
case "down":
case "s":
case "j":
if (direction !== "up") nextDirection = "down";
break;
case "left":
case "a":
case "h":
if (direction !== "right") nextDirection = "left";
break;
case "right":
case "d":
case "l":
if (direction !== "left") nextDirection = "right";
break;
}
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Timer management ---
let timer: ReturnType<typeof setInterval>;
function restartTimer() {
clearInterval(timer);
timer = setInterval(() => {
tick();
render();
}, currentInterval);
}
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearInterval(timer);
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
resetGame();
render();
restartTimer();

View File

@@ -0,0 +1,103 @@
/**
* demo-sparkline-inline.ts — Renders several inline sparklines with labels
* showing CPU, Memory, Network, and Disk time series data.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = Math.min(writer.columns || 80, 72);
const height = 14;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const dimStyle = screen.style({ fg: 0x5c6370 });
const labelStyle = screen.style({ fg: 0xabb2bf });
const valueStyle = screen.style({ fg: 0xe5c07b, bold: true });
const sparkColors = {
cpu: screen.style({ fg: 0xe06c75 }),
mem: screen.style({ fg: 0x61afef }),
net: screen.style({ fg: 0x98c379 }),
disk: screen.style({ fg: 0xc678dd }),
latency: screen.style({ fg: 0xe5c07b }),
};
const sparkChars = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
// Seeded pseudo-random for reproducibility
let seed = 1337;
function rand() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
function generateSeries(len: number, min: number, max: number, smooth = true): number[] {
const data: number[] = [];
let val = min + rand() * (max - min);
for (let i = 0; i < len; i++) {
if (smooth) {
val += (rand() - 0.5) * (max - min) * 0.3;
val = Math.max(min, Math.min(max, val));
} else {
val = min + rand() * (max - min);
}
data.push(val);
}
return data;
}
function sparkline(data: number[], min: number, max: number): string {
return data
.map(v => {
const idx = Math.round(((v - min) / (max - min)) * (sparkChars.length - 1));
return sparkChars[Math.max(0, Math.min(sparkChars.length - 1, idx))];
})
.join("");
}
const sparkWidth = width - 30;
interface MetricDef {
label: string;
unit: string;
min: number;
max: number;
color: number;
key: keyof typeof sparkColors;
}
const metrics: MetricDef[] = [
{ label: "CPU Usage", unit: "%", min: 0, max: 100, color: 0xe06c75, key: "cpu" },
{ label: "Memory", unit: "GB", min: 2, max: 16, color: 0x61afef, key: "mem" },
{ label: "Network I/O", unit: "MB/s", min: 0, max: 500, color: 0x98c379, key: "net" },
{ label: "Disk I/O", unit: "MB/s", min: 0, max: 200, color: 0xc678dd, key: "disk" },
{ label: "Latency", unit: "ms", min: 1, max: 50, color: 0xe5c07b, key: "latency" },
];
// Title
screen.setText(2, 0, "System Metrics (last 60s)", titleStyle);
screen.setText(2, 1, "\u2500".repeat(width - 4), dimStyle);
let y = 3;
for (const metric of metrics) {
const data = generateSeries(sparkWidth, metric.min, metric.max);
const lastVal = data[data.length - 1];
const spark = sparkline(data, metric.min, metric.max);
screen.setText(2, y, metric.label.padEnd(12), labelStyle);
// Draw sparkline chars individually with color
const sparkStr = spark;
screen.setText(15, y, sparkStr, sparkColors[metric.key]);
// Current value
const valStr = `${lastVal.toFixed(1)} ${metric.unit}`;
screen.setText(15 + sparkWidth + 1, y, valStr, valueStyle);
y += 2;
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

View File

@@ -0,0 +1,136 @@
/**
* demo-spinner.ts — Inline Spinner & Status (No Alt Screen)
*
* A CLI-style spinner that renders inline without alt screen, updating
* in place. Shows a sequence of tasks with spinners that complete one
* by one — like npm install or a build tool output.
*
* Demonstrates: inline rendering without alt screen, in-place updates via
* diff rendering, small fixed-height screens, sequential task simulation,
* setText, fill, style (fg/bold), TUITerminalWriter, TUIScreen.
*
* Run: bun run test/js/bun/tui/demos/demo-spinner.ts
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
interface Task {
name: string;
duration: number; // ms
status: "pending" | "running" | "done" | "error";
detail: string;
}
const tasks: Task[] = [
{ name: "Resolving dependencies", duration: 800, status: "pending", detail: "package.json" },
{ name: "Downloading packages", duration: 1200, status: "pending", detail: "48 packages" },
{ name: "Linking modules", duration: 600, status: "pending", detail: "node_modules" },
{ name: "Compiling TypeScript", duration: 1500, status: "pending", detail: "src/**/*.ts" },
{ name: "Running tests", duration: 2000, status: "pending", detail: "257 tests" },
{ name: "Building bundle", duration: 1000, status: "pending", detail: "dist/index.js" },
{ name: "Generating types", duration: 700, status: "pending", detail: "dist/index.d.ts" },
];
// Render height: 1 line per task + 1 summary line
const SCREEN_H = tasks.length + 2;
const SCREEN_W = 70;
const screen = new Bun.TUIScreen(SCREEN_W, SCREEN_H);
// Styles
const stSpin = screen.style({ fg: 0xc678dd, bold: true });
const stName = screen.style({ fg: 0xabb2bf });
const stNameDone = screen.style({ fg: 0x5c6370 });
const stDetail = screen.style({ fg: 0x5c6370 });
const stDone = screen.style({ fg: 0x98c379, bold: true });
const stError = screen.style({ fg: 0xe06c75, bold: true });
const stPending = screen.style({ fg: 0x3e4451 });
const stSummary = screen.style({ fg: 0x61afef, bold: true });
const stTime = screen.style({ fg: 0xe5c07b });
let currentTask = 0;
let spinFrame = 0;
let taskStartTime = Date.now();
let totalStartTime = Date.now();
let done = false;
function renderTasks() {
screen.clear();
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const y = i;
switch (task.status) {
case "pending":
screen.setText(0, y, " \u25CB", stPending); // ○
screen.setText(4, y, task.name, stPending);
break;
case "running": {
const ch = SPIN[spinFrame % SPIN.length];
screen.setText(0, y, ` ${ch}`, stSpin);
screen.setText(4, y, task.name, stName);
screen.setText(4 + task.name.length + 1, y, task.detail, stDetail);
const elapsed = ((Date.now() - taskStartTime) / 1000).toFixed(1);
screen.setText(SCREEN_W - 6, y, `${elapsed}s`, stTime);
break;
}
case "done":
screen.setText(0, y, " \u2714", stDone); // ✔
screen.setText(4, y, task.name, stNameDone);
break;
case "error":
screen.setText(0, y, " \u2718", stError); // ✘
screen.setText(4, y, task.name, stError);
break;
}
}
// Summary line
const summaryY = tasks.length + 1;
if (done) {
const totalTime = ((Date.now() - totalStartTime) / 1000).toFixed(1);
screen.setText(0, summaryY, `\u2728 All tasks completed in ${totalTime}s`, stSummary);
} else {
const completed = tasks.filter(t => t.status === "done").length;
screen.setText(0, summaryY, ` ${completed}/${tasks.length} tasks complete`, stDetail);
}
writer.render(screen);
}
// Run tasks sequentially
function startNextTask() {
if (currentTask >= tasks.length) {
done = true;
renderTasks();
clearInterval(spinTimer);
// Final newlines to push content into scrollback
setTimeout(() => {
writer.write("\r\n");
writer.close();
process.exit(0);
}, 100);
return;
}
tasks[currentTask].status = "running";
taskStartTime = Date.now();
setTimeout(() => {
tasks[currentTask].status = "done";
currentTask++;
startNextTask();
}, tasks[currentTask].duration);
}
// Spinner animation
const spinTimer = setInterval(() => {
spinFrame++;
renderTasks();
}, 80);
// Start
renderTasks();
startNextTask();

View File

@@ -0,0 +1,424 @@
/**
* demo-split.ts — Split Pane Layout
*
* Demonstrates a composable split-pane layout with multiple independent
* panels: a sidebar navigation, a main content area, and a bottom status/log
* panel. Each pane has its own scroll state and focus behavior.
*
* Demonstrates: clipping (clip/unclip), copy between screens, multi-pane
* layouts, independent scroll states, focus tracking, drawBox, setText, fill,
* style, TUITerminalWriter, TUIKeyReader, alt screen, resize.
*
* Run: bun run test/js/bun/tui/demos/demo-split.ts
* Controls: Tab switch pane, j/k scroll active pane, Enter select, Q quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
borderFocused: screen.style({ fg: 0x61afef, bold: true }),
borderUnfocused: screen.style({ fg: 0x3e4451 }),
panelTitle: screen.style({ fg: 0x61afef, bold: true }),
panelTitleFocused: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
item: screen.style({ fg: 0xabb2bf }),
itemSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
itemActive: screen.style({ fg: 0x98c379, bold: true }),
header: screen.style({ fg: 0xc678dd, bold: true }),
text: screen.style({ fg: 0xabb2bf }),
code: screen.style({ fg: 0x98c379, bg: 0x21252b }),
dim: screen.style({ fg: 0x5c6370 }),
logInfo: screen.style({ fg: 0x61afef }),
logWarn: screen.style({ fg: 0xe5c07b }),
logError: screen.style({ fg: 0xe06c75 }),
logTime: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
focusIndicator: screen.style({ fg: 0xe5c07b, bold: true }),
};
// --- Content data ---
interface NavItem {
name: string;
icon: string;
content: string[];
}
const navItems: NavItem[] = [
{
name: "Getting Started",
icon: "\u{1F680}",
content: [
"Getting Started with Bun TUI",
"",
"The Bun TUI library provides low-level primitives for",
"building terminal user interfaces. It uses Ghostty's",
"cell grid internally for efficient rendering.",
"",
"Quick Start:",
"",
" const screen = new Bun.TUIScreen(80, 24);",
" const writer = new Bun.TUITerminalWriter(Bun.stdout);",
" const reader = new Bun.TUIKeyReader();",
"",
" screen.setText(0, 0, 'Hello, World!');",
" writer.render(screen);",
"",
"The screen is a grid of cells, each with a codepoint",
"and a style ID. Styles are interned for efficiency.",
],
},
{
name: "Screen API",
icon: "\u{1F4FA}",
content: [
"TuiScreen API Reference",
"",
"Constructor:",
" new Bun.TUIScreen(cols, rows)",
"",
"Methods:",
" setText(x, y, text, styleId?)",
" fill(x, y, w, h, char, styleId?)",
" clear()",
" clearRect(x, y, w, h)",
" resize(cols, rows)",
" copy(src, sx, sy, dx, dy, w, h)",
" style({ fg, bg, bold, ... })",
" drawBox(x, y, w, h, options?)",
" clip(x1, y1, x2, y2)",
" unclip()",
" getCell(x, y)",
" hyperlink(url)",
" setHyperlink(x, y, id)",
"",
"Properties:",
" width - column count",
" height - row count",
],
},
{
name: "Writer API",
icon: "\u{270D}",
content: [
"TuiTerminalWriter API Reference",
"",
"Constructor:",
" new Bun.TUITerminalWriter(Bun.stdout)",
"",
"Methods:",
" render(screen, options?)",
" clear()",
" close() / end()",
" enterAltScreen() / exitAltScreen()",
" enableMouseTracking()",
" disableMouseTracking()",
" enableFocusTracking()",
" disableFocusTracking()",
" enableBracketedPaste()",
" disableBracketedPaste()",
" write(string)",
"",
"Properties:",
" columns / rows - terminal dimensions",
" onresize - resize callback",
"",
"The writer does cell-level diffing between frames,",
"only emitting ANSI for changed cells.",
],
},
{
name: "Key Reader",
icon: "\u{2328}",
content: [
"TuiKeyReader API Reference",
"",
"Constructor:",
" new Bun.TUIKeyReader()",
"",
"Callbacks:",
" onkeypress = (event) => { ... }",
" event: { name, sequence, ctrl, shift, alt }",
"",
" onmouse = (event) => { ... }",
" event: { type, button, x, y, shift, alt, ctrl }",
" types: down, up, drag, move, scrollUp, scrollDown",
"",
" onpaste = (text) => { ... }",
" onfocus = () => { ... }",
" onblur = () => { ... }",
"",
"Methods:",
" close() - restore terminal, stop reading",
],
},
{
name: "Styling",
icon: "\u{1F3A8}",
content: [
"Style System",
"",
"Styles are interned objects with numeric IDs:",
"",
" const id = screen.style({",
" fg: 0xff0000, // RGB foreground",
" bg: 0x000088, // RGB background",
" bold: true,",
" italic: true,",
" underline: 'curly', // single|double|curly|dotted|dashed",
" underlineColor: 0xffff00,",
" strikethrough: true,",
" overline: true,",
" faint: true,",
" blink: true,",
" inverse: true,",
" });",
"",
"Style 0 is always the default (no styling).",
"Up to 4096 unique styles per screen.",
"",
"Colors can be specified as:",
" - Number: 0xff0000",
" - Hex string: '#ff0000'",
" - Object: { r: 255, g: 0, b: 0 }",
" - Palette: { palette: 196 }",
],
},
];
// --- Log entries ---
interface LogMsg {
time: string;
level: "info" | "warn" | "error";
text: string;
}
const logMessages: LogMsg[] = [
{ time: "12:00:01", level: "info", text: "Application started" },
{ time: "12:00:01", level: "info", text: "TUI screen initialized (80x24)" },
{ time: "12:00:02", level: "info", text: "Key reader started in raw mode" },
{ time: "12:00:03", level: "info", text: "Alt screen entered" },
{ time: "12:00:05", level: "warn", text: "Terminal does not support true color, falling back" },
{ time: "12:00:10", level: "info", text: "First render completed in 2ms" },
{ time: "12:00:15", level: "info", text: "Diff render: 0 changed cells" },
{ time: "12:00:20", level: "error", text: "Style capacity warning: 3800/4096 used" },
{ time: "12:00:25", level: "info", text: "Resize detected: 120x45" },
{ time: "12:00:30", level: "info", text: "Styles migrated after resize: 42 styles" },
];
// --- State ---
let focusedPane = 0; // 0=sidebar, 1=content, 2=logs
let sidebarSelected = 0;
let sidebarScroll = 0;
let contentScroll = 0;
let logScroll = 0;
// --- Layout ---
function sidebarW() {
return Math.min(28, Math.floor(cols * 0.25));
}
function contentW() {
return cols - sidebarW();
}
function contentH() {
return rows - 1 - logPanelH();
} // -1 for title
function logPanelH() {
return Math.max(4, Math.floor(rows * 0.25));
}
// --- Render ---
function render() {
screen.clear();
const sw = sidebarW();
const cw = contentW();
const ch = contentH();
const lh = logPanelH();
// Title bar
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Split Pane Demo ", st.titleBar);
const paneNames = ["Sidebar", "Content", "Logs"];
screen.setText(cols - paneNames[focusedPane].length - 12, 0, `Focus: ${paneNames[focusedPane]}`, st.titleBar);
// --- Sidebar ---
const sbBorder = focusedPane === 0 ? st.borderFocused : st.borderUnfocused;
const sbTitle = focusedPane === 0 ? st.panelTitleFocused : st.panelTitle;
screen.drawBox(0, 1, sw, ch, { style: "rounded", styleId: sbBorder });
screen.setText(2, 1, " Navigation ", sbTitle);
screen.clip(1, 2, sw - 1, ch);
const sbVisH = ch - 2;
if (sidebarSelected < sidebarScroll) sidebarScroll = sidebarSelected;
if (sidebarSelected >= sidebarScroll + sbVisH) sidebarScroll = sidebarSelected - sbVisH + 1;
for (let i = 0; i < sbVisH; i++) {
const idx = sidebarScroll + i;
if (idx >= navItems.length) break;
const item = navItems[idx];
const y = 2 + i;
const isSel = idx === sidebarSelected;
if (isSel) {
screen.fill(1, y, sw - 2, 1, " ", st.itemSelected);
screen.setText(2, y, `${item.icon} ${item.name}`, st.itemSelected);
} else {
screen.setText(2, y, `${item.icon} ${item.name}`, st.item);
}
}
screen.unclip();
// --- Content pane ---
const cx = sw;
const cBorder = focusedPane === 1 ? st.borderFocused : st.borderUnfocused;
const cTitle = focusedPane === 1 ? st.panelTitleFocused : st.panelTitle;
screen.drawBox(cx, 1, cw, ch, { style: "rounded", styleId: cBorder });
screen.setText(cx + 2, 1, ` ${navItems[sidebarSelected].name} `, cTitle);
screen.clip(cx + 1, 2, cx + cw - 1, ch);
const content = navItems[sidebarSelected].content;
const contentVisH = ch - 2;
if (contentScroll > Math.max(0, content.length - contentVisH)) {
contentScroll = Math.max(0, content.length - contentVisH);
}
for (let i = 0; i < contentVisH; i++) {
const lineIdx = contentScroll + i;
if (lineIdx >= content.length) break;
const line = content[lineIdx];
const y = 2 + i;
if (lineIdx === 0) {
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.header);
} else if (line.startsWith(" ")) {
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.code);
} else {
screen.setText(cx + 2, y, line.slice(0, cw - 4), st.text);
}
}
// Scroll indicator
if (content.length > contentVisH) {
const pct = Math.round((contentScroll / Math.max(1, content.length - contentVisH)) * 100);
screen.setText(cx + cw - 6, 1, ` ${pct}% `, st.dim);
}
screen.unclip();
// --- Log panel ---
const ly = 1 + ch;
const lBorder = focusedPane === 2 ? st.borderFocused : st.borderUnfocused;
const lTitle = focusedPane === 2 ? st.panelTitleFocused : st.panelTitle;
screen.drawBox(0, ly, cols, lh, { style: "rounded", styleId: lBorder });
screen.setText(2, ly, " Output ", lTitle);
screen.clip(1, ly + 1, cols - 1, ly + lh - 1);
const logVisH = lh - 2;
const maxLogScroll = Math.max(0, logMessages.length - logVisH);
if (logScroll > maxLogScroll) logScroll = maxLogScroll;
for (let i = 0; i < logVisH; i++) {
const idx = logScroll + i;
if (idx >= logMessages.length) break;
const msg = logMessages[idx];
const y = ly + 1 + i;
screen.setText(1, y, msg.time, st.logTime);
const lvlStyle = msg.level === "error" ? st.logError : msg.level === "warn" ? st.logWarn : st.logInfo;
screen.setText(11, y, `[${msg.level.toUpperCase().padEnd(5)}]`, lvlStyle);
screen.setText(19, y, msg.text.slice(0, cols - 21), st.text);
}
screen.unclip();
// Focus indicators (dots next to focused pane)
const focusY = [Math.floor(ch / 2) + 1, Math.floor(ch / 2) + 1, ly + Math.floor(lh / 2)];
const focusX = [0, cx, 0];
screen.setText(focusX[focusedPane], focusY[focusedPane], "\u25b6", st.focusIndicator);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "tab":
focusedPane = (focusedPane + 1) % 3;
break;
case "up":
case "k":
if (focusedPane === 0) {
if (sidebarSelected > 0) sidebarSelected--;
contentScroll = 0; // reset content scroll on nav change
} else if (focusedPane === 1) {
contentScroll = Math.max(0, contentScroll - 1);
} else {
logScroll = Math.max(0, logScroll - 1);
}
break;
case "down":
case "j":
if (focusedPane === 0) {
if (sidebarSelected < navItems.length - 1) sidebarSelected++;
contentScroll = 0;
} else if (focusedPane === 1) {
contentScroll++;
} else {
logScroll++;
}
break;
case "pageup":
if (focusedPane === 1) contentScroll = Math.max(0, contentScroll - 10);
else if (focusedPane === 2) logScroll = Math.max(0, logScroll - 5);
break;
case "pagedown":
if (focusedPane === 1) contentScroll += 10;
else if (focusedPane === 2) logScroll += 5;
break;
case "enter":
if (focusedPane === 0) {
focusedPane = 1; // switch to content pane
contentScroll = 0;
}
break;
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
render();

View File

@@ -0,0 +1,309 @@
/**
* demo-table.ts — Sortable Data Table
*
* An interactive data table with column headers, sorting, row selection,
* scrolling, and a detail panel.
*
* Demonstrates: tabular layout, column alignment, sort state, setText, fill,
* style (fg/bg/bold/italic/inverse), drawBox, TUITerminalWriter, TUIKeyReader,
* alt screen, and resize handling.
*
* Run: bun run test/js/bun/tui/demos/demo-table.ts
* Controls: j/k or arrows to navigate, 1-5 to sort by column, Tab to toggle
* sort direction, Q / Ctrl+C to quit
*/
// --- Data ---
interface Row {
name: string;
language: string;
stars: number;
version: string;
license: string;
}
const DATA: Row[] = [
{ name: "Bun", language: "Zig", stars: 75200, version: "1.3.9", license: "MIT" },
{ name: "Node.js", language: "C++", stars: 109000, version: "22.11.0", license: "MIT" },
{ name: "Deno", language: "Rust", stars: 97800, version: "2.1.4", license: "MIT" },
{ name: "esbuild", language: "Go", stars: 38500, version: "0.24.0", license: "MIT" },
{ name: "swc", language: "Rust", stars: 31800, version: "1.9.3", license: "Apache-2.0" },
{ name: "Vite", language: "TypeScript", stars: 70100, version: "6.0.3", license: "MIT" },
{ name: "webpack", language: "JavaScript", stars: 64900, version: "5.97.1", license: "MIT" },
{ name: "Rollup", language: "JavaScript", stars: 25400, version: "4.28.1", license: "MIT" },
{ name: "Parcel", language: "JavaScript", stars: 43500, version: "2.13.2", license: "MIT" },
{ name: "Turbopack", language: "Rust", stars: 26100, version: "2.3.3", license: "MPL-2.0" },
{ name: "Rome", language: "Rust", stars: 23900, version: "12.1.3", license: "MIT" },
{ name: "Biome", language: "Rust", stars: 16200, version: "1.9.4", license: "MIT" },
{ name: "Rspack", language: "Rust", stars: 10300, version: "1.1.8", license: "MIT" },
{ name: "tsup", language: "TypeScript", stars: 9400, version: "8.3.5", license: "MIT" },
{ name: "unbuild", language: "TypeScript", stars: 2400, version: "2.0.0", license: "MIT" },
{ name: "tsx", language: "TypeScript", stars: 9800, version: "4.19.2", license: "MIT" },
{ name: "Oxc", language: "Rust", stars: 12600, version: "0.40.0", license: "MIT" },
{ name: "Prettier", language: "JavaScript", stars: 49800, version: "3.4.2", license: "MIT" },
{ name: "ESLint", language: "JavaScript", stars: 25200, version: "9.16.0", license: "MIT" },
{ name: "TypeScript", language: "TypeScript", stars: 101000, version: "5.7.2", license: "Apache-2.0" },
];
type ColKey = keyof Row;
const COLUMNS: { key: ColKey; label: string; width: number; align: "left" | "right" }[] = [
{ key: "name", label: "Name", width: 16, align: "left" },
{ key: "language", label: "Language", width: 14, align: "left" },
{ key: "stars", label: "Stars", width: 10, align: "right" },
{ key: "version", label: "Version", width: 12, align: "left" },
{ key: "license", label: "License", width: 14, align: "left" },
];
// --- State ---
let selectedIndex = 0;
let scrollOffset = 0;
let sortColumn: ColKey = "stars";
let sortAscending = false;
let sortedData = sortData();
function sortData(): Row[] {
const copy = [...DATA];
copy.sort((a, b) => {
const av = a[sortColumn];
const bv = b[sortColumn];
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sortAscending ? cmp : -cmp;
});
return copy;
}
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const s = {
titleBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
headerBg: screen.style({ fg: 0xffffff, bg: 0x3e4451, bold: true }),
headerSort: screen.style({ fg: 0xe5c07b, bg: 0x3e4451, bold: true }),
rowEven: screen.style({ fg: 0xabb2bf }),
rowOdd: screen.style({ fg: 0xabb2bf, bg: 0x21252b }),
rowSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
number: screen.style({ fg: 0xe5c07b }),
numberOdd: screen.style({ fg: 0xe5c07b, bg: 0x21252b }),
numberSelected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
border: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
detailLabel: screen.style({ fg: 0xabb2bf }),
detailValue: screen.style({ fg: 0xe5c07b }),
detailHeader: screen.style({ fg: 0x61afef, bold: true }),
summary: screen.style({ fg: 0x98c379 }),
};
// --- Render ---
function render() {
screen.clear();
// Title
screen.fill(0, 0, cols, 1, " ", s.titleBar);
const title = " JS Runtime & Build Tool Comparison ";
screen.setText(Math.max(0, Math.floor((cols - title.length) / 2)), 0, title, s.titleBar);
const tableX = 1;
const tableY = 2;
const tableW = COLUMNS.reduce((sum, c) => sum + c.width + 1, 0) + 1;
const maxVisibleRows = rows - tableY - 4; // header + footer + detail
// Ensure selected is visible
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + maxVisibleRows) scrollOffset = selectedIndex - maxVisibleRows + 1;
// --- Column headers ---
const headerY = tableY;
screen.fill(tableX, headerY, Math.min(tableW, cols - tableX), 1, " ", s.headerBg);
let hx = tableX;
for (let ci = 0; ci < COLUMNS.length; ci++) {
const col = COLUMNS[ci];
const isSorted = col.key === sortColumn;
const arrow = isSorted ? (sortAscending ? " \u25b2" : " \u25bc") : "";
const label = `${ci + 1}:${col.label}${arrow}`;
const headerStyle = isSorted ? s.headerSort : s.headerBg;
if (col.align === "right") {
const padded = label.padStart(col.width);
screen.setText(hx, headerY, padded.slice(0, col.width), headerStyle);
} else {
screen.setText(hx, headerY, label.slice(0, col.width), headerStyle);
}
hx += col.width + 1;
}
// --- Separator ---
const sepY = headerY + 1;
for (let i = 0; i < Math.min(tableW, cols - tableX); i++) {
screen.setText(tableX + i, sepY, "\u2500", s.border);
}
// --- Data rows ---
const visibleCount = Math.min(maxVisibleRows, sortedData.length - scrollOffset);
for (let vi = 0; vi < visibleCount; vi++) {
const dataIdx = scrollOffset + vi;
const row = sortedData[dataIdx];
const rowY = sepY + 1 + vi;
const isSelected = dataIdx === selectedIndex;
const isOdd = vi % 2 === 1;
// Row background
const rowStyle = isSelected ? s.rowSelected : isOdd ? s.rowOdd : s.rowEven;
const numStyle = isSelected ? s.numberSelected : isOdd ? s.numberOdd : s.number;
if (isSelected) {
screen.fill(tableX, rowY, Math.min(tableW, cols - tableX), 1, " ", s.rowSelected);
} else if (isOdd) {
screen.fill(tableX, rowY, Math.min(tableW, cols - tableX), 1, " ", s.rowOdd);
}
let rx = tableX;
for (const col of COLUMNS) {
const val = String(row[col.key]);
const isNum = col.key === "stars";
const cellStyle = isNum ? numStyle : rowStyle;
if (col.align === "right") {
const formatted = isNum ? Number(row[col.key]).toLocaleString() : val;
const padded = formatted.padStart(col.width);
screen.setText(rx, rowY, padded.slice(0, col.width), cellStyle);
} else {
screen.setText(rx, rowY, val.slice(0, col.width), cellStyle);
}
rx += col.width + 1;
}
}
// --- Detail panel (right side) ---
const detailX = tableX + tableW + 1;
const detailW = cols - detailX - 1;
if (detailW > 16 && selectedIndex < sortedData.length) {
const sel = sortedData[selectedIndex];
const detailY = tableY;
screen.drawBox(detailX, detailY, detailW, 10, {
style: "rounded",
styleId: s.border,
fill: true,
});
screen.setText(detailX + 2, detailY, " Details ", s.detailHeader);
let dy = detailY + 1;
const pairs: [string, string][] = [
["Name:", sel.name],
["Language:", sel.language],
["Stars:", Number(sel.stars).toLocaleString()],
["Version:", sel.version],
["License:", sel.license],
];
for (const [label, value] of pairs) {
screen.setText(detailX + 2, dy, label, s.detailLabel);
screen.setText(detailX + 12, dy, value.slice(0, detailW - 14), s.detailValue);
dy++;
}
// Summary
dy++;
const avgStars = Math.round(sortedData.reduce((sum, r) => sum + r.stars, 0) / sortedData.length);
screen.setText(detailX + 2, dy, `Avg: ${avgStars.toLocaleString()} stars`, s.summary);
}
// --- Footer ---
const footerY = rows - 1;
const sortInfo = `Sorted by ${sortColumn} ${sortAscending ? "\u25b2" : "\u25bc"}`;
const navInfo = `${selectedIndex + 1}/${sortedData.length}`;
const footerText = ` \u2191\u2193/jk: Navigate | 1-5: Sort column | Tab: Toggle direction | ${sortInfo} | ${navInfo} | q: Quit `;
screen.setText(0, footerY, footerText.slice(0, cols), s.footer);
writer.render(screen, { cursorVisible: false });
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean }) => {
const { name, ctrl } = event;
if (name === "q" || (ctrl && name === "c")) {
cleanup();
return;
}
switch (name) {
case "up":
case "k":
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
case "j":
if (selectedIndex < sortedData.length - 1) selectedIndex++;
break;
case "home":
case "g":
selectedIndex = 0;
break;
case "end":
selectedIndex = sortedData.length - 1;
break;
case "pageup":
selectedIndex = Math.max(0, selectedIndex - (rows - 6));
break;
case "pagedown":
selectedIndex = Math.min(sortedData.length - 1, selectedIndex + (rows - 6));
break;
case "tab":
sortAscending = !sortAscending;
sortedData = sortData();
break;
case "1":
case "2":
case "3":
case "4":
case "5": {
const idx = parseInt(name) - 1;
if (idx < COLUMNS.length) {
if (sortColumn === COLUMNS[idx].key) {
sortAscending = !sortAscending;
} else {
sortColumn = COLUMNS[idx].key;
sortAscending = sortColumn === "name" || sortColumn === "language";
}
sortedData = sortData();
}
break;
}
}
render();
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render ---
render();

View File

@@ -0,0 +1,320 @@
/**
* demo-text-editor.ts — Simple Text Editor
*
* A minimal text editor with cursor movement, typing, backspace, enter,
* line numbers, scrolling, and a status bar.
* Demonstrates: alt screen, TUIKeyReader (arrow keys, printable chars,
* ctrl keys, shift), TUITerminalWriter, cursor rendering, setText, fill,
* style, clearRect, drawBox, clipboard paste.
*
* Run: bun run test/js/bun/tui/demos/demo-text-editor.ts
* Exit: Ctrl+Q
*/
// --- Editor state ---
let lines: string[] = [
"Welcome to the Bun TUI text editor!",
"",
"This is a minimal editor built with Bun.TUIScreen",
"and Bun.TUIKeyReader. Try the following:",
"",
" - Type to insert text at the cursor",
" - Arrow keys to move the cursor",
" - Enter to create a new line",
" - Backspace / Delete to remove characters",
" - Home / End to jump to line start / end",
" - Page Up / Page Down to scroll",
" - Ctrl+Q to quit",
"",
"The editor supports basic scrolling when the",
"document is taller than the screen.",
"",
"Have fun!",
];
let cursorRow = 0;
let cursorCol = 0;
let scrollY = 0; // first visible line index
// --- Setup ---
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Layout constants ---
const GUTTER_WIDTH = 5; // width reserved for line numbers
const STATUS_HEIGHT = 2; // status bar height at bottom
function editableRows() {
return rows - STATUS_HEIGHT;
}
// --- Styles ---
const styles = {
lineNumber: screen.style({ fg: 0x5c6370 }),
lineNumberCurrent: screen.style({ fg: 0xe5c07b, bold: true }),
text: 0, // default style
statusBar: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
statusBarRight: screen.style({ fg: 0x000000, bg: 0x61afef }),
helpBar: screen.style({ fg: 0x5c6370, bg: 0x282c34 }),
gutter: screen.style({ fg: 0x5c6370, bg: 0x21252b }),
cursor: screen.style({ fg: 0x000000, bg: 0x61afef }),
};
// --- Ensure cursor is visible by adjusting scroll ---
function ensureCursorVisible() {
const maxRow = editableRows();
if (cursorRow < scrollY) {
scrollY = cursorRow;
} else if (cursorRow >= scrollY + maxRow) {
scrollY = cursorRow - maxRow + 1;
}
}
// --- Clamp cursor to valid positions ---
function clampCursor() {
if (cursorRow < 0) cursorRow = 0;
if (cursorRow >= lines.length) cursorRow = lines.length - 1;
if (cursorCol < 0) cursorCol = 0;
if (cursorCol > lines[cursorRow].length) cursorCol = lines[cursorRow].length;
}
// --- Render the editor ---
function render() {
screen.clear();
const maxRow = editableRows();
const textWidth = cols - GUTTER_WIDTH;
// Draw each visible line
for (let i = 0; i < maxRow; i++) {
const lineIdx = scrollY + i;
const y = i;
// Gutter background
screen.fill(0, y, GUTTER_WIDTH - 1, 1, " ", styles.gutter);
if (lineIdx < lines.length) {
// Line number
const numStr = String(lineIdx + 1).padStart(GUTTER_WIDTH - 2, " ") + " ";
const numStyle = lineIdx === cursorRow ? styles.lineNumberCurrent : styles.lineNumber;
screen.setText(0, y, numStr, numStyle);
// Line content (clipped to available width)
const line = lines[lineIdx];
if (line.length > 0) {
const displayLine = line.slice(0, textWidth);
screen.setText(GUTTER_WIDTH, y, displayLine, styles.text);
}
} else {
// Tilde for lines beyond the buffer
screen.setText(GUTTER_WIDTH - 2, y, "~", styles.lineNumber);
}
}
// --- Status bar ---
const statusY = rows - STATUS_HEIGHT;
screen.fill(0, statusY, cols, 1, " ", styles.statusBar);
// Left side: mode and file info
const modeText = " EDIT ";
screen.setText(0, statusY, modeText, styles.statusBar);
const fileInfo = " [scratch buffer] ";
screen.setText(modeText.length, statusY, fileInfo, styles.statusBarRight);
// Right side: cursor position
const posText = `Ln ${cursorRow + 1}, Col ${cursorCol + 1} Lines: ${lines.length} `;
if (cols > posText.length + modeText.length + fileInfo.length) {
screen.setText(cols - posText.length, statusY, posText, styles.statusBarRight);
}
// Help bar
const helpY = rows - 1;
screen.fill(0, helpY, cols, 1, " ", styles.helpBar);
const helpText = " Ctrl+Q: Quit | Arrows: Move | Enter: New Line | Backspace: Delete ";
screen.setText(0, helpY, helpText.slice(0, cols), styles.helpBar);
// Render with cursor
const cursorScreenY = cursorRow - scrollY;
const cursorScreenX = GUTTER_WIDTH + cursorCol;
writer.render(screen, {
cursorX: cursorScreenX,
cursorY: cursorScreenY,
cursorVisible: true,
cursorStyle: "line",
cursorBlinking: true,
});
}
// --- Handle keyboard input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; shift: boolean; alt: boolean; sequence: string }) => {
const { name, ctrl, shift } = event;
// Ctrl+Q to quit
if (ctrl && name === "q") {
cleanup();
return;
}
// Ctrl+C also quits
if (ctrl && name === "c") {
cleanup();
return;
}
switch (name) {
case "up":
cursorRow--;
clampCursor();
break;
case "down":
cursorRow++;
clampCursor();
break;
case "left":
if (cursorCol > 0) {
cursorCol--;
} else if (cursorRow > 0) {
// Wrap to end of previous line
cursorRow--;
cursorCol = lines[cursorRow].length;
}
break;
case "right":
if (cursorCol < lines[cursorRow].length) {
cursorCol++;
} else if (cursorRow < lines.length - 1) {
// Wrap to start of next line
cursorRow++;
cursorCol = 0;
}
break;
case "home":
cursorCol = 0;
break;
case "end":
cursorCol = lines[cursorRow].length;
break;
case "pageup": {
const pageSize = editableRows() - 1;
cursorRow = Math.max(0, cursorRow - pageSize);
clampCursor();
break;
}
case "pagedown": {
const pageSize = editableRows() - 1;
cursorRow = Math.min(lines.length - 1, cursorRow + pageSize);
clampCursor();
break;
}
case "enter": {
// Split current line at cursor
const currentLine = lines[cursorRow];
const before = currentLine.slice(0, cursorCol);
const after = currentLine.slice(cursorCol);
lines[cursorRow] = before;
lines.splice(cursorRow + 1, 0, after);
cursorRow++;
cursorCol = 0;
break;
}
case "backspace": {
if (cursorCol > 0) {
// Delete character before cursor
const line = lines[cursorRow];
lines[cursorRow] = line.slice(0, cursorCol - 1) + line.slice(cursorCol);
cursorCol--;
} else if (cursorRow > 0) {
// Merge with previous line
const prevLen = lines[cursorRow - 1].length;
lines[cursorRow - 1] += lines[cursorRow];
lines.splice(cursorRow, 1);
cursorRow--;
cursorCol = prevLen;
}
break;
}
case "delete": {
const line = lines[cursorRow];
if (cursorCol < line.length) {
// Delete character at cursor
lines[cursorRow] = line.slice(0, cursorCol) + line.slice(cursorCol + 1);
} else if (cursorRow < lines.length - 1) {
// Merge with next line
lines[cursorRow] += lines[cursorRow + 1];
lines.splice(cursorRow + 1, 1);
}
break;
}
case "tab": {
// Insert two spaces
const line = lines[cursorRow];
lines[cursorRow] = line.slice(0, cursorCol) + " " + line.slice(cursorCol);
cursorCol += 2;
break;
}
default: {
// Insert printable character
if (!ctrl && !event.alt && name.length === 1) {
const line = lines[cursorRow];
lines[cursorRow] = line.slice(0, cursorCol) + name + line.slice(cursorCol);
cursorCol++;
}
break;
}
}
ensureCursorVisible();
render();
};
// Handle paste
reader.onpaste = (text: string) => {
// Insert pasted text at cursor, splitting on newlines
const pasteLines = text.split("\n");
for (let i = 0; i < pasteLines.length; i++) {
if (i > 0) {
// Insert a newline
const currentLine = lines[cursorRow];
const before = currentLine.slice(0, cursorCol);
const after = currentLine.slice(cursorCol);
lines[cursorRow] = before;
lines.splice(cursorRow + 1, 0, after);
cursorRow++;
cursorCol = 0;
}
// Insert the text
const insertText = pasteLines[i];
const line = lines[cursorRow];
lines[cursorRow] = line.slice(0, cursorCol) + insertText + line.slice(cursorCol);
cursorCol += insertText.length;
}
ensureCursorVisible();
render();
};
// Handle resize
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
ensureCursorVisible();
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Initial render ---
render();

View File

@@ -0,0 +1,461 @@
/**
* demo-todo.ts — Todo List Manager
*
* A fully interactive todo app with checkboxes, priorities, categories,
* inline editing, and persistence to a temp file.
*
* Demonstrates: checkbox toggling, inline text editing, priority styling,
* category filtering, setText, fill, style (fg/bg/bold/italic/strikethrough),
* drawBox, TUITerminalWriter, TUIKeyReader, alt screen, resize, onpaste.
*
* Run: bun run test/js/bun/tui/demos/demo-todo.ts
* Controls: j/k navigate, Space toggle, a add, e edit, d delete, 1-4 priority,
* Tab cycle filter, Q/Ctrl+C quit
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const reader = new Bun.TUIKeyReader();
let cols = writer.columns || 80;
let rows = writer.rows || 24;
let screen = new Bun.TUIScreen(cols, rows);
writer.enterAltScreen();
// --- Styles ---
const st = {
titleBar: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
header: screen.style({ fg: 0xc678dd, bold: true }),
label: screen.style({ fg: 0xabb2bf }),
dim: screen.style({ fg: 0x5c6370 }),
footer: screen.style({ fg: 0x5c6370, italic: true }),
border: screen.style({ fg: 0x5c6370 }),
selected: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
selectedDone: screen.style({ fg: 0x000000, bg: 0x61afef, strikethrough: true }),
done: screen.style({ fg: 0x5c6370, strikethrough: true }),
checkOn: screen.style({ fg: 0x98c379, bold: true }),
checkOff: screen.style({ fg: 0x5c6370 }),
checkOnSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
checkOffSel: screen.style({ fg: 0x000000, bg: 0x61afef }),
priHigh: screen.style({ fg: 0xe06c75, bold: true }),
priMed: screen.style({ fg: 0xe5c07b, bold: true }),
priLow: screen.style({ fg: 0x56b6c2 }),
priNone: screen.style({ fg: 0x5c6370 }),
priHighSel: screen.style({ fg: 0x000000, bg: 0x61afef, bold: true }),
catWork: screen.style({ fg: 0x61afef, italic: true }),
catPersonal: screen.style({ fg: 0xc678dd, italic: true }),
catHealth: screen.style({ fg: 0x98c379, italic: true }),
catOther: screen.style({ fg: 0xe5c07b, italic: true }),
statsLabel: screen.style({ fg: 0xabb2bf }),
statsValue: screen.style({ fg: 0xe5c07b, bold: true }),
inputBg: screen.style({ fg: 0xffffff, bg: 0x2c313a }),
inputLabel: screen.style({ fg: 0xe5c07b, bold: true }),
filterActive: screen.style({ fg: 0x000000, bg: 0xc678dd, bold: true }),
filterInactive: screen.style({ fg: 0xabb2bf, bg: 0x2c313a }),
};
// --- Data ---
type Priority = "high" | "medium" | "low" | "none";
type Category = "work" | "personal" | "health" | "other";
interface Todo {
text: string;
done: boolean;
priority: Priority;
category: Category;
createdAt: Date;
}
const todos: Todo[] = [
{ text: "Ship the TUI library", done: false, priority: "high", category: "work", createdAt: new Date() },
{ text: "Write documentation", done: false, priority: "medium", category: "work", createdAt: new Date() },
{ text: "Add more demo apps", done: true, priority: "medium", category: "work", createdAt: new Date() },
{ text: "Review pull requests", done: false, priority: "high", category: "work", createdAt: new Date() },
{ text: "Buy groceries", done: false, priority: "low", category: "personal", createdAt: new Date() },
{ text: "Go for a run", done: true, priority: "medium", category: "health", createdAt: new Date() },
{ text: "Read a book", done: false, priority: "low", category: "personal", createdAt: new Date() },
{ text: "Meditate", done: false, priority: "medium", category: "health", createdAt: new Date() },
{ text: "Clean the kitchen", done: false, priority: "low", category: "personal", createdAt: new Date() },
{ text: "Benchmark TUI rendering", done: false, priority: "high", category: "work", createdAt: new Date() },
];
// --- State ---
let selectedIndex = 0;
let scrollOffset = 0;
let editMode = false;
let editText = "";
let editCursor = 0;
let addMode = false;
let filterCategory: Category | "all" = "all";
const categories: (Category | "all")[] = ["all", "work", "personal", "health", "other"];
function getFilteredTodos(): Todo[] {
if (filterCategory === "all") return todos;
return todos.filter(t => t.category === filterCategory);
}
// --- Priority helpers ---
function priLabel(p: Priority): string {
switch (p) {
case "high":
return "!!!";
case "medium":
return "!! ";
case "low":
return "! ";
default:
return " ";
}
}
function priStyle(p: Priority, sel: boolean): number {
if (sel) return st.priHighSel;
switch (p) {
case "high":
return st.priHigh;
case "medium":
return st.priMed;
case "low":
return st.priLow;
default:
return st.priNone;
}
}
function catStyle(c: Category): number {
switch (c) {
case "work":
return st.catWork;
case "personal":
return st.catPersonal;
case "health":
return st.catHealth;
default:
return st.catOther;
}
}
function cyclePriority(p: Priority): Priority {
switch (p) {
case "none":
return "low";
case "low":
return "medium";
case "medium":
return "high";
case "high":
return "none";
}
}
// --- Render ---
function render() {
screen.clear();
const filtered = getFilteredTodos();
// Title
screen.fill(0, 0, cols, 1, " ", st.titleBar);
screen.setText(2, 0, " Todo List ", st.titleBar);
// Filter tabs
let tx = 2;
const tabY = 2;
for (const cat of categories) {
const label = ` ${cat === "all" ? "All" : cat.charAt(0).toUpperCase() + cat.slice(1)} `;
const style = cat === filterCategory ? st.filterActive : st.filterInactive;
screen.setText(tx, tabY, label, style);
tx += label.length + 1;
}
// Stats
const totalCount = filterCategory === "all" ? todos.length : filtered.length;
const doneCount = filtered.filter(t => t.done).length;
const statsText = `${doneCount}/${totalCount} done`;
screen.setText(cols - statsText.length - 2, tabY, statsText, st.statsValue);
// List
const listY = 4;
const listH = rows - listY - 2;
// Ensure selected is visible
if (selectedIndex >= filtered.length) selectedIndex = Math.max(0, filtered.length - 1);
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + listH) scrollOffset = selectedIndex - listH + 1;
if (filtered.length === 0) {
screen.setText(4, listY + 1, "No items. Press 'a' to add one.", st.dim);
}
const visibleCount = Math.min(listH, filtered.length - scrollOffset);
for (let vi = 0; vi < visibleCount; vi++) {
const idx = scrollOffset + vi;
const todo = filtered[idx];
const y = listY + vi;
const isSel = idx === selectedIndex;
const isEditing = isSel && editMode;
// Selection highlight
if (isSel) {
screen.fill(1, y, cols - 2, 1, " ", st.selected);
}
// Checkbox
const checkChar = todo.done ? "[\u2713]" : "[ ]";
screen.setText(
2,
y,
checkChar,
isSel ? (todo.done ? st.checkOnSel : st.checkOffSel) : todo.done ? st.checkOn : st.checkOff,
);
// Priority
screen.setText(6, y, priLabel(todo.priority), priStyle(todo.priority, isSel));
// Text
const textX = 10;
const maxTextW = cols - textX - 16;
if (isEditing) {
// Edit mode: show editable text with cursor
const displayText = editText.slice(0, maxTextW);
screen.setText(textX, y, displayText, st.inputBg);
// Fill remaining
if (displayText.length < maxTextW) {
screen.fill(textX + displayText.length, y, maxTextW - displayText.length, 1, " ", st.inputBg);
}
} else {
const textStyle = isSel ? (todo.done ? st.selectedDone : st.selected) : todo.done ? st.done : st.label;
const displayText = todo.text.slice(0, maxTextW);
screen.setText(textX, y, displayText, textStyle);
}
// Category tag
const tagX = cols - 12;
screen.setText(tagX, y, todo.category.slice(0, 8), isSel ? st.selected : catStyle(todo.category));
}
// Scroll indicators
if (scrollOffset > 0) {
screen.setText(cols - 2, listY, "\u25b2", st.statsValue);
}
if (scrollOffset + listH < filtered.length) {
screen.setText(cols - 2, listY + listH - 1, "\u25bc", st.statsValue);
}
// Add mode input
if (addMode) {
const addY = rows - 3;
screen.fill(1, addY, cols - 2, 1, " ", st.inputBg);
screen.setText(2, addY, "New: ", st.inputLabel);
screen.setText(7, addY, editText + "_", st.inputBg);
}
// Footer
const footerY = rows - 1;
let footerText: string;
if (editMode) {
footerText = " Enter: Save | Esc: Cancel | Editing... ";
} else if (addMode) {
footerText = " Enter: Add | Esc: Cancel | Type your todo... ";
} else {
footerText = " j/k:\u2195 Space:\u2713 a:Add e:Edit d:Del p:Priority Tab:Filter q:Quit ";
}
screen.setText(0, footerY, footerText.slice(0, cols), st.footer);
writer.render(screen, {
cursorVisible: editMode || addMode,
cursorX: editMode ? 10 + editCursor : addMode ? 7 + editCursor : 0,
cursorY: editMode ? listY + (selectedIndex - scrollOffset) : addMode ? rows - 3 : 0,
cursorStyle: "line",
});
}
// --- Input ---
reader.onkeypress = (event: { name: string; ctrl: boolean; alt: boolean }) => {
const { name, ctrl, alt } = event;
if (ctrl && name === "c") {
cleanup();
return;
}
const filtered = getFilteredTodos();
if (editMode) {
switch (name) {
case "enter":
if (editText.trim().length > 0) {
filtered[selectedIndex].text = editText.trim();
}
editMode = false;
break;
case "escape":
editMode = false;
break;
case "backspace":
if (editCursor > 0) {
editText = editText.slice(0, editCursor - 1) + editText.slice(editCursor);
editCursor--;
}
break;
case "left":
if (editCursor > 0) editCursor--;
break;
case "right":
if (editCursor < editText.length) editCursor++;
break;
case "home":
editCursor = 0;
break;
case "end":
editCursor = editText.length;
break;
default:
if (!ctrl && !alt && name.length === 1) {
editText = editText.slice(0, editCursor) + name + editText.slice(editCursor);
editCursor++;
}
break;
}
render();
return;
}
if (addMode) {
switch (name) {
case "enter":
if (editText.trim().length > 0) {
todos.push({
text: editText.trim(),
done: false,
priority: "none",
category: filterCategory === "all" ? "other" : filterCategory,
createdAt: new Date(),
});
selectedIndex = getFilteredTodos().length - 1;
}
addMode = false;
editText = "";
break;
case "escape":
addMode = false;
editText = "";
break;
case "backspace":
if (editCursor > 0) {
editText = editText.slice(0, editCursor - 1) + editText.slice(editCursor);
editCursor--;
}
break;
case "left":
if (editCursor > 0) editCursor--;
break;
case "right":
if (editCursor < editText.length) editCursor++;
break;
default:
if (!ctrl && !alt && name.length === 1) {
editText = editText.slice(0, editCursor) + name + editText.slice(editCursor);
editCursor++;
}
break;
}
render();
return;
}
// Normal mode
if (name === "q") {
cleanup();
return;
}
switch (name) {
case "up":
case "k":
if (selectedIndex > 0) selectedIndex--;
break;
case "down":
case "j":
if (selectedIndex < filtered.length - 1) selectedIndex++;
break;
case " ":
if (filtered[selectedIndex]) {
filtered[selectedIndex].done = !filtered[selectedIndex].done;
}
break;
case "a":
addMode = true;
editText = "";
editCursor = 0;
break;
case "e":
if (filtered[selectedIndex]) {
editMode = true;
editText = filtered[selectedIndex].text;
editCursor = editText.length;
}
break;
case "d":
if (filtered[selectedIndex]) {
const realIdx = todos.indexOf(filtered[selectedIndex]);
if (realIdx >= 0) todos.splice(realIdx, 1);
if (selectedIndex >= getFilteredTodos().length) {
selectedIndex = Math.max(0, getFilteredTodos().length - 1);
}
}
break;
case "p":
if (filtered[selectedIndex]) {
filtered[selectedIndex].priority = cyclePriority(filtered[selectedIndex].priority);
}
break;
case "tab": {
const ci = categories.indexOf(filterCategory);
filterCategory = categories[(ci + 1) % categories.length];
selectedIndex = 0;
scrollOffset = 0;
break;
}
case "home":
selectedIndex = 0;
break;
case "end":
selectedIndex = Math.max(0, filtered.length - 1);
break;
}
render();
};
// --- Paste ---
reader.onpaste = (text: string) => {
if (editMode || addMode) {
const firstLine = text.split("\n")[0];
editText = editText.slice(0, editCursor) + firstLine + editText.slice(editCursor);
editCursor += firstLine.length;
render();
}
};
// --- Resize ---
writer.onresize = (newCols: number, newRows: number) => {
cols = newCols;
rows = newRows;
screen.resize(cols, rows);
render();
};
// --- Cleanup ---
let cleanedUp = false;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
reader.close();
writer.exitAltScreen();
writer.close();
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
// --- Start ---
render();

View File

@@ -0,0 +1,130 @@
/**
* demo-tree.ts — Renders a directory tree structure with icons.
* Uses box-drawing characters with file-type coloring.
*/
const writer = new Bun.TUITerminalWriter(Bun.stdout);
const width = writer.columns || 80;
interface TreeEntry {
name: string;
type: "dir" | "ts" | "json" | "md" | "zig" | "file" | "lock";
children?: TreeEntry[];
}
const tree: TreeEntry = {
name: "my-project/",
type: "dir",
children: [
{
name: "src/",
type: "dir",
children: [
{ name: "index.ts", type: "ts" },
{ name: "server.ts", type: "ts" },
{
name: "utils/",
type: "dir",
children: [
{ name: "helpers.ts", type: "ts" },
{ name: "constants.ts", type: "ts" },
],
},
{
name: "routes/",
type: "dir",
children: [
{ name: "api.ts", type: "ts" },
{ name: "auth.ts", type: "ts" },
],
},
],
},
{
name: "test/",
type: "dir",
children: [
{ name: "server.test.ts", type: "ts" },
{ name: "helpers.test.ts", type: "ts" },
],
},
{
name: "lib/",
type: "dir",
children: [{ name: "native.zig", type: "zig" }],
},
{ name: "package.json", type: "json" },
{ name: "tsconfig.json", type: "json" },
{ name: "bun.lock", type: "lock" },
{ name: "README.md", type: "md" },
],
};
// Collect all lines
const lines: { indent: string; name: string; type: string }[] = [];
function walk(node: TreeEntry, prefix: string, isLast: boolean, isRoot: boolean) {
const connector = isRoot ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
lines.push({ indent: prefix + connector, name: node.name, type: node.type });
if (node.children) {
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "\u2502 ");
node.children.forEach((child, i) => {
walk(child, childPrefix, i === node.children!.length - 1, false);
});
}
}
walk(tree, "", true, true);
const height = lines.length + 3;
const screen = new Bun.TUIScreen(width, height);
// Styles
const titleStyle = screen.style({ fg: 0xffffff, bold: true });
const treeChars = screen.style({ fg: 0x555555 });
const dirStyle = screen.style({ fg: 0x61afef, bold: true });
const tsStyle = screen.style({ fg: 0x98c379 });
const jsonStyle = screen.style({ fg: 0xe5c07b });
const mdStyle = screen.style({ fg: 0xc678dd });
const zigStyle = screen.style({ fg: 0xf0a030 });
const fileStyle = screen.style({ fg: 0xabb2bf });
const typeIcons: Record<string, string> = {
dir: "\uD83D\uDCC1 ",
ts: "\uD83D\uDCC4 ",
json: "\u2699\uFE0F ",
md: "\uD83D\uDCD6 ",
zig: "\u26A1 ",
file: "\uD83D\uDCC4 ",
lock: "\uD83D\uDD12 ",
};
const typeStyles: Record<string, number> = {
dir: dirStyle,
ts: tsStyle,
json: jsonStyle,
md: mdStyle,
zig: zigStyle,
file: fileStyle,
lock: fileStyle,
};
// Title
screen.setText(1, 0, "Project Structure", titleStyle);
const sep = "\u2500".repeat(Math.min(40, width - 2));
screen.setText(1, 1, sep, screen.style({ fg: 0x444444 }));
// Render tree lines
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const y = i + 2;
let x = 1;
// Draw tree connector chars
x += screen.setText(x, y, line.indent, treeChars);
// Draw name with type coloring
screen.setText(x, y, line.name, typeStyles[line.type] ?? fileStyle);
}
writer.render(screen);
writer.clear();
writer.write("\r\n");
writer.close();

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

View File

@@ -0,0 +1,77 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("--bail writes JUnit reporter outfile", async () => {
using dir = tempDir("bail-junit", {
"fail.test.ts": `
import { test, expect } from "bun:test";
test("failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`, "fail.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
expect(xml).toContain("failing test");
});
test("--bail writes JUnit reporter outfile with multiple files", async () => {
using dir = tempDir("bail-junit-multi", {
"a_pass.test.ts": `
import { test, expect } from "bun:test";
test("passing test", () => { expect(1).toBe(1); });
`,
"b_fail.test.ts": `
import { test, expect } from "bun:test";
test("another failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
// Both the passing and failing tests should be recorded
expect(xml).toContain("passing test");
expect(xml).toContain("another failing test");
});