From f3ae7d0d2c9482a36828480e7e470225af1b6012 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 23 Dec 2025 22:41:27 +0100 Subject: [PATCH 1/2] feat: add VTerm terminal rendering functions and renderables - Add vterm.zig with ghostty-vt integration for terminal emulation - Add VTerm FFI functions to lib.zig and zig.ts - Add StatelessTerminalRenderable and TerminalRenderable classes - Fix renderer.zig to skip stdout writes in testing mode (fixes hanging tests) - Fix circular dependency by moving VTerm types to lib/vterm-ffi.ts --- .../src/examples/terminal-interactive-demo.ts | 253 +++++++++ .../core/src/examples/terminal-simple-demo.ts | 162 ++++++ packages/core/src/lib/index.ts | 1 + packages/core/src/lib/vterm-ffi.ts | 80 +++ packages/core/src/renderables/Terminal.ts | 273 +++++++++ packages/core/src/renderables/index.ts | 1 + packages/core/src/zig.ts | 261 +++++++++ packages/core/src/zig/lib.zig | 68 ++- packages/core/src/zig/renderer.zig | 14 +- packages/core/src/zig/vterm.zig | 532 ++++++++++++++++++ 10 files changed, 1637 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/examples/terminal-interactive-demo.ts create mode 100644 packages/core/src/examples/terminal-simple-demo.ts create mode 100644 packages/core/src/lib/vterm-ffi.ts create mode 100644 packages/core/src/renderables/Terminal.ts create mode 100644 packages/core/src/zig/vterm.zig diff --git a/packages/core/src/examples/terminal-interactive-demo.ts b/packages/core/src/examples/terminal-interactive-demo.ts new file mode 100644 index 000000000..01095f418 --- /dev/null +++ b/packages/core/src/examples/terminal-interactive-demo.ts @@ -0,0 +1,253 @@ +import { + createCliRenderer, + TerminalRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, +} from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +interface Button { + label: string + data: string +} + +const BUTTONS: Button[] = [ + { label: "[1] Send 'hello'", data: "hello" }, + { label: "[2] Send Enter", data: "\r" }, + { label: "[3] Send 'help'", data: "help" }, + { label: "[4] Send Escape", data: "\x1b" }, + { label: "[5] Send Ctrl+C", data: "\x03" }, + { label: "[6] Send '/clear'", data: "/clear" }, +] + +const LEFT_PANEL_WIDTH = 33 +const RIGHT_PANEL_BORDER = 2 +const VERTICAL_OVERHEAD = 3 + +let renderer: CliRenderer | null = null +let pty: any = null +let terminalDisplay: TerminalRenderable | null = null +let statusDisplay: TextRenderable | null = null +let selectedButton = 0 +let status = "Starting..." +let terminalCols = 80 +let terminalRows = 24 + +async function initPty(cols: number, rows: number): Promise { + try { + const { spawn } = await import("bun-pty") + return spawn("opencode", [], { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + }) + } catch (e) { + console.error("Failed to import bun-pty. Make sure it's installed: bun add bun-pty") + throw e + } +} + +function sendData(data: string): void { + if (pty) { + pty.write(data) + if (data === "\r") { + status = "Sent: Enter" + } else if (data === "\x1b") { + status = "Sent: Escape" + } else if (data === "\x03") { + status = "Sent: Ctrl+C" + } else { + status = `Sent: "${data}"` + } + updateStatus() + } +} + +function updateStatus(): void { + if (statusDisplay) { + statusDisplay.content = `Status: ${status} | Size: ${terminalCols}x${terminalRows}` + } +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + if (pty) { + pty.kill() + } + process.exit(0) + } + + if (key.name === "1") sendData(BUTTONS[0].data) + if (key.name === "2") sendData(BUTTONS[1].data) + if (key.name === "3") sendData(BUTTONS[2].data) + if (key.name === "4") sendData(BUTTONS[3].data) + if (key.name === "5") sendData(BUTTONS[4].data) + if (key.name === "6") sendData(BUTTONS[5].data) + + if (key.name === "up") { + selectedButton = selectedButton > 0 ? selectedButton - 1 : BUTTONS.length - 1 + renderer?.requestRender() + } + if (key.name === "down") { + selectedButton = selectedButton < BUTTONS.length - 1 ? selectedButton + 1 : 0 + renderer?.requestRender() + } + if (key.name === "return") { + sendData(BUTTONS[selectedButton].data) + } +} + +export async function run(rendererInstance: CliRenderer): Promise { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const width = renderer.width + const height = renderer.height + terminalCols = Math.max(40, width - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, height - VERTICAL_OVERHEAD) + + const mainContainer = new BoxRenderable(renderer, { + id: "main-container", + flexDirection: "row", + flexGrow: 1, + }) + renderer.root.add(mainContainer) + + const leftPanel = new BoxRenderable(renderer, { + id: "left-panel", + width: 30, + flexDirection: "column", + padding: 1, + }) + mainContainer.add(leftPanel) + + const commandsTitle = new TextRenderable(renderer, { + id: "commands-title", + content: "Commands", + fg: "#58a6ff", + marginBottom: 1, + }) + leftPanel.add(commandsTitle) + + for (let i = 0; i < BUTTONS.length; i++) { + const btn = BUTTONS[i] + const isSelected = i === selectedButton + const buttonText = new TextRenderable(renderer, { + id: `button-${i}`, + content: btn.label, + fg: isSelected ? "#000" : "#d4d4d4", + bg: isSelected ? "#58a6ff" : undefined, + }) + leftPanel.add(buttonText) + } + + const helpText = new TextRenderable(renderer, { + id: "help-text", + content: "Use arrow keys + Enter\nor number keys 1-6\n\nPress 'q' to quit", + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(helpText) + + statusDisplay = new TextRenderable(renderer, { + id: "status-text", + content: `Status: ${status}`, + fg: "#8b949e", + marginTop: 2, + }) + leftPanel.add(statusDisplay) + + const rightPanel = new BoxRenderable(renderer, { + id: "right-panel", + flexGrow: 1, + flexDirection: "column", + marginLeft: 1, + }) + mainContainer.add(rightPanel) + + const terminalTitle = new TextRenderable(renderer, { + id: "terminal-title", + content: "Terminal Output", + fg: "#58a6ff", + height: 1, + paddingLeft: 1, + bg: "#333", + }) + rightPanel.add(terminalTitle) + + terminalDisplay = new TerminalRenderable(renderer, { + id: "terminal-display", + cols: terminalCols, + rows: terminalRows, + trimEnd: true, + flexGrow: 1, + }) + rightPanel.add(terminalDisplay) + + try { + pty = await initPty(terminalCols, terminalRows) + + pty.onData((data: string) => { + terminalDisplay?.feed(data) + }) + + pty.onExit(({ exitCode }: { exitCode: number }) => { + status = `Process exited with code ${exitCode}` + updateStatus() + }) + + status = "Running opencode" + updateStatus() + } catch (e) { + status = "Failed to start PTY" + updateStatus() + } + + renderer.on("resize", (newWidth: number, newHeight: number) => { + terminalCols = Math.max(40, newWidth - LEFT_PANEL_WIDTH - RIGHT_PANEL_BORDER) + terminalRows = Math.max(10, newHeight - VERTICAL_OVERHEAD) + pty?.resize(terminalCols, terminalRows) + if (terminalDisplay) { + terminalDisplay.cols = terminalCols + terminalDisplay.rows = terminalRows + } + updateStatus() + }) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (pty) { + pty.kill() + pty = null + } + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("main-container") + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + }) + + await run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/examples/terminal-simple-demo.ts b/packages/core/src/examples/terminal-simple-demo.ts new file mode 100644 index 000000000..e00de6ebe --- /dev/null +++ b/packages/core/src/examples/terminal-simple-demo.ts @@ -0,0 +1,162 @@ +import { + createCliRenderer, + StatelessTerminalRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, + ScrollBoxRenderable, +} from "../index" +import { TextRenderable } from "../renderables/Text" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let renderer: CliRenderer | null = null +let terminalDisplay: StatelessTerminalRenderable | null = null +let scrollBox: ScrollBoxRenderable | null = null +let statusDisplay: TextRenderable | null = null + +const SAMPLE_ANSI = `\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ ls -la +total 128 +drwxr-xr-x 12 user user 4096 Nov 26 10:30 \x1b[1;34m.\x1b[0m +drwxr-xr-x 5 user user 4096 Nov 25 14:22 \x1b[1;34m..\x1b[0m +-rw-r--r-- 1 user user 234 Nov 26 10:30 .gitignore +drwxr-xr-x 8 user user 4096 Nov 26 10:28 \x1b[1;34m.git\x1b[0m +-rw-r--r-- 1 user user 1842 Nov 26 09:15 package.json + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ git status +On branch \x1b[1;36mmain\x1b[0m +Changes to be committed: + \x1b[32mmodified: src/index.ts\x1b[0m + \x1b[32mnew file: src/utils.ts\x1b[0m + +Changes not staged for commit: + \x1b[31mmodified: package.json\x1b[0m + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ npm run build +\x1b[1;33m[WARN]\x1b[0m Deprecation warning: 'fs.exists' is deprecated +\x1b[1;36m[INFO]\x1b[0m Compiling TypeScript files... +\x1b[1;32m[SUCCESS]\x1b[0m Build completed in 2.34s + +\x1b[1;32muser@hostname\x1b[0m:\x1b[1;34m~/projects/my-app\x1b[0m$ echo "Style showcase:" +Style showcase: + +\x1b[1mBold text\x1b[0m +\x1b[2mFaint/dim text\x1b[0m +\x1b[3mItalic text\x1b[0m +\x1b[4mUnderlined text\x1b[0m +\x1b[7mInverse/reverse text\x1b[0m +\x1b[9mStrikethrough text\x1b[0m + +\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[33mYellow\x1b[0m \x1b[34mBlue\x1b[0m \x1b[35mMagenta\x1b[0m \x1b[36mCyan\x1b[0m +\x1b[38;5;208mOrange (256 color)\x1b[0m +\x1b[38;2;255;105;180mHot Pink (RGB)\x1b[0m +` + +let currentAnsi = SAMPLE_ANSI +let prefixCount = 0 + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#0d1117") + + const container = new BoxRenderable(renderer, { + id: "container", + flexDirection: "column", + flexGrow: 1, + }) + renderer.root.add(container) + + statusDisplay = new TextRenderable(renderer, { + id: "status", + content: "Press 'p' to add prefix | 't' scroll top | 'b' scroll bottom | 'q' to quit", + height: 1, + fg: "#8b949e", + padding: 1, + }) + container.add(statusDisplay) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "scroll-box", + flexGrow: 1, + padding: 1, + }) + container.add(scrollBox) + + terminalDisplay = new StatelessTerminalRenderable(renderer, { + id: "terminal", + ansi: currentAnsi, + cols: 120, + rows: 100, + trimEnd: true, + }) + scrollBox.add(terminalDisplay) + + rendererInstance.keyInput.on("keypress", handleKey) +} + +function handleKey(key: KeyEvent): void { + if (key.name === "q" || key.name === "escape") { + process.exit(0) + } + + if (key.name === "p" && terminalDisplay) { + prefixCount++ + const prefix = `\x1b[1;35m[PREFIX ${prefixCount}]\x1b[0m\n` + currentAnsi = prefix + currentAnsi + terminalDisplay.ansi = currentAnsi + updateStatus() + } + + if (key.name === "t" && scrollBox) { + scrollBox.scrollTo(0) + } + + if (key.name === "b" && scrollBox && terminalDisplay) { + const lastLine = terminalDisplay.lineCount - 1 + const scrollPos = terminalDisplay.getScrollPositionForLine(lastLine) + scrollBox.scrollTo(scrollPos) + } +} + +function updateStatus(): void { + if (statusDisplay && terminalDisplay) { + statusDisplay.content = `Press 'p' to add prefix | 't' top | 'b' bottom | 'q' quit | Prefixes: ${prefixCount} | Lines: ${terminalDisplay.lineCount}` + } +} + +export function destroy(rendererInstance: CliRenderer): void { + rendererInstance.keyInput.off("keypress", handleKey) + + if (terminalDisplay) { + terminalDisplay.destroy() + terminalDisplay = null + } + + if (scrollBox) { + scrollBox.destroy() + scrollBox = null + } + + if (statusDisplay) { + statusDisplay.destroy() + statusDisplay = null + } + + rendererInstance.root.remove("container") + renderer = null +} + +if (import.meta.main) { + const inputFile = process.argv[2] + if (inputFile) { + const fs = await import("fs") + currentAnsi = fs.readFileSync(inputFile, "utf-8") + } + + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 71496bbcb..80527ba0b 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -16,3 +16,4 @@ export * from "./tree-sitter" export * from "./data-paths" export * from "./extmarks" export * from "./terminal-palette" +export * from "./vterm-ffi" diff --git a/packages/core/src/lib/vterm-ffi.ts b/packages/core/src/lib/vterm-ffi.ts new file mode 100644 index 000000000..d5ce1341f --- /dev/null +++ b/packages/core/src/lib/vterm-ffi.ts @@ -0,0 +1,80 @@ +import { StyledText } from "./styled-text" +import { RGBA } from "./RGBA" +import type { TextChunk } from "../text-buffer" +import { TextAttributes } from "../types" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +export const VTermStyleFlags = { + BOLD: 1, + ITALIC: 2, + UNDERLINE: 4, + STRIKETHROUGH: 8, + INVERSE: 16, + FAINT: 32, +} as const + +export interface VTermSpan { + text: string + fg: string | null + bg: string | null + flags: number + width: number +} + +export interface VTermLine { + spans: VTermSpan[] +} + +export interface VTermData { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: VTermLine[] +} + +function convertSpanToChunk(span: VTermSpan): TextChunk { + const { text, fg, bg, flags } = span + + let fgColor = fg ? RGBA.fromHex(fg) : DEFAULT_FG + let bgColor = bg ? RGBA.fromHex(bg) : undefined + + if (flags & VTermStyleFlags.INVERSE) { + const temp = fgColor + fgColor = bgColor || DEFAULT_FG + bgColor = temp + } + + let attributes = 0 + if (flags & VTermStyleFlags.BOLD) attributes |= TextAttributes.BOLD + if (flags & VTermStyleFlags.ITALIC) attributes |= TextAttributes.ITALIC + if (flags & VTermStyleFlags.UNDERLINE) attributes |= TextAttributes.UNDERLINE + if (flags & VTermStyleFlags.STRIKETHROUGH) attributes |= TextAttributes.STRIKETHROUGH + if (flags & VTermStyleFlags.FAINT) attributes |= TextAttributes.DIM + + return { __isChunk: true, text, fg: fgColor, bg: bgColor, attributes } +} + +export function vtermDataToStyledText(data: VTermData): StyledText { + const chunks: TextChunk[] = [] + + for (let i = 0; i < data.lines.length; i++) { + const line = data.lines[i] + + if (line.spans.length === 0) { + chunks.push({ __isChunk: true, text: " ", attributes: 0 }) + } else { + for (const span of line.spans) { + chunks.push(convertSpanToChunk(span)) + } + } + + if (i < data.lines.length - 1) { + chunks.push({ __isChunk: true, text: "\n", attributes: 0 }) + } + } + + return new StyledText(chunks) +} diff --git a/packages/core/src/renderables/Terminal.ts b/packages/core/src/renderables/Terminal.ts new file mode 100644 index 000000000..783522cf7 --- /dev/null +++ b/packages/core/src/renderables/Terminal.ts @@ -0,0 +1,273 @@ +import { TextBufferRenderable, type TextBufferOptions } from "./TextBufferRenderable" +import { RGBA } from "../lib/RGBA" +import type { RenderContext } from "../types" +import type { OptimizedBuffer } from "../buffer" +import { resolveRenderLib, type RenderLib } from "../zig" +import { vtermDataToStyledText, type VTermData } from "../lib/vterm-ffi" + +// Re-export types from vterm-ffi for backwards compatibility +export { VTermStyleFlags, type VTermSpan, type VTermLine, type VTermData, vtermDataToStyledText } from "../lib/vterm-ffi" + +const DEFAULT_FG = RGBA.fromHex("#d4d4d4") + +function trimEmptyLines(data: VTermData): void { + while (data.lines.length > 0) { + const lastLine = data.lines[data.lines.length - 1] + const hasText = lastLine.spans.some((span) => span.text.trim().length > 0) + if (hasText) break + data.lines.pop() + } +} + +export interface StatelessTerminalOptions extends TextBufferOptions { + ansi?: string | Buffer + cols?: number + rows?: number + limit?: number + trimEnd?: boolean +} + +export class StatelessTerminalRenderable extends TextBufferRenderable { + private _ansi: string | Buffer + private _cols: number + private _rows: number + private _limit?: number + private _trimEnd?: boolean + private _needsUpdate: boolean = true + private _lineCount: number = 0 + private _lib: RenderLib + + constructor(ctx: RenderContext, options: StatelessTerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + this._ansi = options.ansi ?? "" + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._limit = options.limit + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + } + + get lineCount(): number { + return this._lineCount + } + + get ansi(): string | Buffer { + return this._ansi + } + + set ansi(value: string | Buffer) { + if (this._ansi !== value) { + this._ansi = value + this._needsUpdate = true + this.requestRender() + } + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._needsUpdate = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._needsUpdate = true + this.requestRender() + } + } + + get limit(): number | undefined { + return this._limit + } + + set limit(value: number | undefined) { + if (this._limit !== value) { + this._limit = value + this._needsUpdate = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._needsUpdate = true + this.requestRender() + } + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._needsUpdate) { + const data = this._lib.vtermPtyToJson(this._ansi, { + cols: this._cols, + rows: this._rows, + limit: this._limit, + }) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._needsUpdate = false + } + super.renderSelf(buffer) + } + + getScrollPositionForLine(lineNumber: number): number { + const clampedLine = Math.max(0, Math.min(lineNumber, this._lineCount - 1)) + const lineStarts = this.textBufferView.logicalLineInfo.lineStarts + const lineYOffset = lineStarts?.[clampedLine] ?? clampedLine + return this.y + lineYOffset + } +} + +export interface TerminalOptions extends TextBufferOptions { + ansi?: string | Buffer + cols?: number + rows?: number + trimEnd?: boolean +} + +let nextTerminalId = 1 + +export class TerminalRenderable extends TextBufferRenderable { + private _cols: number + private _rows: number + private _trimEnd?: boolean + private _contentDirty: boolean = true + private _lineCount: number = 0 + private _terminalId: number + private _lib: RenderLib + private _destroyed = false + + constructor(ctx: RenderContext, options: TerminalOptions) { + super(ctx, { ...options, fg: DEFAULT_FG, wrapMode: "none" }) + + this._cols = options.cols ?? 120 + this._rows = options.rows ?? 40 + this._trimEnd = options.trimEnd + this._lib = resolveRenderLib() + this._terminalId = nextTerminalId++ + + const success = this._lib.vtermCreateTerminal(this._terminalId, this._cols, this._rows) + if (!success) { + throw new Error("Failed to create terminal") + } + + const ansi = options.ansi + if (ansi && (typeof ansi === "string" ? ansi.length > 0 : ansi.length > 0)) { + this._lib.vtermFeedTerminal(this._terminalId, ansi) + } + } + + get lineCount(): number { + return this._lineCount + } + + get cols(): number { + return this._cols + } + + set cols(value: number) { + if (this._cols !== value) { + this._cols = value + this._lib.vtermResizeTerminal(this._terminalId, value, this._rows) + this._contentDirty = true + this.requestRender() + } + } + + get rows(): number { + return this._rows + } + + set rows(value: number) { + if (this._rows !== value) { + this._rows = value + this._lib.vtermResizeTerminal(this._terminalId, this._cols, value) + this._contentDirty = true + this.requestRender() + } + } + + get trimEnd(): boolean | undefined { + return this._trimEnd + } + + set trimEnd(value: boolean | undefined) { + if (this._trimEnd !== value) { + this._trimEnd = value + this._contentDirty = true + this.requestRender() + } + } + + feed(data: string | Buffer): void { + this._lib.vtermFeedTerminal(this._terminalId, data) + this._contentDirty = true + this.requestRender() + } + + reset(): void { + this._lib.vtermResetTerminal(this._terminalId) + this._contentDirty = true + this.requestRender() + } + + getCursor(): [number, number] { + return this._lib.vtermGetTerminalCursor(this._terminalId) + } + + getText(): string { + return this._lib.vtermGetTerminalText(this._terminalId) + } + + isReady(): boolean { + return this._lib.vtermIsTerminalReady(this._terminalId) + } + + destroy(): void { + if (!this._destroyed) { + this._destroyed = true + this._lib.vtermDestroyTerminal(this._terminalId) + } + super.destroy() + } + + protected renderSelf(buffer: OptimizedBuffer): void { + if (this._contentDirty && !this._destroyed) { + const data = this._lib.vtermGetTerminalJson(this._terminalId, {}) as VTermData + + if (this._trimEnd) trimEmptyLines(data) + + this.textBuffer.setStyledText(vtermDataToStyledText(data)) + this.updateTextInfo() + this._lineCount = this.textBufferView.logicalLineInfo.lineStarts.length + this._contentDirty = false + } + super.renderSelf(buffer) + } + + getScrollPositionForLine(lineNumber: number): number { + const clampedLine = Math.max(0, Math.min(lineNumber, this._lineCount - 1)) + const lineStarts = this.textBufferView.logicalLineInfo.lineStarts + const lineYOffset = lineStarts?.[clampedLine] ?? clampedLine + return this.y + lineYOffset + } +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2abdd9827..bf671919e 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -13,6 +13,7 @@ export * from "./ScrollBox" export * from "./Select" export * from "./Slider" export * from "./TabSelect" +export * from "./Terminal" export * from "./Text" export * from "./TextBufferRenderable" export * from "./TextNode" diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 8a2c801e2..de5a60f3a 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -929,6 +929,56 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32", "u32", "ptr", "ptr", "u8"], returns: "void", }, + + // VTerm functions + vtermFreeArena: { + args: [] as const, + returns: "void", + }, + vtermPtyToJson: { + args: ["ptr", "usize" as const, "u16", "u16", "usize" as const, "usize" as const, "ptr"] as const, + returns: "ptr", + }, + vtermPtyToText: { + args: ["ptr", "usize" as const, "u16", "u16", "ptr"] as const, + returns: "ptr", + }, + vtermCreateTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermDestroyTerminal: { + args: ["u32"] as const, + returns: "void", + }, + vtermFeedTerminal: { + args: ["u32", "ptr", "usize" as const] as const, + returns: "bool", + }, + vtermResizeTerminal: { + args: ["u32", "u32", "u32"] as const, + returns: "bool", + }, + vtermResetTerminal: { + args: ["u32"] as const, + returns: "bool", + }, + vtermGetTerminalJson: { + args: ["u32", "u32", "u32", "ptr"] as const, + returns: "ptr", + }, + vtermGetTerminalText: { + args: ["u32", "ptr"] as const, + returns: "ptr", + }, + vtermGetTerminalCursor: { + args: ["u32", "ptr"] as const, + returns: "ptr", + }, + vtermIsTerminalReady: { + args: ["u32"] as const, + returns: "i32", + }, }) if (env.OTUI_DEBUG_FFI || env.OTUI_TRACE_FFI) { @@ -1555,6 +1605,20 @@ export interface RenderLib { onceNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void offNativeEvent: (name: string, handler: (data: ArrayBuffer) => void) => void onAnyNativeEvent: (handler: (name: string, data: ArrayBuffer) => void) => void + + // VTerm functions + vtermFreeArena: () => void + vtermPtyToJson: (input: Buffer | Uint8Array | string, options?: { cols?: number; rows?: number; offset?: number; limit?: number }) => any + vtermPtyToText: (input: Buffer | Uint8Array | string, options?: { cols?: number; rows?: number }) => string + vtermCreateTerminal: (id: number, cols: number, rows: number) => boolean + vtermDestroyTerminal: (id: number) => void + vtermFeedTerminal: (id: number, data: Buffer | Uint8Array | string) => boolean + vtermResizeTerminal: (id: number, cols: number, rows: number) => boolean + vtermResetTerminal: (id: number) => boolean + vtermGetTerminalJson: (id: number, options?: { offset?: number; limit?: number }) => any + vtermGetTerminalText: (id: number) => string + vtermGetTerminalCursor: (id: number) => [number, number] + vtermIsTerminalReady: (id: number) => boolean } class FFIRenderLib implements RenderLib { @@ -3210,6 +3274,203 @@ class FFIRenderLib implements RenderLib { public onAnyNativeEvent(handler: (name: string, data: ArrayBuffer) => void): void { this._anyEventHandlers.push(handler) } + + // VTerm methods + public vtermFreeArena(): void { + this.opentui.symbols.vtermFreeArena() + } + + private readVTermStringFromPointer(resultPtr: Pointer | null, outLenBuffer: BigUint64Array): string { + if (!resultPtr) { + throw new Error("VTerm native function returned null") + } + + const outLen = Number(outLenBuffer[0]) + const buffer = toArrayBuffer(resultPtr, 0, outLen) + const str = this.decoder.decode(buffer) + + this.vtermFreeArena() + + return str + } + + public vtermPtyToJson( + input: Buffer | Uint8Array | string, + options: { cols?: number; rows?: number; offset?: number; limit?: number } = {}, + ): any { + const { cols = 120, rows = 40, offset = 0, limit = 0 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return { + cols, + rows, + cursor: [0, 0], + offset, + totalLines: 0, + lines: [], + } + } + + const inputBuffer = Buffer.from(inputStr) + const inputPtr = ptr(inputBuffer) + + const outLenBuffer = new BigUint64Array(1) + const outLenPtr = ptr(outLenBuffer) + + const resultPtr = this.opentui.symbols.vtermPtyToJson(inputPtr, inputBuffer.length, cols, rows, offset, limit, outLenPtr) + + const jsonStr = this.readVTermStringFromPointer(resultPtr, outLenBuffer) + + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermPtyToText(input: Buffer | Uint8Array | string, options: { cols?: number; rows?: number } = {}): string { + const { cols = 500, rows = 256 } = options + + const inputStr = typeof input === "string" ? input : input.toString("utf-8") + + if (inputStr.length === 0) { + return "" + } + + const inputBuffer = Buffer.from(inputStr) + const inputPtr = ptr(inputBuffer) + + const outLenBuffer = new BigUint64Array(1) + const outLenPtr = ptr(outLenBuffer) + + const resultPtr = this.opentui.symbols.vtermPtyToText(inputPtr, inputBuffer.length, cols, rows, outLenPtr) + + return this.readVTermStringFromPointer(resultPtr, outLenBuffer) + } + + public vtermCreateTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermCreateTerminal(id, cols, rows) + } + + public vtermDestroyTerminal(id: number): void { + this.opentui.symbols.vtermDestroyTerminal(id) + } + + public vtermFeedTerminal(id: number, data: Buffer | Uint8Array | string): boolean { + let str: string + if (typeof data === "string") { + str = data + } else if (Buffer.isBuffer(data)) { + str = data.toString("utf-8") + } else { + str = new TextDecoder("utf-8").decode(data) + } + + const buffer = Buffer.from(str) + return this.opentui.symbols.vtermFeedTerminal(id, ptr(buffer), buffer.length) + } + + public vtermResizeTerminal(id: number, cols: number, rows: number): boolean { + return this.opentui.symbols.vtermResizeTerminal(id, cols, rows) + } + + public vtermResetTerminal(id: number): boolean { + return this.opentui.symbols.vtermResetTerminal(id) + } + + public vtermGetTerminalJson(id: number, options: { offset?: number; limit?: number } = {}): any { + const { offset = 0, limit = 0 } = options + + const outLenBuffer = new BigUint64Array(1) + const outLenPtr = ptr(outLenBuffer) + + const resultPtr = this.opentui.symbols.vtermGetTerminalJson(id, offset, limit, outLenPtr) + if (!resultPtr) { + throw new Error("Failed to get terminal JSON - terminal may not exist") + } + + const jsonStr = this.readVTermStringFromPointer(resultPtr, outLenBuffer) + const raw = JSON.parse(jsonStr) as { + cols: number + rows: number + cursor: [number, number] + offset: number + totalLines: number + lines: Array> + } + + return { + cols: raw.cols, + rows: raw.rows, + cursor: raw.cursor, + offset: raw.offset, + totalLines: raw.totalLines, + lines: raw.lines.map((line) => ({ + spans: line.map(([text, fg, bg, flags, width]) => ({ + text, + fg, + bg, + flags, + width, + })), + })), + } + } + + public vtermGetTerminalText(id: number): string { + const outLenBuffer = new BigUint64Array(1) + const outLenPtr = ptr(outLenBuffer) + + const resultPtr = this.opentui.symbols.vtermGetTerminalText(id, outLenPtr) + if (!resultPtr) { + throw new Error("Failed to get terminal text - terminal may not exist") + } + + return this.readVTermStringFromPointer(resultPtr, outLenBuffer) + } + + public vtermGetTerminalCursor(id: number): [number, number] { + const outLenBuffer = new BigUint64Array(1) + const outLenPtr = ptr(outLenBuffer) + + const resultPtr = this.opentui.symbols.vtermGetTerminalCursor(id, outLenPtr) + if (!resultPtr) { + throw new Error("Failed to get terminal cursor - terminal may not exist") + } + + const jsonStr = this.readVTermStringFromPointer(resultPtr, outLenBuffer) + return JSON.parse(jsonStr) as [number, number] + } + + public vtermIsTerminalReady(id: number): boolean { + const result = this.opentui.symbols.vtermIsTerminalReady(id) + if (result === -1) { + throw new Error("Failed to check terminal ready state - terminal may not exist") + } + return result === 1 + } } let opentuiLibPath: string | undefined diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 561b8220d..d29d017c8 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -16,6 +16,7 @@ const logger = @import("logger.zig"); const event_bus = @import("event-bus.zig"); const utils = @import("utils.zig"); const ghostty = @import("ghostty-vt"); +const vterm = @import("vterm.zig"); pub const OptimizedBuffer = buffer.OptimizedBuffer; pub const CliRenderer = renderer.CliRenderer; @@ -1486,7 +1487,68 @@ export fn bufferDrawChar( bufferPtr.drawChar(char, x, y, rgbaFg, rgbaBg, attributes) catch {}; } -// Temp: ensures ghostty-vt gets bundled into lib to test build works -export fn ghosttyGetTerminalSize() usize { - return @sizeOf(ghostty.Terminal); +// ============================================================================= +// VTerm FFI Export Functions +// ============================================================================= + +export fn vtermFreeArena() void { + _ = arena.reset(.free_all); +} + +export fn vtermPtyToJson( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_len: *usize, +) ?[*]u8 { + return vterm.ptyToJson(globalArena, input_ptr, input_len, cols, rows, offset, limit, out_len); +} + +export fn vtermPtyToText( + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_len: *usize, +) ?[*]u8 { + return vterm.ptyToText(globalArena, input_ptr, input_len, cols, rows, out_len); +} + +export fn vtermCreateTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.createTerminal(id, cols, rows); +} + +export fn vtermDestroyTerminal(id: u32) void { + vterm.destroyTerminal(id); +} + +export fn vtermFeedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + return vterm.feedTerminal(id, data_ptr, data_len); +} + +export fn vtermResizeTerminal(id: u32, cols: u32, rows: u32) bool { + return vterm.resizeTerminal(id, cols, rows); +} + +export fn vtermResetTerminal(id: u32) bool { + return vterm.resetTerminal(id); +} + +export fn vtermGetTerminalJson(id: u32, offset: u32, limit: u32, out_len: *usize) ?[*]u8 { + return vterm.getTerminalJson(globalArena, id, offset, limit, out_len); +} + +export fn vtermGetTerminalText(id: u32, out_len: *usize) ?[*]u8 { + return vterm.getTerminalText(globalArena, id, out_len); +} + +export fn vtermGetTerminalCursor(id: u32, out_len: *usize) ?[*]u8 { + return vterm.getTerminalCursor(globalArena, id, out_len); +} + +export fn vtermIsTerminalReady(id: u32) i32 { + return vterm.isTerminalReady(id); } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index d4775dd6b..4228b272b 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -463,7 +463,8 @@ pub const CliRenderer = struct { const outputLen = self.currentOutputLen; const writeStart = std.time.microTimestamp(); - if (outputLen > 0) { + // Skip stdout writes in testing mode to avoid blocking + if (outputLen > 0 and !self.testing) { var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); const w = &stdoutWriter.interface; w.writeAll(outputData[0..outputLen]) catch {}; @@ -511,10 +512,13 @@ pub const CliRenderer = struct { self.renderMutex.unlock(); } else { const writeStart = std.time.microTimestamp(); - var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); - const w = &stdoutWriter.interface; - w.writeAll(outputBuffer[0..outputBufferLen]) catch {}; - w.flush() catch {}; + // Skip stdout writes in testing mode to avoid blocking + if (!self.testing) { + var stdoutWriter = std.fs.File.stdout().writer(&self.stdoutBuffer); + const w = &stdoutWriter.interface; + w.writeAll(outputBuffer[0..outputBufferLen]) catch {}; + w.flush() catch {}; + } self.renderStats.stdoutWriteTime = @as(f64, @floatFromInt(std.time.microTimestamp() - writeStart)); } diff --git a/packages/core/src/zig/vterm.zig b/packages/core/src/zig/vterm.zig new file mode 100644 index 000000000..7d184775b --- /dev/null +++ b/packages/core/src/zig/vterm.zig @@ -0,0 +1,532 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const color = ghostty_vt.color; +const pagepkg = ghostty_vt.page; +const formatter = ghostty_vt.formatter; +const Screen = ghostty_vt.Screen; + +pub const StyleFlags = packed struct(u8) { + bold: bool = false, + italic: bool = false, + underline: bool = false, + strikethrough: bool = false, + inverse: bool = false, + faint: bool = false, + _padding: u2 = 0, + + pub fn toInt(self: StyleFlags) u8 { + return @bitCast(self); + } + + pub fn eql(self: StyleFlags, other: StyleFlags) bool { + return self.toInt() == other.toInt(); + } +}; + +pub const CellStyle = struct { + fg: ?color.RGB, + bg: ?color.RGB, + flags: StyleFlags, + + pub fn eql(self: CellStyle, other: CellStyle) bool { + const fg_eq = if (self.fg) |a| (if (other.fg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.fg == null; + const bg_eq = if (self.bg) |a| (if (other.bg) |b| a.r == b.r and a.g == b.g and a.b == b.b else false) else other.bg == null; + return fg_eq and bg_eq and self.flags.eql(other.flags); + } +}; + +fn getStyleFromCell( + cell: *const pagepkg.Cell, + pin: ghostty_vt.Pin, + palette: *const color.Palette, + terminal_bg: ?color.RGB, +) CellStyle { + var flags: StyleFlags = .{}; + var fg: ?color.RGB = null; + var bg: ?color.RGB = null; + + const style = pin.style(cell); + + flags.bold = style.flags.bold; + flags.italic = style.flags.italic; + flags.faint = style.flags.faint; + flags.inverse = style.flags.inverse; + flags.strikethrough = style.flags.strikethrough; + flags.underline = style.flags.underline != .none; + + fg = switch (style.fg_color) { + .none => null, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; + + bg = style.bg(cell, palette) orelse switch (cell.content_tag) { + .bg_color_palette => palette[cell.content.color_palette], + .bg_color_rgb => .{ .r = cell.content.color_rgb.r, .g = cell.content.color_rgb.g, .b = cell.content.color_rgb.b }, + else => null, + }; + + if (bg) |cell_bg| { + if (terminal_bg) |term_bg| { + if (cell_bg.r == term_bg.r and cell_bg.g == term_bg.g and cell_bg.b == term_bg.b) { + bg = null; + } + } + } + + return .{ .fg = fg, .bg = bg, .flags = flags }; +} + +fn writeJsonString(writer: anytype, s: []const u8) !void { + try writer.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => { + if (c < 0x20) { + try writer.print("\\u{x:0>4}", .{c}); + } else { + try writer.writeByte(c); + } + }, + } + } + try writer.writeByte('"'); +} + +fn writeColor(writer: anytype, rgb: ?color.RGB) !void { + if (rgb) |c| { + try writer.print("\"#{x:0>2}{x:0>2}{x:0>2}\"", .{ c.r, c.g, c.b }); + } else { + try writer.writeAll("null"); + } +} + +fn countLines(screen: *Screen) usize { + var total: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + total += 1; + } + return total; +} + +fn hasEnoughLines(screen: *Screen, threshold: usize) bool { + var count: usize = 0; + var iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + while (iter.next()) |_| { + count += 1; + if (count >= threshold) return true; + } + return false; +} + +pub fn writeJsonOutput( + writer: anytype, + t: *ghostty_vt.Terminal, + offset: usize, + limit: ?usize, +) !void { + const screen = t.screens.active; + const palette = &t.colors.palette.current; + const terminal_bg = t.colors.background.get(); + + const total_lines = countLines(screen); + + try writer.writeAll("{"); + try writer.print("\"cols\":{},\"rows\":{},", .{ screen.pages.cols, screen.pages.rows }); + try writer.print("\"cursor\":[{},{}],", .{ screen.cursor.x, screen.cursor.y }); + try writer.print("\"offset\":{},\"totalLines\":{},", .{ offset, total_lines }); + try writer.writeAll("\"lines\":["); + + var text_buf: [4096]u8 = undefined; + var row_iter = screen.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row_idx: usize = 0; + var output_idx: usize = 0; + + while (row_iter.next()) |pin| { + if (row_idx < offset) { + row_idx += 1; + continue; + } + + if (limit) |lim| { + if (output_idx >= lim) break; + } + + if (output_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + + const cells = pin.cells(.all); + var span_start: usize = 0; + var span_len: usize = 0; + var current_style: ?CellStyle = null; + var text_len: usize = 0; + var span_idx: usize = 0; + + for (cells, 0..) |*cell, col_idx| { + if (cell.wide == .spacer_tail) continue; + + const cp = cell.codepoint(); + const is_null = cp == 0; + + if (is_null) { + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + current_style = null; + continue; + } + + const style = getStyleFromCell(cell, pin, palette, terminal_bg); + const style_changed = if (current_style) |cs| !cs.eql(style) else true; + + if (style_changed and text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + span_idx += 1; + text_len = 0; + span_len = 0; + } + + if (style_changed) { + span_start = col_idx; + current_style = style; + } + + const cp21: u21 = @intCast(cp); + const len = std.unicode.utf8CodepointSequenceLength(cp21) catch 1; + if (text_len + len <= text_buf.len) { + _ = std.unicode.utf8Encode(cp21, text_buf[text_len..]) catch 0; + text_len += len; + } + + span_len += if (cell.wide == .wide) 2 else 1; + } + + if (text_len > 0) { + if (span_idx > 0) try writer.writeByte(','); + try writer.writeByte('['); + try writeJsonString(writer, text_buf[0..text_len]); + try writer.writeByte(','); + try writeColor(writer, current_style.?.fg); + try writer.writeByte(','); + try writeColor(writer, current_style.?.bg); + try writer.print(",{},{}", .{ current_style.?.flags.toInt(), span_len }); + try writer.writeByte(']'); + } + + try writer.writeByte(']'); + row_idx += 1; + output_idx += 1; + } + + try writer.writeAll("]}"); +} + +const ReadonlyStream = @typeInfo(@TypeOf(ghostty_vt.Terminal.vtStream)).@"fn".return_type.?; + +pub const PersistentTerminal = struct { + terminal: ghostty_vt.Terminal, + allocator: std.mem.Allocator, + stream: ?ReadonlyStream, + + pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !PersistentTerminal { + var terminal = try ghostty_vt.Terminal.init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }); + + terminal.modes.set(.linefeed, true); + + return .{ + .terminal = terminal, + .allocator = alloc, + .stream = null, + }; + } + + pub fn initStream(self: *PersistentTerminal) void { + self.stream = self.terminal.vtStream(); + } + + pub fn deinit(self: *PersistentTerminal) void { + if (self.stream) |*s| { + s.deinit(); + } + self.terminal.deinit(self.allocator); + } + + pub fn feed(self: *PersistentTerminal, data: []const u8) !void { + try self.stream.?.nextSlice(data); + } + + pub fn isReady(self: *const PersistentTerminal) bool { + if (self.stream) |s| { + return s.parser.state == .ground; + } + return true; + } + + pub fn resize(self: *PersistentTerminal, cols: u16, rows: u16) !void { + try self.terminal.resize(self.allocator, cols, rows); + } + + pub fn reset(self: *PersistentTerminal) void { + self.terminal.fullReset(); + if (self.stream) |*s| { + s.deinit(); + } + self.stream = self.terminal.vtStream(); + } +}; + +var terminals_mutex: std.Thread.Mutex = .{}; +var terminals: ?std.AutoHashMap(u32, *PersistentTerminal) = null; + +fn getTerminalsMap() *std.AutoHashMap(u32, *PersistentTerminal) { + if (terminals == null) { + terminals = std.AutoHashMap(u32, *PersistentTerminal).init(std.heap.page_allocator); + } + return &terminals.?; +} + +pub fn ptyToJson( + globalArena: std.mem.Allocator, + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + offset: usize, + limit: usize, + out_len: *usize, +) ?[*]u8 { + const input = input_ptr[0..input_len]; + const lim: ?usize = if (limit == 0) null else limit; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(globalArena, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return null; + defer t.deinit(globalArena); + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + if (lim) |line_limit| { + const chunk_size: usize = 4096; + const threshold = line_limit + offset + 20; + var pos: usize = 0; + + while (pos < input.len) { + const end = @min(pos + chunk_size, input.len); + stream.nextSlice(input[pos..end]) catch return null; + pos = end; + + if (stream.parser.state == .ground) { + if (hasEnoughLines(t.screens.active, threshold)) { + break; + } + } + } + } else { + stream.nextSlice(input) catch return null; + } + + var output: std.ArrayListAligned(u8, null) = .empty; + writeJsonOutput(output.writer(globalArena), &t, offset, lim) catch return null; + + out_len.* = output.items.len; + return output.items.ptr; +} + +pub fn ptyToText( + globalArena: std.mem.Allocator, + input_ptr: [*]const u8, + input_len: usize, + cols: u16, + rows: u16, + out_len: *usize, +) ?[*]u8 { + const input = input_ptr[0..input_len]; + + var t: ghostty_vt.Terminal = ghostty_vt.Terminal.init(globalArena, .{ + .cols = cols, + .rows = rows, + .max_scrollback = std.math.maxInt(usize), + }) catch return null; + defer t.deinit(globalArena); + + t.modes.set(.linefeed, true); + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice(input) catch return null; + + var builder: std.Io.Writer.Allocating = .init(globalArena); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&t, .plain); + fmt.format(&builder.writer) catch return null; + + const output = builder.writer.buffered(); + out_len.* = output.len; + return @constCast(output.ptr); +} + +pub fn createTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + + if (map.get(id)) |existing| { + existing.deinit(); + std.heap.page_allocator.destroy(existing); + _ = map.remove(id); + } + + const term_ptr = std.heap.page_allocator.create(PersistentTerminal) catch return false; + + term_ptr.* = PersistentTerminal.init( + std.heap.page_allocator, + @intCast(cols), + @intCast(rows), + ) catch { + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + term_ptr.initStream(); + + map.put(id, term_ptr) catch { + term_ptr.deinit(); + std.heap.page_allocator.destroy(term_ptr); + return false; + }; + + return true; +} + +pub fn destroyTerminal(id: u32) void { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + if (map.get(id)) |term| { + term.deinit(); + std.heap.page_allocator.destroy(term); + _ = map.remove(id); + } +} + +pub fn feedTerminal(id: u32, data_ptr: [*]const u8, data_len: usize) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.feed(data_ptr[0..data_len]) catch return false; + return true; +} + +pub fn resizeTerminal(id: u32, cols: u32, rows: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.resize(@intCast(cols), @intCast(rows)) catch return false; + return true; +} + +pub fn resetTerminal(id: u32) bool { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return false; + term.reset(); + return true; +} + +pub fn getTerminalJson(globalArena: std.mem.Allocator, id: u32, offset: u32, limit: u32, out_len: *usize) ?[*]u8 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return null; + + const lim: ?usize = if (limit == 0) null else @intCast(limit); + + var output: std.ArrayListAligned(u8, null) = .empty; + writeJsonOutput(output.writer(globalArena), &term.terminal, @intCast(offset), lim) catch return null; + + out_len.* = output.items.len; + return output.items.ptr; +} + +pub fn getTerminalText(globalArena: std.mem.Allocator, id: u32, out_len: *usize) ?[*]u8 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return null; + + var builder: std.Io.Writer.Allocating = .init(globalArena); + var fmt: formatter.TerminalFormatter = formatter.TerminalFormatter.init(&term.terminal, .plain); + fmt.format(&builder.writer) catch return null; + + const output = builder.writer.buffered(); + out_len.* = output.len; + return @constCast(output.ptr); +} + +pub fn getTerminalCursor(globalArena: std.mem.Allocator, id: u32, out_len: *usize) ?[*]u8 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return null; + + const screen = term.terminal.screens.active; + + const output = std.fmt.allocPrint(globalArena, "[{},{}]", .{ screen.cursor.x, screen.cursor.y }) catch return null; + out_len.* = output.len; + return @constCast(output.ptr); +} + +pub fn isTerminalReady(id: u32) i32 { + terminals_mutex.lock(); + defer terminals_mutex.unlock(); + + const map = getTerminalsMap(); + const term = map.get(id) orelse return -1; + + return if (term.isReady()) 1 else 0; +} From d8cf9ef2ea08ff542aff338e2f5501ee308546d1 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 23 Dec 2025 23:27:02 +0100 Subject: [PATCH 2/2] feat: register Terminal renderables in solid, react, and vue --- packages/react/src/components/index.ts | 4 ++++ packages/react/src/types/components.ts | 8 ++++++++ packages/solid/src/elements/index.ts | 4 ++++ packages/vue/src/elements.ts | 4 ++++ 4 files changed, 20 insertions(+) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 26f47190d..f1ecf1554 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextRenderable, } from "@opentui/core" @@ -29,6 +31,8 @@ export const baseComponents = { select: SelectRenderable, textarea: TextareaRenderable, scrollbox: ScrollBoxRenderable, + terminal: TerminalRenderable, + "stateless-terminal": StatelessTerminalRenderable, "ascii-font": ASCIIFontRenderable, "tab-select": TabSelectRenderable, "line-number": LineNumberRenderable, diff --git a/packages/react/src/types/components.ts b/packages/react/src/types/components.ts index f5165a0c7..1890178d9 100644 --- a/packages/react/src/types/components.ts +++ b/packages/react/src/types/components.ts @@ -19,9 +19,13 @@ import type { SelectOption, SelectRenderable, SelectRenderableOptions, + StatelessTerminalOptions, + StatelessTerminalRenderable, TabSelectOption, TabSelectRenderable, TabSelectRenderableOptions, + TerminalOptions, + TerminalRenderable, TextareaOptions, TextareaRenderable, TextNodeOptions, @@ -140,6 +144,10 @@ export type CodeProps = ComponentProps export type DiffProps = ComponentProps +export type TerminalProps = ComponentProps + +export type StatelessTerminalProps = ComponentProps + export type SelectProps = ComponentProps & { focused?: boolean onChange?: (index: number, option: SelectOption | null) => void diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index 6d545f385..8e7abf303 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -7,7 +7,9 @@ import { LineNumberRenderable, ScrollBoxRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextareaRenderable, TextAttributes, TextNodeRenderable, @@ -88,6 +90,8 @@ export const baseComponents = { code: CodeRenderable, diff: DiffRenderable, line_number: LineNumberRenderable, + terminal: TerminalRenderable, + stateless_terminal: StatelessTerminalRenderable, span: SpanRenderable, strong: BoldSpanRenderable, diff --git a/packages/vue/src/elements.ts b/packages/vue/src/elements.ts index 2bdec94a3..505f9f574 100644 --- a/packages/vue/src/elements.ts +++ b/packages/vue/src/elements.ts @@ -3,7 +3,9 @@ import { BoxRenderable, InputRenderable, SelectRenderable, + StatelessTerminalRenderable, TabSelectRenderable, + TerminalRenderable, TextRenderable, ScrollBoxRenderable, } from "@opentui/core" @@ -14,6 +16,8 @@ export const elements = { inputRenderable: InputRenderable, selectRenderable: SelectRenderable, tabSelectRenderable: TabSelectRenderable, + terminalRenderable: TerminalRenderable, + statelessTerminalRenderable: StatelessTerminalRenderable, textRenderable: TextRenderable, scrollBoxRenderable: ScrollBoxRenderable, }