Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/core/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import * as liveStateExample from "./live-state-demo"
import * as fullUnicodeExample from "./full-unicode-demo"
import * as textNodeDemo from "./text-node-demo"
import { getKeyHandler } from "../lib/KeyHandler"
import { setRenderLibPath } from "../zig"
import fs from "node:fs"
import { setupCommonDemoKeys } from "./lib/standalone-keys"

interface Example {
Expand Down Expand Up @@ -508,6 +510,17 @@ class ExampleSelector {
}
}

// Prefer local freshly-built native lib during development
try {
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch
const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux"
const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so"
const url = new URL(`../zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url)
if (fs.existsSync(url.pathname)) {
setRenderLibPath(url.pathname)
}
} catch {}

const renderer = await createCliRenderer({
exitOnCtrlC: false,
targetFps: 60,
Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/examples/simple-terminal-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bun

/**
* Simple Interactive Terminal Demo
*
* A minimal terminal emulator using libvterm that provides a fully interactive shell.
* You can type commands and interact with it like a normal terminal.
*/

import { createCliRenderer, BoxRenderable } from "../index"
import { TerminalRenderer } from "../renderables/TerminalRenderer"
import { setRenderLibPath } from "../zig"
import { parseKeypress } from "../lib/parse.keypress"

async function main() {
try {
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : process.arch
const os = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux"
const ext = process.platform === "darwin" ? "dylib" : process.platform === "win32" ? "dll" : "so"
const libPath = new URL(`../zig/lib/${arch}-${os}/libopentui.${ext}`, import.meta.url).pathname
setRenderLibPath(libPath)
} catch (e) {
console.warn("⚠️ Failed to load local libopentui library", e)
}

const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
})

renderer.useMouse = true
renderer.setBackgroundColor("#001122")

renderer.console.show()

const container = new BoxRenderable(renderer, {
position: "absolute",
left: 2,
top: 1,
width: renderer.width - 4,
height: renderer.height - 2,
border: true,
borderStyle: "single",
borderColor: "#00AAFF",
title: "Terminal (libvterm)",
backgroundColor: "#000000",
})

const terminalCols = Math.max(10, container.width - 2)
const terminalRows = Math.max(5, container.height - 2)

const terminal = new TerminalRenderer(renderer, {
width: container.width - 2, // Account for container border
height: container.height - 2, // Account for container border
cols: terminalCols,
rows: terminalRows,
shell: "bash",
backgroundColor: "#000000",
autoFocus: true,
})

container.add(terminal)
renderer.root.add(container)

renderer.on("resize", () => {
container.width = renderer.width - 4
container.height = renderer.height - 2
terminal.width = container.width - 2
terminal.height = container.height - 2
terminal.cols = Math.max(10, container.width - 2)
terminal.rows = Math.max(5, container.height - 2)
})

renderer.on("key", (data: Buffer) => {
const key = parseKeypress(data)

if (key.raw === "\u0003") return

terminal.handleKeyPress(key)
})

renderer.focusRenderable(terminal)

renderer.start()

console.log("🚀 Simple Terminal Demo Started!")
console.log("💡 Type commands normally. Press Ctrl+C to exit.")
console.log(`📊 Terminal: ${terminalCols}x${terminalRows} characters`)
console.log(`🔧 Backend: ${terminal.hasLibvtermSupport ? "libvterm" : "basic PTY"}`)
console.log("📋 Copy/Paste:")
console.log(" - Copy: Ctrl+Shift+C")
console.log(" - Paste: Ctrl+Shift+V")
}

if (import.meta.main) {
main().catch(console.error)
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./animation/Timeline"
export * from "./lib"
export * from "./renderer"
export * from "./renderables"
export * from "./renderables/TerminalRenderer"
export * from "./zig"
export * from "./console"
export * as Yoga from "yoga-layout"
71 changes: 71 additions & 0 deletions packages/core/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const encoder = new TextEncoder()
const decoder = new TextDecoder()

type Command = string[]

enum Platform {
Mac = "darwin",
Windows = "win32",
}

function runCommand(cmd: Command, input?: string): { success: boolean; output?: string } {
try {
const result = Bun.spawnSync({
cmd,
stdin: input ? encoder.encode(input) : undefined,
stdout: "pipe",
stderr: "inherit",
})

if (result.success) {
const out = result.stdout ? decoder.decode(result.stdout) : ""
return { success: true, output: out }
}
} catch (error) {
// Command not available; ignore
}

return { success: false }
}

function platformCopyCommands(): Command[] {
switch (process.platform) {
case Platform.Mac:
return [["pbcopy"]]
case Platform.Windows:
return [["cmd", "/c", "clip"]]
default:
return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
}
}

function platformPasteCommands(): Command[] {
switch (process.platform) {
case Platform.Mac:
return [["pbpaste"]]
case Platform.Windows:
return [["powershell", "-NoProfile", "-Command", "Get-Clipboard"]]
default:
return [["wl-paste"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]]
}
}

export function copyTextToClipboard(text: string): boolean {
for (const cmd of platformCopyCommands()) {
const result = runCommand(cmd, text)
if (result.success) {
return true
}
}
return false
}

export function pasteTextFromClipboard(): string {
for (const cmd of platformPasteCommands()) {
const result = runCommand(cmd)
if (result.success && typeof result.output === "string") {
return result.output.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
}
return ""
}
87 changes: 87 additions & 0 deletions packages/core/src/pty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { resolveRenderLib, type RenderLib } from "./zig"
import type { Pointer } from "bun:ffi"
import { OptimizedBuffer } from "./buffer"

export class NativePtySession {
private lib: RenderLib
private ptr: Pointer | null
public cols: number
public rows: number
private destroyed: boolean = false

constructor(ptr: Pointer, cols: number, rows: number, lib: RenderLib) {
this.ptr = ptr
this.cols = cols
this.rows = rows
this.lib = lib
}

static create(cols: number, rows: number): NativePtySession {
const lib = resolveRenderLib()
const sess = lib.terminalSessionCreate(cols, rows)
if (!sess) throw new Error(`Failed to create native PTY session (cols=${cols}, rows=${rows})`)
return new NativePtySession(sess, cols, rows, lib)
}

write(data: string | Uint8Array): number {
if (this.destroyed || !this.ptr) {
console.warn("Attempted to write to destroyed PTY session")
return 0
}
try {
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data
return this.lib.terminalSessionWrite(this.ptr, bytes)
} catch (e) {
console.error("PTY write error:", e)
return 0
}
}

resize(cols: number, rows: number): void {
if (this.destroyed || !this.ptr) {
console.warn("Attempted to resize destroyed PTY session")
return
}
this.cols = cols
this.rows = rows
try {
this.lib.terminalSessionResize(this.ptr, cols, rows)
} catch (e) {
console.error("PTY resize error:", e)
}
}

tick(): number {
if (this.destroyed || !this.ptr) return 0
try {
return this.lib.terminalSessionTick(this.ptr)
} catch (e) {
console.error("PTY tick error:", e)
return 0
}
}

render(target: OptimizedBuffer, x: number, y: number): void {
if (this.destroyed || !this.ptr) return
try {
this.lib.terminalSessionRender(this.ptr, target.ptr, x, y)
} catch (e) {
console.error("PTY render error:", e)
}
}

destroy(): void {
if (this.destroyed || !this.ptr) return
this.destroyed = true
try {
this.lib.terminalSessionDestroy(this.ptr)
} catch (e) {
console.error("PTY destroy error:", e)
}
this.ptr = null
}
}

export function createPtySession(cols: number, rows: number): NativePtySession {
return NativePtySession.create(cols, rows)
}
Loading