diff --git a/packages/vue/README.md b/packages/vue/README.md
index b1ffc81d7..d5ee95396 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -45,9 +45,9 @@ declare module "*.vue" {
-
+
Hello World
-
+
```
@@ -55,11 +55,10 @@ declare module "*.vue" {
```ts
// index.ts
-import { createApp } from "vue"
import { render } from "@opentui/vue"
import App from "./App.vue"
-render(createApp(App))
+render(App)
```
### 6. Create a build script build.ts.
@@ -104,9 +103,9 @@ bun run dist/index.js
## Note
-Important Note on
+Important Note on ``
-The component only accepts plain text as a direct child. For styled text or text chunks, you must use the content prop.
+The `` component only accepts plain text as a direct child. For styled text or text chunks, you must use the content prop.
```jsx
-
-
+
+
-This is plain text.
+ This is plain text.
```
@@ -156,9 +155,9 @@ useKeyboard((key: KeyEvent) => {
-
+
Last key pressed: {{ lastKey }}
-
+
```
@@ -188,9 +187,9 @@ const dimensions = useTerminalDimensions()
-
+
Width: {{ dimensions.width }}, Height: {{ dimensions.height }}
-
+
```
@@ -250,7 +249,7 @@ export class ConsoleButtonRenderable extends BoxRenderable {
#### 2. Register the new component
-In your application's entry point (e.g., `main.ts`), import the `extend` function and your custom component. Then, call `extend` with an object where the key is the component's tag name (in camelCase) and the value is the component class.
+In your application's entry point (e.g., `main.ts`), import the `extend` function and your custom component. Then, call `extend` with an object where the key is the component's tag name (in PascalCase) and the value is the component class.
`main.ts`:
@@ -260,7 +259,7 @@ import { ConsoleButtonRenderable } from "./CustomButtonRenderable"
import App from "./App.vue"
// Register the custom component
-extend({ consoleButtonRenderable: ConsoleButtonRenderable })
+extend({ ConsoleButton: ConsoleButtonRenderable })
// Render the app
render(App)
@@ -279,7 +278,7 @@ import { ConsoleButtonRenderable } from "./CustomButtonRenderable"
declare module "@opentui/vue" {
export interface OpenTUIComponents {
- consoleButtonRenderable: typeof ConsoleButtonRenderable
+ ConsoleButton: typeof ConsoleButtonRenderable
}
}
```
@@ -288,21 +287,21 @@ _Note: Make sure this file is included in your `tsconfig.json`._
#### 4. Use your custom component
-Now you can use `` in your Vue components just like any other OpenTUI component.
+Now you can use `` in your Vue components just like any other OpenTUI component.
`ExtendExample.vue`:
```vue
-
- Custom Button Example
-
+ Custom Button Example
+
-
+
diff --git a/packages/vue/example/ASCII.vue b/packages/vue/example/ASCII.vue
index 9be77dd30..853ec6fc2 100644
--- a/packages/vue/example/ASCII.vue
+++ b/packages/vue/example/ASCII.vue
@@ -1,5 +1,6 @@
-
-
-
+
+
-
-
-
+ >
+
+
+
diff --git a/packages/vue/example/Animation.vue b/packages/vue/example/Animation.vue
new file mode 100644
index 000000000..ffbdc11b5
--- /dev/null
+++ b/packages/vue/example/Animation.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+ {{ system.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ label }}: 0
+
+
+
+
+
+
+
+
+
+
+ Press ESC to return to menu
+
+
+
diff --git a/packages/vue/example/App.vue b/packages/vue/example/App.vue
index bd2fbf609..37143eaec 100644
--- a/packages/vue/example/App.vue
+++ b/packages/vue/example/App.vue
@@ -5,18 +5,28 @@ import Counter from "./Counter.vue"
import StyledText from "./Styled-Text.vue"
import TabSelect from "./TabSelect.vue"
import ScrollBox from "./ScrollBox.vue"
+import Code from "./Code.vue"
+import Diff from "./Diff.vue"
+import Textarea from "./Textarea.vue"
+import Animation from "./Animation.vue"
+import LineNumber from "./LineNumber.vue"
import { ref } from "vue"
import ExtendExample from "./ExtendExample.vue"
-import { useCliRenderer } from ".."
+import { useKeyboard } from "@opentui/vue"
const exampleOptions = [
- { name: "ASCII", description: "Assci text example", value: "ascii" },
+ { name: "ASCII", description: "ASCII text example", value: "ascii" },
{ name: "Counter", description: "Counter example", value: "counter" },
{ name: "Login Form", description: "A simple login form example", value: "login" },
{ name: "Styled Text", description: "Text with various styles applied", value: "styledText" },
{ name: "Tab Select", description: "Tabs", value: "tabSelect" },
{ name: "Extend", description: "Extend example", value: "extend" },
{ name: "ScrollBox", description: "ScrollBox example", value: "scrollBox" },
+ { name: "Code", description: "Syntax highlighting demo", value: "code" },
+ { name: "Diff", description: "Diff view demo", value: "diff" },
+ { name: "Textarea", description: "Interactive editor demo", value: "textarea" },
+ { name: "Animation", description: "Timeline animation demo", value: "animation" },
+ { name: "Line Number", description: "Line numbers demo", value: "lineNumber" },
]
type ExampleOption = (typeof exampleOptions)[number]
@@ -29,8 +39,7 @@ const onSelectExample = (i: number) => {
selectedExample.value = selectedOption
}
-const renderer = useCliRenderer()
-renderer.keyInput.on("keypress", (key) => {
+useKeyboard((key) => {
if (key.name === "escape") {
selectedExample.value = null
}
@@ -48,14 +57,19 @@ const selectStyles = { flexGrow: 1 }
-
-
+
+
+
+
+
+
+ >
+
diff --git a/packages/vue/example/Code.vue b/packages/vue/example/Code.vue
new file mode 100644
index 000000000..3886fe45a
--- /dev/null
+++ b/packages/vue/example/Code.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Code Syntax Highlighting Demo
+
+
+
+
+
+
+ Press ESC to return to menu
+
+
diff --git a/packages/vue/example/Counter.vue b/packages/vue/example/Counter.vue
index 19280f4da..17daf59b2 100644
--- a/packages/vue/example/Counter.vue
+++ b/packages/vue/example/Counter.vue
@@ -1,11 +1,11 @@
-
- Count : {{ count }}
- Press Up/Down to increment/decrement, R to reset
- Press + or = to increment, - to decrement
- Press R to reset
-
+
+ Count : {{ count }}
+ Press Up/Down to increment/decrement, R to reset
+ Press + or = to increment, - to decrement
+ Press R to reset
+
diff --git a/packages/vue/example/Diff.vue b/packages/vue/example/Diff.vue
new file mode 100644
index 000000000..e6b728d76
--- /dev/null
+++ b/packages/vue/example/Diff.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+ Diff Demo - Unified & Split View
+ Keybindings:
+ V - Toggle view ({{ currentView.toUpperCase() }})
+ L - Toggle line numbers ({{ showLineNumbers ? "ON" : "OFF" }})
+
+
+
+
+
+
+
diff --git a/packages/vue/example/ExtendExample.vue b/packages/vue/example/ExtendExample.vue
index 94c67f926..df971bbfb 100644
--- a/packages/vue/example/ExtendExample.vue
+++ b/packages/vue/example/ExtendExample.vue
@@ -1,13 +1,13 @@
-
- Custom Button Example
-
+ Custom Button Example
+
-
+
diff --git a/packages/vue/example/LineNumber.vue b/packages/vue/example/LineNumber.vue
new file mode 100644
index 000000000..63ae91a0f
--- /dev/null
+++ b/packages/vue/example/LineNumber.vue
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+ Line Number Demo
+
+
+
+
+ L: line numbers ({{ showLineNumbers ? "ON" : "OFF" }})
+ J/K: navigate
+ W: wrap ({{ wrapMode }})
+ Line {{ currentLine + 1 }}/{{ lineCount }}
+
+
+
+
+
+
+
+
+
+
+ Press ESC to return to menu
+
+
diff --git a/packages/vue/example/LoginForm.vue b/packages/vue/example/LoginForm.vue
index 0110a82bb..2953c4a48 100644
--- a/packages/vue/example/LoginForm.vue
+++ b/packages/vue/example/LoginForm.vue
@@ -1,18 +1,18 @@
-
-
-
-
+
+
+
-
-
-
+
+
-
-
+
+
diff --git a/packages/vue/example/ScrollBox.vue b/packages/vue/example/ScrollBox.vue
index 33c611dd3..6f68bc9b2 100644
--- a/packages/vue/example/ScrollBox.vue
+++ b/packages/vue/example/ScrollBox.vue
@@ -1,5 +1,5 @@
-
-
- Item {{ item }}
-
-
+
+ Item {{ item }}
+
+
-
-
- {{ plainText }}
+
+
+ {{ plainText }}
diff --git a/packages/vue/example/TabSelect.vue b/packages/vue/example/TabSelect.vue
index 7fea34c7a..3204884b5 100644
--- a/packages/vue/example/TabSelect.vue
+++ b/packages/vue/example/TabSelect.vue
@@ -22,8 +22,8 @@ const description = computed(
-
-
+
-
-
-
-
+
+
+
+
diff --git a/packages/vue/example/Textarea.vue b/packages/vue/example/Textarea.vue
new file mode 100644
index 000000000..1e8903302
--- /dev/null
+++ b/packages/vue/example/Textarea.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+ {{ statusText }}
+
+ Shift+W: wrap mode | Tab: cursor style | ESC: menu
+
+
diff --git a/packages/vue/example/main.ts b/packages/vue/example/main.ts
index 35ceec33c..f6b6c3310 100644
--- a/packages/vue/example/main.ts
+++ b/packages/vue/example/main.ts
@@ -3,6 +3,6 @@ import { extend } from "@opentui/vue"
import { ConsoleButtonRenderable } from "./CustomButtonRenderable"
import App from "./App.vue"
-extend({ consoleButtonRenderable: ConsoleButtonRenderable })
+extend({ ConsoleButton: ConsoleButtonRenderable })
render(App)
diff --git a/packages/vue/index.ts b/packages/vue/index.ts
index df3ad1f4b..119872423 100644
--- a/packages/vue/index.ts
+++ b/packages/vue/index.ts
@@ -1,15 +1,133 @@
-import { CliRenderer, createCliRenderer, type CliRendererConfig } from "@opentui/core"
+import {
+ CliRenderEvents,
+ CliRenderer,
+ createCliRenderer,
+ engine,
+ type BaseRenderable,
+ type CliRendererConfig,
+} from "@opentui/core"
import { createOpenTUIRenderer } from "./src/renderer"
-import type { InjectionKey } from "vue"
+import { defineComponent, h, shallowRef, type App, type Component, type InjectionKey } from "vue"
+import { elements, textNodeKeys, type Element, type TextNodeKey } from "./src/elements"
+import { initializeDevtools } from "./src/devtools"
export * from "./src/composables/index"
export * from "./src/extend"
+export { OpenTUIResolver } from "./src/resolver"
+export { testRender } from "./src/test-utils"
+export { Portal, type PortalProps } from "./src/components/Portal"
+export { setupOpenTUIDevtools, type OpenTUIDevtoolsSettings } from "./src/devtools"
export const cliRendererKey: InjectionKey = Symbol("cliRenderer")
-export async function render(component: any, rendererConfig: CliRendererConfig = {}): Promise {
- const cliRenderer = await createCliRenderer(rendererConfig)
+export interface RenderableComponentExpose {
+ readonly element: TRenderable | null
+}
+
+export function installOpenTUIComponents(app: App): void {
+ for (const elementName of Object.keys(elements)) {
+ const displayName = elementName.endsWith("Renderable")
+ ? elementName.slice(0, -10).toLowerCase()
+ : elementName.toLowerCase()
+
+ app.component(
+ elementName,
+ defineComponent({
+ name: displayName,
+ inheritAttrs: false,
+ setup(_props, { attrs, slots, expose }) {
+ const element = shallowRef(null)
+ expose({
+ get element() {
+ return element.value
+ },
+ })
+
+ return () => h(elementName, { ...attrs, ref: element }, slots.default?.())
+ },
+ }),
+ )
+ }
+}
+
+let currentCliRenderer: CliRenderer | null = null
+let currentEngineOwner: CliRenderer | null = null
+
+export function getCurrentCliRenderer(): CliRenderer | null {
+ return currentCliRenderer
+}
+
+export async function render(
+ component: Component,
+ rendererOrConfig: CliRenderer | CliRendererConfig = {},
+): Promise {
+ const { shouldEnableDevtools, devtoolsCleanup } = await initializeDevtools()
+
+ const cliRenderer =
+ rendererOrConfig instanceof CliRenderer ? rendererOrConfig : await createCliRenderer(rendererOrConfig)
+ currentCliRenderer = cliRenderer
+
+ engine.attach(cliRenderer)
+ currentEngineOwner = cliRenderer
+
+ cliRenderer.once(CliRenderEvents.DESTROY, () => {
+ if (currentEngineOwner === cliRenderer) {
+ engine.detach()
+ currentEngineOwner = null
+ }
+ devtoolsCleanup?.()
+ if (currentCliRenderer === cliRenderer) {
+ currentCliRenderer = null
+ }
+ })
+
const renderer = createOpenTUIRenderer(cliRenderer)
const app = renderer.createApp(component)
+ installOpenTUIComponents(app as App)
app.provide(cliRendererKey, cliRenderer)
+
+ if (shouldEnableDevtools) {
+ try {
+ const { setupOpenTUIDevtools } = await import("./src/devtools")
+
+ ;(app.config as unknown as Record).devtools = true
+ setupOpenTUIDevtools(app as App, cliRenderer)
+ } catch (e) {
+ if (process.env["NODE_ENV"] === "development") {
+ console.warn("[OpenTUI] Failed to setup DevTools:", e)
+ }
+ }
+ }
+
app.mount(cliRenderer.root)
}
+
+// Component catalogue exports (matching Solid API)
+export { elements, textNodeKeys, type Element, type TextNodeKey }
+
+/**
+ * Base components (non-text-node components).
+ * Derived from elements by excluding text node keys.
+ */
+export const baseComponents = Object.fromEntries(
+ Object.entries(elements).filter(([key]) => !textNodeKeys.includes(key as TextNodeKey)),
+) as Omit
+
+export type {
+ TextProps,
+ BoxProps,
+ ScrollBoxProps,
+ InputProps,
+ SelectProps,
+ TabSelectProps,
+ TextareaProps,
+ CodeProps,
+ DiffProps,
+ LineNumberProps,
+ AsciiFontProps,
+ SpanProps,
+ LinkProps,
+ RenderableConstructor,
+ ExtendedComponentProps,
+ ExtendedIntrinsicElements,
+ OpenTUIComponents,
+} from "./types/opentui"
diff --git a/packages/vue/package.json b/packages/vue/package.json
index 9b8e65d07..0ed26c4ce 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -16,24 +16,51 @@
".": {
"types": "./index.ts",
"import": "./index.ts"
+ },
+ "./composables": {
+ "types": "./src/composables/index.ts",
+ "import": "./src/composables/index.ts"
+ },
+ "./components": {
+ "types": "./src/elements.ts",
+ "import": "./src/elements.ts"
+ },
+ "./devtools": {
+ "types": "./src/devtools/index.ts",
+ "import": "./src/devtools/index.ts"
+ },
+ "./resolver": {
+ "types": "./src/resolver.ts",
+ "import": "./src/resolver.ts"
}
},
"scripts": {
"build": "bun scripts/build.ts",
"publish": "bun scripts/publish.ts",
- "start:example": "bun scripts/build-examples.ts && bun run example/dist/main.js"
+ "start:example": "bun run typecheck && bun scripts/build-examples.ts && bun example/dist/main.js",
+ "test": "bun test",
+ "typecheck": "bunx vue-tsc --noEmit -p tsconfig.typecheck.json"
},
"dependencies": {
"@opentui/core": "workspace:*",
- "@vue/runtime-core": "^3.5.18"
+ "@vue/runtime-core": "^3.5.26"
},
"peerDependencies": {
"vue": "^3.5.18",
"typescript": "^5"
},
"devDependencies": {
+ "@types/bun": "^1.3.5",
+ "@types/node": "^25.0.3",
+ "@vue/devtools": "^8.0.5",
+ "@vue/devtools-api": "^8.0.5",
+ "@vue/devtools-core": "^8.0.5",
+ "@vue/devtools-kit": "^8.0.5",
"bun-plugin-vue3": "^1.0.0-beta.2",
- "typescript": "^5",
- "vue": "^3.5.18"
+ "bun-types": "^1.3.5",
+ "socket.io-client": "^4.8.3",
+ "typescript": "^5.9.3",
+ "vue": "^3.5.26",
+ "vue-tsc": "^3.2.2"
}
}
diff --git a/packages/vue/src/cli-renderer-ref.ts b/packages/vue/src/cli-renderer-ref.ts
deleted file mode 100644
index 860c02bb8..000000000
--- a/packages/vue/src/cli-renderer-ref.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { CliRenderer } from "@opentui/core"
-
-let currentCliRenderer: CliRenderer | null = null
-
-export function setCurrentCliRenderer(renderer: CliRenderer) {
- currentCliRenderer = renderer
-}
-
-export function getCurrentCliRenderer(): CliRenderer {
- if (!currentCliRenderer) {
- throw new Error("No CLI renderer available. Make sure to call setCurrentCliRenderer.")
- }
- return currentCliRenderer
-}
diff --git a/packages/vue/src/components/Portal.ts b/packages/vue/src/components/Portal.ts
new file mode 100644
index 000000000..febd132c6
--- /dev/null
+++ b/packages/vue/src/components/Portal.ts
@@ -0,0 +1,121 @@
+import { defineComponent, Fragment, h, onMounted, onUnmounted, watch, type PropType, type VNode, shallowRef } from "vue"
+import { BoxRenderable, type Renderable } from "@opentui/core"
+import { useCliRenderer } from "../composables/useCliRenderer"
+import { createOpenTUIRenderer } from "../renderer"
+
+let portalId = 0
+
+export const Portal = defineComponent({
+ name: "Portal",
+ props: {
+ to: {
+ type: Object as PropType,
+ default: undefined,
+ },
+ },
+ setup(props, { slots }) {
+ const cliRenderer = useCliRenderer()
+ const id = `portal-${++portalId}`
+ const opentuiRenderer = createOpenTUIRenderer(cliRenderer)
+
+ let container: BoxRenderable | null = null
+ let currentTarget: Renderable | null = null
+ let isMounted = false
+ let instanceId = 0
+
+ const childrenRef = shallowRef(null)
+
+ const getTarget = (): Renderable => props.to || cliRenderer.root
+
+ const createNewContainer = (): BoxRenderable => {
+ instanceId++
+ return new BoxRenderable(cliRenderer, { id: `${id}-${instanceId}` })
+ }
+
+ const destroyCurrentContainer = () => {
+ if (container && currentTarget) {
+ try {
+ opentuiRenderer.render(null, container)
+ } catch {}
+ try {
+ currentTarget.remove(container.id)
+ } catch {}
+ try {
+ container.destroyRecursively()
+ } catch {}
+ container = null
+ currentTarget = null
+ }
+ }
+
+ const attachToTarget = () => {
+ if (!isMounted) return
+
+ const target = getTarget()
+
+ if (currentTarget === target && container) {
+ return
+ }
+
+ destroyCurrentContainer()
+
+ container = createNewContainer()
+ try {
+ target.add(container)
+ } catch {}
+ currentTarget = target
+
+ renderChildren()
+ }
+
+ const renderChildren = () => {
+ if (!isMounted || !container) return
+
+ const children = childrenRef.value
+
+ if (!children || children.length === 0) {
+ try {
+ opentuiRenderer.render(null, container)
+ } catch {}
+ return
+ }
+
+ try {
+ opentuiRenderer.render(h(Fragment, children), container)
+ } catch {}
+ }
+
+ onMounted(() => {
+ isMounted = true
+ attachToTarget()
+ })
+
+ onUnmounted(() => {
+ isMounted = false
+ destroyCurrentContainer()
+ })
+
+ watch(
+ () => props.to,
+ () => {
+ attachToTarget()
+ },
+ )
+
+ watch(
+ childrenRef,
+ () => {
+ renderChildren()
+ },
+ { flush: "post" },
+ )
+
+ return () => {
+ const children = slots.default?.()
+ childrenRef.value = (children as VNode[] | undefined) ?? null
+ return null
+ }
+ },
+})
+
+export type PortalProps = InstanceType["$props"]
diff --git a/packages/vue/src/composables/index.ts b/packages/vue/src/composables/index.ts
index 0c24fdee8..1f2651429 100644
--- a/packages/vue/src/composables/index.ts
+++ b/packages/vue/src/composables/index.ts
@@ -1,4 +1,7 @@
export { useTerminalDimensions } from "./useTerminalDimensions"
export { useOnResize } from "./useOnResize"
-export { useKeyboard } from "./useKeyboard"
+export { useKeyboard, type UseKeyboardOptions } from "./useKeyboard"
export { useCliRenderer } from "./useCliRenderer"
+export { useTimeline } from "./useTimeline"
+export { usePaste } from "./usePaste"
+export { useSelectionHandler } from "./useSelectionHandler"
diff --git a/packages/vue/src/composables/useCliRenderer.ts b/packages/vue/src/composables/useCliRenderer.ts
index aff26b663..96a992cd9 100644
--- a/packages/vue/src/composables/useCliRenderer.ts
+++ b/packages/vue/src/composables/useCliRenderer.ts
@@ -1,9 +1,9 @@
import { inject } from "@vue/runtime-core"
import type { CliRenderer } from "@opentui/core"
-import { cliRendererKey } from "../.."
+import { cliRendererKey, getCurrentCliRenderer } from "../.."
export function useCliRenderer(): CliRenderer {
- const renderer = inject(cliRendererKey)
+ const renderer = inject(cliRendererKey) ?? getCurrentCliRenderer()
if (!renderer) {
throw new Error("Could not find CliRenderer instance. Was it provided by the app?")
diff --git a/packages/vue/src/composables/useKeyboard.ts b/packages/vue/src/composables/useKeyboard.ts
index 64264eca9..bcc959bc8 100644
--- a/packages/vue/src/composables/useKeyboard.ts
+++ b/packages/vue/src/composables/useKeyboard.ts
@@ -1,16 +1,44 @@
-// packages/vue/src/composables/useKeyboard.ts
import { type KeyEvent } from "@opentui/core"
import { onMounted, onUnmounted } from "vue"
import { useCliRenderer } from "./useCliRenderer"
-export const useKeyboard = (handler: (key: KeyEvent) => void) => {
+export interface UseKeyboardOptions {
+ /** Include release events - callback receives events with eventType: "release" */
+ release?: boolean
+}
+
+/**
+ * Subscribe to keyboard events.
+ *
+ * By default, only receives press events (including key repeats with `repeated: true`).
+ * Use `options.release` to also receive release events.
+ *
+ * @example
+ * // Basic press handling (includes repeats)
+ * useKeyboard((e) => console.log(e.name, e.repeated ? "(repeat)" : ""))
+ *
+ * // With release events
+ * const keys = new Set()
+ * useKeyboard((e) => {
+ * if (e.eventType === "release") keys.delete(e.name)
+ * else keys.add(e.name)
+ * }, { release: true })
+ */
+export const useKeyboard = (handler: (key: KeyEvent) => void, options?: UseKeyboardOptions) => {
const renderer = useCliRenderer()
+ const keyHandler = renderer.keyInput
onMounted(() => {
- renderer.keyInput.on("keypress", handler)
+ keyHandler.on("keypress", handler)
+ if (options?.release) {
+ keyHandler.on("keyrelease", handler)
+ }
})
onUnmounted(() => {
- renderer.keyInput.off("keypress", handler)
+ keyHandler.off("keypress", handler)
+ if (options?.release) {
+ keyHandler.off("keyrelease", handler)
+ }
})
}
diff --git a/packages/vue/src/composables/usePaste.ts b/packages/vue/src/composables/usePaste.ts
new file mode 100644
index 000000000..0cbb3777f
--- /dev/null
+++ b/packages/vue/src/composables/usePaste.ts
@@ -0,0 +1,16 @@
+import { onMounted, onUnmounted } from "vue"
+import type { PasteEvent } from "@opentui/core"
+import { useCliRenderer } from "./useCliRenderer"
+
+export function usePaste(callback: (event: PasteEvent) => void) {
+ const renderer = useCliRenderer()
+ const keyHandler = renderer.keyInput
+
+ onMounted(() => {
+ keyHandler.on("paste", callback)
+ })
+
+ onUnmounted(() => {
+ keyHandler.off("paste", callback)
+ })
+}
diff --git a/packages/vue/src/composables/useSelectionHandler.ts b/packages/vue/src/composables/useSelectionHandler.ts
new file mode 100644
index 000000000..15602c239
--- /dev/null
+++ b/packages/vue/src/composables/useSelectionHandler.ts
@@ -0,0 +1,15 @@
+import { onMounted, onUnmounted } from "vue"
+import type { Selection } from "@opentui/core"
+import { useCliRenderer } from "./useCliRenderer"
+
+export function useSelectionHandler(callback: (selection: Selection) => void) {
+ const renderer = useCliRenderer()
+
+ onMounted(() => {
+ renderer.on("selection", callback)
+ })
+
+ onUnmounted(() => {
+ renderer.off("selection", callback)
+ })
+}
diff --git a/packages/vue/src/composables/useTimeline.ts b/packages/vue/src/composables/useTimeline.ts
new file mode 100644
index 000000000..11ef405de
--- /dev/null
+++ b/packages/vue/src/composables/useTimeline.ts
@@ -0,0 +1,20 @@
+import { onMounted, onUnmounted } from "vue"
+import { engine, Timeline, type TimelineOptions } from "@opentui/core"
+
+export function useTimeline(options: TimelineOptions = {}): Timeline {
+ const timeline = new Timeline(options)
+
+ onMounted(() => {
+ if (options.autoplay !== false) {
+ timeline.play()
+ }
+ engine.register(timeline)
+ })
+
+ onUnmounted(() => {
+ timeline.pause()
+ engine.unregister(timeline)
+ })
+
+ return timeline
+}
diff --git a/packages/vue/src/devtools/connect.ts b/packages/vue/src/devtools/connect.ts
new file mode 100644
index 000000000..872d124b8
--- /dev/null
+++ b/packages/vue/src/devtools/connect.ts
@@ -0,0 +1,154 @@
+const g = globalThis as Record
+
+function ensureLocalStorage(): void {
+ if (typeof g.localStorage !== "undefined") return
+
+ const store: Record = {}
+ g.localStorage = {
+ getItem: (key: string) => store[key] ?? null,
+ setItem: (key: string, value: string) => {
+ store[key] = value
+ },
+ removeItem: (key: string) => {
+ delete store[key]
+ },
+ clear: () => {
+ Object.keys(store).forEach((k) => delete store[k])
+ },
+ get length() {
+ return Object.keys(store).length
+ },
+ key: (i: number) => Object.keys(store)[i] ?? null,
+ }
+}
+
+function ensureDevtoolsGlobals(): void {
+ ensureLocalStorage()
+
+ // Vue DevTools assumes a browser-like environment based on `navigator`.
+ // In terminal environments (Bun/Node) we provide minimal DOM-ish globals to prevent crashes.
+ if (typeof g.document === "undefined") {
+ g.document = {
+ querySelectorAll: () => [],
+ querySelector: () => null,
+ createElement: () => ({
+ style: {},
+ appendChild: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ parentNode: { removeChild: () => {} },
+ }),
+ createRange: () => ({
+ selectNode: () => {},
+ getBoundingClientRect: () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }),
+ }),
+ getElementById: () => null,
+ body: { appendChild: () => {}, removeChild: () => {} },
+ documentElement: { appendChild: () => {} },
+ }
+ }
+
+ if (typeof g.window === "undefined") {
+ g.window = g
+ }
+
+ if (typeof g.self === "undefined") {
+ g.self = g
+ }
+
+ if (typeof g.navigator === "undefined") {
+ g.navigator = { userAgent: "node" }
+ }
+
+ const windowRecord = g.window as Record
+ windowRecord.addEventListener =
+ (windowRecord.addEventListener as unknown as (...args: unknown[]) => void) || (() => {})
+ windowRecord.removeEventListener =
+ (windowRecord.removeEventListener as unknown as (...args: unknown[]) => void) || (() => {})
+}
+
+export async function initDevtoolsGlobalHook(): Promise {
+ ensureDevtoolsGlobals()
+ const { initDevTools, toggleHighPerfMode } = await import("@vue/devtools-kit")
+ initDevTools()
+
+ const highPerfEnv = process.env["OPENTUI_DEVTOOLS_HIGH_PERF"] ?? "false"
+ const isHighPerfEnabled = highPerfEnv === "true"
+ if (!isHighPerfEnabled) {
+ toggleHighPerfMode(false)
+ }
+}
+
+export interface DevToolsConnectOptions {
+ connect?: boolean
+ waitForConnect?: boolean
+ timeoutMs?: number
+}
+
+export async function connectToDevTools(
+ host = "http://localhost",
+ port = 8098,
+ options: DevToolsConnectOptions = {},
+): Promise<() => void> {
+ await initDevtoolsGlobalHook()
+
+ if (options.connect === false) return () => {}
+
+ const { createRpcServer, setElectronServerContext } = await import("@vue/devtools-kit")
+ const { functions } = await import("@vue/devtools-core")
+ const { io } = await import("socket.io-client")
+
+ const url = `${host}:${port}`
+ const socket = io(url)
+ let didSetupRpc = false
+
+ const onConnect = () => {
+ if (!didSetupRpc) {
+ didSetupRpc = true
+ setElectronServerContext(socket)
+ createRpcServer(functions, { preset: "electron" })
+ }
+ socket.emit("vue-devtools:init")
+ }
+
+ socket.on("connect", onConnect)
+
+ socket.on("vue-devtools:disconnect-user-app", () => {
+ socket.disconnect()
+ })
+
+ const waitForConnect = options.waitForConnect ?? true
+ if (waitForConnect && !socket.connected) {
+ const timeoutMs = options.timeoutMs ?? 2000
+ await new Promise((resolve) => {
+ let settled = false
+ const settle = () => {
+ if (settled) return
+ settled = true
+ socket.off("connect", handleConnect)
+ socket.off("connect_error", handleError)
+ socket.off("error", handleError)
+ clearTimeout(timer)
+ resolve()
+ }
+
+ const handleConnect = () => {
+ settle()
+ }
+
+ const handleError = () => {
+ settle()
+ }
+
+ socket.on("connect", handleConnect)
+ socket.on("connect_error", handleError)
+ socket.on("error", handleError)
+ const timer = setTimeout(settle, timeoutMs)
+ })
+ }
+
+ return () => {
+ socket.emit("vue-devtools:disconnect")
+ socket.disconnect()
+ }
+}
diff --git a/packages/vue/src/devtools/highlight.ts b/packages/vue/src/devtools/highlight.ts
new file mode 100644
index 000000000..cb8994ea0
--- /dev/null
+++ b/packages/vue/src/devtools/highlight.ts
@@ -0,0 +1,125 @@
+import { Renderable, type CliRenderer, type OptimizedBuffer, RGBA } from "@opentui/core"
+import { HIGHLIGHT } from "./theme"
+
+function getTooltipPosition(
+ target: { x: number; y: number; width: number; height: number },
+ textLength: number,
+ screen: { width: number; height: number },
+): { x: number; y: number } {
+ const hasSpaceAbove = target.y > 0
+ const hasSpaceBelow = target.y + target.height < screen.height
+ const aboveY = target.y - 1
+ const belowY = target.y + target.height
+
+ const y = hasSpaceAbove ? aboveY : hasSpaceBelow ? belowY : target.y
+ const x = Math.max(0, Math.min(target.x, screen.width - textLength))
+
+ return { x, y }
+}
+
+export interface HighlightController {
+ highlight(renderable: Renderable | null): void
+ setInspectMode(enabled: boolean): void
+ clear(): void
+ destroy(): void
+}
+
+const CHROMATIC_LEFT = RGBA.fromInts(255, 80, 100, 200)
+const CHROMATIC_RIGHT = RGBA.fromInts(80, 220, 255, 200)
+const BORDER_COLOR = RGBA.fromInts(79, 192, 141, 220)
+
+export function createHighlightOverlay(cliRenderer: CliRenderer): HighlightController {
+ let target: Renderable | null = null
+ let inspectMode = false
+ let destroyed = false
+
+ const render = (buffer: OptimizedBuffer): void => {
+ if (destroyed || !target || target.isDestroyed) return
+
+ const { x, y, width: w, height: h } = target
+ const screenW = cliRenderer.width
+ const screenH = cliRenderer.height
+
+ const outerX1 = Math.max(0, x - 1)
+ const outerY1 = Math.max(0, y - 1)
+ const outerX2 = Math.min(screenW - 1, x + w)
+ const outerY2 = Math.min(screenH - 1, y + h)
+
+ const isOnScreen = (px: number, py: number) => px >= 0 && px < screenW && py >= 0 && py < screenH
+
+ const drawChromaticCorner = (cx: number, cy: number, char: string, isLeftSide: boolean) => {
+ if (!isOnScreen(cx, cy)) return
+ buffer.drawText(char, cx, cy, isLeftSide ? CHROMATIC_LEFT : CHROMATIC_RIGHT, undefined)
+ }
+
+ drawChromaticCorner(outerX1, outerY1, "┌", true)
+ drawChromaticCorner(outerX2, outerY1, "┐", false)
+ drawChromaticCorner(outerX1, outerY2, "└", true)
+ drawChromaticCorner(outerX2, outerY2, "┘", false)
+
+ for (let dx = outerX1 + 1; dx < outerX2; dx++) {
+ if (isOnScreen(dx, outerY1)) {
+ buffer.drawText("─", dx, outerY1, BORDER_COLOR, undefined)
+ }
+ if (isOnScreen(dx, outerY2)) {
+ buffer.drawText("─", dx, outerY2, BORDER_COLOR, undefined)
+ }
+ }
+
+ for (let dy = outerY1 + 1; dy < outerY2; dy++) {
+ if (isOnScreen(outerX1, dy)) {
+ buffer.drawText("│", outerX1, dy, BORDER_COLOR, undefined)
+ }
+ if (isOnScreen(outerX2, dy)) {
+ buffer.drawText("│", outerX2, dy, BORDER_COLOR, undefined)
+ }
+ }
+
+ const text = `${target.constructor.name} #${target.id} (${w}x${h})`
+ const tooltip = getTooltipPosition(
+ { x: outerX1, y: outerY1, width: outerX2 - outerX1 + 1, height: outerY2 - outerY1 + 1 },
+ text.length,
+ { width: screenW, height: screenH },
+ )
+ if (tooltip.y >= 0 && tooltip.y < screenH && tooltip.x >= 0) {
+ buffer.drawText(text, tooltip.x, tooltip.y, HIGHLIGHT.tooltipFg, HIGHLIGHT.tooltipBg)
+ }
+ }
+
+ cliRenderer.root.onMouse = (event) => {
+ if (destroyed || !inspectMode) return
+ if (event.type === "move" || event.type === "drag") {
+ if (event.target !== target) {
+ target = event.target
+ cliRenderer.requestRender()
+ }
+ }
+ }
+
+ cliRenderer.addPostProcessFn(render)
+
+ return {
+ highlight: (r) => {
+ if (destroyed) return
+ target = r
+ cliRenderer.requestRender()
+ },
+ setInspectMode: (on) => {
+ if (destroyed) return
+ inspectMode = on
+ cliRenderer.requestRender()
+ },
+ clear: () => {
+ if (destroyed) return
+ target = null
+ cliRenderer.requestRender()
+ },
+ destroy: () => {
+ if (destroyed) return
+ destroyed = true
+ target = null
+ cliRenderer.removePostProcessFn(render)
+ cliRenderer.root.onMouse = undefined
+ },
+ }
+}
diff --git a/packages/vue/src/devtools/index.ts b/packages/vue/src/devtools/index.ts
new file mode 100644
index 000000000..da7c3eb91
--- /dev/null
+++ b/packages/vue/src/devtools/index.ts
@@ -0,0 +1,275 @@
+import { setupDevtoolsPlugin } from "@vue/devtools-api"
+import { CliRenderEvents, LayoutEvents, type CliRenderer } from "@opentui/core"
+import type { App } from "vue"
+import { buildRenderableTree, getRenderableState, findRenderableById } from "./inspector"
+import { setupTimeline } from "./timeline"
+import { createHighlightOverlay, type HighlightController } from "./highlight"
+
+const PLUGIN_ID = "dev.opentui.vue"
+const INSPECTOR_ID = "opentui:renderables"
+const COMPONENT_ATTRS_TYPE = "Element Attrs"
+const SKIP_ATTR_KEYS = new Set(["ref", "key"])
+
+function formatAttrValue(value: unknown): unknown {
+ if (value === null || value === undefined) return value
+ if (typeof value === "function") return "(function)"
+ if (typeof value === "symbol") return value.toString()
+ if (typeof value === "object") {
+ const ctorName = (value as object).constructor?.name
+ if (ctorName && (ctorName.endsWith("Renderable") || ctorName === "RGBA")) {
+ return `[${ctorName}]`
+ }
+ }
+ return value
+}
+
+function isOpenTUIComponent(instance: unknown): boolean {
+ const name = ((instance as Record)?.type as Record)?.name
+ if (typeof name !== "string") return false
+ const lowerName = name.toLowerCase()
+ return lowerName.endsWith("renderable") || lowerName === "portal"
+}
+
+export interface OpenTUIDevtoolsSettings {
+ autoRefresh: boolean
+ showHiddenNodes: boolean
+ highlightOnSelect: boolean
+}
+
+const DEFAULT_SETTINGS: OpenTUIDevtoolsSettings = {
+ autoRefresh: true,
+ showHiddenNodes: true,
+ highlightOnSelect: true,
+}
+
+interface DevtoolsApi {
+ sendInspectorTree(inspectorId: string): void
+ sendInspectorState(inspectorId: string): void
+ selectInspectorNode(inspectorId: string, nodeId: string): void
+ getSettings?(): Record
+}
+
+export async function initializeDevtools() {
+ const shouldEnableDevtools = process.env["NODE_ENV"] === "development" || process.env["VUE_DEVTOOLS"] === "true"
+
+ let devtoolsCleanup: (() => void) | null = null
+ if (shouldEnableDevtools) {
+ try {
+ const { connectToDevTools } = await import("./connect")
+ const connect = process.env["OPENTUI_DEVTOOLS_DISABLE_SOCKET"] !== "true"
+ const host = process.env["OPENTUI_DEVTOOLS_HOST"] || "http://localhost"
+ const port = parseInt(process.env["OPENTUI_DEVTOOLS_PORT"] || "8098", 10)
+ const waitForConnect = process.env["OPENTUI_DEVTOOLS_WAIT_FOR_CONNECT"] !== "false"
+ devtoolsCleanup = await connectToDevTools(host, port, { connect, waitForConnect })
+ } catch (e) {
+ if (process.env["NODE_ENV"] === "development") {
+ console.warn("[OpenTUI] Failed to initialize DevTools hook:", e)
+ }
+ }
+ }
+
+ return { shouldEnableDevtools, devtoolsCleanup }
+}
+
+export function setupOpenTUIDevtools(app: App, cliRenderer: CliRenderer): void {
+ setupDevtoolsPlugin(
+ {
+ id: PLUGIN_ID,
+ label: "OpenTUI",
+ packageName: "@opentui/vue",
+ homepage: "https://github.com/sst/opentui",
+ app,
+ enableEarlyProxy: true,
+ componentStateTypes: ["OpenTUI Renderable", COMPONENT_ATTRS_TYPE],
+ settings: {
+ autoRefresh: {
+ label: "Auto Refresh Tree",
+ type: "boolean",
+ defaultValue: DEFAULT_SETTINGS.autoRefresh,
+ description: "Automatically refresh the tree when layout changes",
+ },
+ showHiddenNodes: {
+ label: "Show Hidden Nodes",
+ type: "boolean",
+ defaultValue: DEFAULT_SETTINGS.showHiddenNodes,
+ description: "Show nodes that are not visible in the tree",
+ },
+ highlightOnSelect: {
+ label: "Highlight on Select",
+ type: "boolean",
+ defaultValue: DEFAULT_SETTINGS.highlightOnSelect,
+ description: "Highlight the selected element in the terminal",
+ },
+ },
+ },
+ (api) => {
+ const devApi = api as unknown as DevtoolsApi
+ const cleanups: (() => void)[] = []
+ const addCleanup = (fn: () => void) => cleanups.push(fn)
+
+ const getSettings = (): OpenTUIDevtoolsSettings => {
+ const s = devApi.getSettings?.() ?? {}
+ return {
+ autoRefresh: (s.autoRefresh as boolean) ?? DEFAULT_SETTINGS.autoRefresh,
+ showHiddenNodes: (s.showHiddenNodes as boolean) ?? DEFAULT_SETTINGS.showHiddenNodes,
+ highlightOnSelect: (s.highlightOnSelect as boolean) ?? DEFAULT_SETTINGS.highlightOnSelect,
+ }
+ }
+
+ const refresh = () => {
+ devApi.sendInspectorTree(INSPECTOR_ID)
+ devApi.sendInspectorState(INSPECTOR_ID)
+ }
+
+ const refreshIfAutoEnabled = () => {
+ if (getSettings().autoRefresh) refresh()
+ }
+
+ const highlightController: HighlightController = createHighlightOverlay(cliRenderer)
+ addCleanup(() => highlightController.destroy())
+
+ addCleanup(setupTimeline(api, cliRenderer))
+
+ api.addInspector({
+ id: INSPECTOR_ID,
+ label: "OpenTUI Renderables",
+ icon: "account_tree",
+ treeFilterPlaceholder: "Search by ID or type...",
+ actions: [
+ {
+ icon: "gps_fixed",
+ tooltip: "Pick element in terminal",
+ action: () => {
+ highlightController.setInspectMode(true)
+ const previousHandler = cliRenderer.root.onMouse
+ cliRenderer.root.onMouse = (event) => {
+ previousHandler?.(event)
+ if (event.type === "up" && event.target) {
+ highlightController.setInspectMode(false)
+ highlightController.highlight(event.target)
+ devApi.selectInspectorNode(INSPECTOR_ID, event.target.id)
+ cliRenderer.root.onMouse = previousHandler
+ }
+ }
+ },
+ },
+ {
+ icon: "highlight_off",
+ tooltip: "Clear highlight",
+ action: () => {
+ highlightController.clear()
+ },
+ },
+ ],
+ nodeActions: [
+ {
+ icon: "visibility",
+ tooltip: "Toggle visibility",
+ action: (nodeId: string) => {
+ const node = findRenderableById(cliRenderer, nodeId)
+ if (node) {
+ node.visible = !node.visible
+ refresh()
+ }
+ },
+ },
+ {
+ icon: "center_focus_strong",
+ tooltip: "Focus element",
+ action: (nodeId: string) => {
+ const node = findRenderableById(cliRenderer, nodeId)
+ if (node?.focusable) {
+ node.focus()
+ refresh()
+ }
+ },
+ },
+ {
+ icon: "highlight",
+ tooltip: "Highlight element",
+ action: (nodeId: string) => {
+ highlightController.highlight(findRenderableById(cliRenderer, nodeId) ?? null)
+ },
+ },
+ ],
+ })
+
+ api.on.getInspectorTree((payload) => {
+ if (payload.inspectorId !== INSPECTOR_ID) {
+ highlightController.clear()
+ return
+ }
+ if (cliRenderer.root) {
+ payload.rootNodes = [buildRenderableTree(cliRenderer.root, payload.filter)]
+ }
+ })
+
+ api.on.getInspectorState((payload) => {
+ if (payload.inspectorId !== INSPECTOR_ID) {
+ highlightController.clear()
+ return
+ }
+ const renderable = findRenderableById(cliRenderer, payload.nodeId)
+ if (!renderable) {
+ highlightController.clear()
+ return
+ }
+
+ payload.state = getRenderableState(renderable)
+ if (getSettings().highlightOnSelect) {
+ highlightController.highlight(renderable)
+ }
+ })
+
+ api.on.editInspectorState((payload) => {
+ if (payload.inspectorId !== INSPECTOR_ID || payload.path.length === 0) return
+ const renderable = findRenderableById(cliRenderer, payload.nodeId)
+ const propName = payload.path[payload.path.length - 1]
+ if (renderable && propName && propName in renderable) {
+ ;(renderable as unknown as Record)[propName] = payload.state.value
+ refresh()
+ }
+ })
+
+ const root = cliRenderer.root
+ const layoutEvents = [LayoutEvents.LAYOUT_CHANGED, LayoutEvents.ADDED, LayoutEvents.REMOVED]
+ layoutEvents.forEach((event) => {
+ root.on(event, refreshIfAutoEnabled)
+ addCleanup(() => root.off(event, refreshIfAutoEnabled))
+ })
+
+ api.on.setPluginSettings?.((payload) => {
+ if (payload.pluginId === PLUGIN_ID) refresh()
+ })
+
+ api.on.inspectComponent((payload) => {
+ const instance = payload.componentInstance
+ if (!instance?.attrs || !isOpenTUIComponent(instance)) return
+
+ const attrs = instance.attrs as Record
+ const attrKeys = Object.keys(attrs)
+ if (attrKeys.length === 0) return
+
+ for (const key of attrKeys) {
+ if (key.startsWith("on") || SKIP_ATTR_KEYS.has(key)) continue
+
+ payload.instanceData.state.push({
+ type: COMPONENT_ATTRS_TYPE,
+ key,
+ value: formatAttrValue(attrs[key]),
+ editable: false,
+ })
+ }
+ })
+
+ cliRenderer.once(CliRenderEvents.DESTROY, () => {
+ cleanups.forEach((fn) => fn())
+ cleanups.length = 0
+ })
+ },
+ )
+}
+
+export { buildRenderableTree, getRenderableState, findRenderableById } from "./inspector"
+export { createHighlightOverlay, type HighlightController } from "./highlight"
+export { TIMELINE_RENDER, TIMELINE_LAYOUT, TIMELINE_EVENTS } from "./timeline"
diff --git a/packages/vue/src/devtools/inspector.ts b/packages/vue/src/devtools/inspector.ts
new file mode 100644
index 000000000..557e491e6
--- /dev/null
+++ b/packages/vue/src/devtools/inspector.ts
@@ -0,0 +1,250 @@
+import type { Renderable, CliRenderer } from "@opentui/core"
+import { RGBA, rgbToHex, Yoga, TextRenderable, BoxRenderable, SelectRenderable } from "@opentui/core"
+import { TAG_COLORS } from "./theme"
+import { CommentNode, WhiteSpaceNode } from "../nodes"
+import { GhostTextRenderable } from "../noOps"
+
+const { Edge } = Yoga
+
+const isTextRenderable = (r: Renderable): r is TextRenderable => r instanceof TextRenderable
+
+const isBoxRenderable = (r: Renderable): r is BoxRenderable => r instanceof BoxRenderable
+
+const isSelectRenderable = (r: Renderable): r is SelectRenderable => r instanceof SelectRenderable
+
+interface CustomInspectorNode {
+ id: string
+ label: string
+ children?: CustomInspectorNode[]
+ tags?: InspectorNodeTag[]
+}
+
+interface InspectorNodeTag {
+ label: string
+ textColor: number
+ backgroundColor: number
+ tooltip?: string
+}
+
+interface StateField {
+ key: string
+ value: unknown
+ editable?: boolean
+}
+
+export interface CustomInspectorState {
+ [section: string]: StateField[]
+}
+
+function getDisplayName(renderable: Renderable): string {
+ const name = renderable.constructor.name
+ const baseName = name.endsWith("Renderable") ? name.slice(0, -10) : name
+ return baseName.toLowerCase()
+}
+
+function matchesFilter(renderable: Renderable, filter: string): boolean {
+ const lowerFilter = filter.toLowerCase()
+ const displayName = getDisplayName(renderable).toLowerCase()
+ const id = renderable.id.toLowerCase()
+ return displayName.includes(lowerFilter) || id.includes(lowerFilter)
+}
+
+function getFlexDirectionString(renderable: Renderable): string {
+ const yogaNode = renderable.getLayoutNode()
+ const direction = yogaNode.getFlexDirection()
+
+ const FLEX_DIRECTION_MAP: Record = {
+ 0: "column",
+ 1: "column-reverse",
+ 2: "row",
+ 3: "row-reverse",
+ }
+
+ return FLEX_DIRECTION_MAP[direction] ?? "column"
+}
+
+function formatColor(color: RGBA | undefined): string {
+ if (!color) return "transparent"
+ if (color.a === 0) return "transparent"
+ return rgbToHex(color)
+}
+
+function formatPadding(renderable: Renderable): string {
+ const yogaNode = renderable.getLayoutNode()
+ const top = yogaNode.getComputedPadding(Edge.Top)
+ const right = yogaNode.getComputedPadding(Edge.Right)
+ const bottom = yogaNode.getComputedPadding(Edge.Bottom)
+ const left = yogaNode.getComputedPadding(Edge.Left)
+
+ if (top === right && right === bottom && bottom === left) {
+ return String(top)
+ }
+ return `${top} ${right} ${bottom} ${left}`
+}
+
+function formatMargin(renderable: Renderable): string {
+ const yogaNode = renderable.getLayoutNode()
+ const top = yogaNode.getComputedMargin(Edge.Top)
+ const right = yogaNode.getComputedMargin(Edge.Right)
+ const bottom = yogaNode.getComputedMargin(Edge.Bottom)
+ const left = yogaNode.getComputedMargin(Edge.Left)
+
+ if (top === right && right === bottom && bottom === left) {
+ return String(top)
+ }
+ return `${top} ${right} ${bottom} ${left}`
+}
+
+function formatBorder(renderable: Renderable): string {
+ const yogaNode = renderable.getLayoutNode()
+ const top = yogaNode.getComputedBorder(Edge.Top)
+ const right = yogaNode.getComputedBorder(Edge.Right)
+ const bottom = yogaNode.getComputedBorder(Edge.Bottom)
+ const left = yogaNode.getComputedBorder(Edge.Left)
+
+ if (top === right && right === bottom && bottom === left) {
+ return String(top)
+ }
+ return `${top} ${right} ${bottom} ${left}`
+}
+
+const isInternalNode = (r: Renderable): boolean =>
+ r instanceof CommentNode || r instanceof WhiteSpaceNode || r instanceof GhostTextRenderable
+
+export function buildRenderableTree(renderable: Renderable, filter?: string): CustomInspectorNode {
+ const displayName = getDisplayName(renderable)
+ const children = renderable.getChildren() as Renderable[]
+
+ const tags: CustomInspectorNode["tags"] = [
+ {
+ label: renderable.id,
+ ...TAG_COLORS.id,
+ },
+ {
+ label: `${renderable.width}x${renderable.height}`,
+ ...TAG_COLORS.size,
+ },
+ ]
+
+ if (!renderable.visible) {
+ tags.push({
+ label: "hidden",
+ ...TAG_COLORS.hidden,
+ })
+ }
+
+ if (renderable.focused) {
+ tags.push({
+ label: "focused",
+ ...TAG_COLORS.focused,
+ })
+ }
+
+ const filteredChildren: CustomInspectorNode[] = []
+ for (const child of children) {
+ if (isInternalNode(child)) continue
+
+ const childNode = buildRenderableTree(child, filter)
+ const childMatchesFilter = !filter || matchesFilter(child, filter) || (childNode.children?.length ?? 0) > 0
+ if (childMatchesFilter) {
+ filteredChildren.push(childNode)
+ }
+ }
+
+ const matchesSelf = !filter || matchesFilter(renderable, filter)
+ const hasMatchingChildren = filteredChildren.length > 0
+
+ return {
+ id: renderable.id,
+ label: displayName,
+ tags,
+ children: matchesSelf || hasMatchingChildren ? filteredChildren : [],
+ }
+}
+
+export function getRenderableState(renderable: Renderable): CustomInspectorState {
+ const yogaNode = renderable.getLayoutNode()
+ const children = renderable.getChildren()
+
+ const state: CustomInspectorState = {
+ Layout: [
+ { key: "id", value: renderable.id, editable: false },
+ { key: "x", value: renderable.x, editable: false },
+ { key: "y", value: renderable.y, editable: false },
+ { key: "width", value: renderable.width, editable: true },
+ { key: "height", value: renderable.height, editable: true },
+ { key: "zIndex", value: renderable.zIndex, editable: true },
+ { key: "padding", value: formatPadding(renderable), editable: false },
+ { key: "margin", value: formatMargin(renderable), editable: false },
+ { key: "border", value: formatBorder(renderable), editable: false },
+ ],
+
+ Visibility: [
+ { key: "visible", value: renderable.visible, editable: true },
+ { key: "opacity", value: renderable.opacity, editable: true },
+ ],
+
+ "Flex Layout": [
+ { key: "flexDirection", value: getFlexDirectionString(renderable), editable: false },
+ { key: "flexGrow", value: yogaNode.getFlexGrow(), editable: false },
+ { key: "flexShrink", value: yogaNode.getFlexShrink(), editable: false },
+ ],
+
+ State: [
+ { key: "focused", value: renderable.focused, editable: false },
+ { key: "focusable", value: renderable.focusable, editable: false },
+ { key: "isDirty", value: renderable.isDirty, editable: false },
+ { key: "isDestroyed", value: renderable.isDestroyed, editable: false },
+ ],
+
+ Children: [
+ { key: "count", value: children.length, editable: false },
+ { key: "childIds", value: children.map((child) => child.id), editable: false },
+ ],
+ }
+
+ if (isTextRenderable(renderable)) {
+ state["Content"] = [{ key: "text", value: renderable.plainText, editable: false }]
+
+ state["Text Styles"] = [
+ { key: "fg", value: formatColor(renderable.fg), editable: false },
+ { key: "bg", value: formatColor(renderable.bg), editable: false },
+ { key: "wrapMode", value: renderable.wrapMode, editable: false },
+ { key: "lineCount", value: renderable.lineCount, editable: false },
+ { key: "selectable", value: renderable.selectable, editable: false },
+ ]
+ }
+
+ if (isBoxRenderable(renderable)) {
+ state["Box Styles"] = [
+ { key: "backgroundColor", value: formatColor(renderable.backgroundColor), editable: false },
+ { key: "borderStyle", value: renderable.borderStyle, editable: false },
+ { key: "borderColor", value: formatColor(renderable.borderColor), editable: false },
+ { key: "focusedBorderColor", value: formatColor(renderable.focusedBorderColor), editable: false },
+ { key: "title", value: renderable.title ?? "(none)", editable: false },
+ { key: "titleAlignment", value: renderable.titleAlignment, editable: false },
+ ]
+ }
+
+ if (isSelectRenderable(renderable)) {
+ const selectedOption = renderable.getSelectedOption()
+ state.State!.push(
+ { key: "value", value: selectedOption ? selectedOption.name : null, editable: false },
+ { key: "selectedIndex", value: renderable.getSelectedIndex(), editable: false },
+ { key: "optionData", value: selectedOption ?? null, editable: false },
+ { key: "optionsCount", value: renderable.options.length, editable: false },
+ { key: "wrapSelection", value: renderable.wrapSelection, editable: false },
+ { key: "showDescription", value: renderable.showDescription, editable: false },
+ { key: "showScrollIndicator", value: renderable.showScrollIndicator, editable: false },
+ )
+ }
+
+ return state
+}
+
+export function findRenderableById(cliRenderer: CliRenderer, id: string): Renderable | undefined {
+ if (cliRenderer.root.id === id) {
+ return cliRenderer.root
+ }
+ return cliRenderer.root.findDescendantById(id)
+}
diff --git a/packages/vue/src/devtools/theme.ts b/packages/vue/src/devtools/theme.ts
new file mode 100644
index 000000000..36deb050d
--- /dev/null
+++ b/packages/vue/src/devtools/theme.ts
@@ -0,0 +1,23 @@
+import { RGBA } from "@opentui/core"
+
+const hexToRGBA = (hex: number, alpha = 255) => RGBA.fromInts((hex >> 16) & 0xff, (hex >> 8) & 0xff, hex & 0xff, alpha)
+
+export const VUE_COLORS = {
+ primary: 0x4fc08d,
+ secondary: 0x42b883,
+ dark: 0x35495e,
+} as const
+
+export const TAG_COLORS = {
+ id: { textColor: 0x6b7280, backgroundColor: 0xf3f4f6 },
+ size: { textColor: 0x059669, backgroundColor: 0xd1fae5 },
+ hidden: { textColor: 0xdc2626, backgroundColor: 0xfee2e2 },
+ focused: { textColor: 0x2563eb, backgroundColor: 0xdbeafe },
+} as const
+
+export const HIGHLIGHT = {
+ border: hexToRGBA(VUE_COLORS.primary),
+ background: hexToRGBA(VUE_COLORS.primary, 40),
+ tooltipBg: hexToRGBA(VUE_COLORS.primary, 230),
+ tooltipFg: RGBA.fromInts(0, 0, 0, 255),
+} as const
diff --git a/packages/vue/src/devtools/timeline.ts b/packages/vue/src/devtools/timeline.ts
new file mode 100644
index 000000000..f37596d6d
--- /dev/null
+++ b/packages/vue/src/devtools/timeline.ts
@@ -0,0 +1,92 @@
+import { LayoutEvents, type CliRenderer, type Renderable } from "@opentui/core"
+import { VUE_COLORS } from "./theme"
+
+export const TIMELINE_RENDER = "opentui:render"
+export const TIMELINE_LAYOUT = "opentui:layout"
+export const TIMELINE_EVENTS = "opentui:events"
+
+interface TimelineApi {
+ now(): number
+ addTimelineLayer(options: { id: string; label: string; color: number }): void
+ addTimelineEvent(options: {
+ layerId: string
+ event: { time: number; title?: string; subtitle?: string; data?: Record }
+ }): void
+ on: {
+ inspectTimelineEvent(
+ handler: (payload: {
+ layerId: string
+ event: { time: number; title?: string; subtitle?: string; data?: Record }
+ data: unknown
+ }) => void,
+ ): void
+ }
+}
+
+const LAYERS = [
+ { id: TIMELINE_RENDER, label: "OpenTUI Render", color: VUE_COLORS.primary },
+ { id: TIMELINE_LAYOUT, label: "OpenTUI Layout", color: VUE_COLORS.secondary },
+ { id: TIMELINE_EVENTS, label: "OpenTUI Events", color: VUE_COLORS.dark },
+] as const
+
+export function setupTimeline(api: unknown, cliRenderer: CliRenderer): () => void {
+ const timelineApi = api as TimelineApi
+ const cleanups: (() => void)[] = []
+
+ LAYERS.forEach((layer) => timelineApi.addTimelineLayer(layer))
+
+ timelineApi.on.inspectTimelineEvent((payload) => {
+ const isOurLayer = LAYERS.some((l) => l.id === payload.layerId)
+ if (!isOurLayer) return
+
+ payload.data = {
+ ...payload.event.data,
+ time: payload.event.time,
+ title: payload.event.title,
+ subtitle: payload.event.subtitle,
+ }
+ })
+
+ const emit = (layerId: string, title: string, subtitle: string, data?: Record) => {
+ timelineApi.addTimelineEvent({ layerId, event: { time: timelineApi.now(), title, subtitle, data } })
+ }
+
+ let frameCount = 0
+ const frameCallback = async (deltaTime: number): Promise => {
+ frameCount++
+ const fps = deltaTime > 0 ? Math.round(1000 / deltaTime) : 0
+ emit(TIMELINE_RENDER, "Frame Rendered", `${deltaTime.toFixed(1)}ms`, {
+ frameTime: deltaTime,
+ fps,
+ nodeCount: countRenderables(cliRenderer.root),
+ frameNumber: frameCount,
+ })
+ }
+
+ cliRenderer.setFrameCallback(frameCallback)
+ cleanups.push(() => cliRenderer.removeFrameCallback(frameCallback))
+
+ const root = cliRenderer.root
+
+ const handlers = {
+ [LayoutEvents.LAYOUT_CHANGED]: () =>
+ emit(TIMELINE_LAYOUT, "Layout Changed", root.id, { rootId: root.id, width: root.width, height: root.height }),
+ [LayoutEvents.ADDED]: (child: Renderable) =>
+ emit(TIMELINE_LAYOUT, "Node Added", child?.id ?? "unknown", { nodeId: child?.id, parentId: root.id }),
+ [LayoutEvents.REMOVED]: (child: Renderable) =>
+ emit(TIMELINE_LAYOUT, "Node Removed", child?.id ?? "unknown", { nodeId: child?.id, parentId: root.id }),
+ [LayoutEvents.RESIZED]: (d: { width: number; height: number }) =>
+ emit(TIMELINE_LAYOUT, "Root Resized", `${d.width}x${d.height}`, d),
+ } as const
+
+ Object.entries(handlers).forEach(([event, handler]) => {
+ root.on(event, handler as (...args: unknown[]) => void)
+ cleanups.push(() => root.off(event, handler as (...args: unknown[]) => void))
+ })
+
+ return () => cleanups.forEach((fn) => fn())
+}
+
+function countRenderables(renderable: Renderable): number {
+ return 1 + renderable.getChildren().reduce((sum, child) => sum + countRenderables(child as Renderable), 0)
+}
diff --git a/packages/vue/src/elements.ts b/packages/vue/src/elements.ts
index 2bdec94a3..1464731a4 100644
--- a/packages/vue/src/elements.ts
+++ b/packages/vue/src/elements.ts
@@ -1,20 +1,129 @@
import {
ASCIIFontRenderable,
BoxRenderable,
+ CodeRenderable,
+ DiffRenderable,
InputRenderable,
+ LineNumberRenderable,
+ ScrollBoxRenderable,
SelectRenderable,
TabSelectRenderable,
+ TextareaRenderable,
+ TextAttributes,
+ TextNodeRenderable,
TextRenderable,
- ScrollBoxRenderable,
+ type RenderContext,
+ type TextNodeOptions,
} from "@opentui/core"
+class SpanRenderable extends TextNodeRenderable {
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions) {
+ super(options)
+ }
+}
+
+export const textNodeKeys = ["Span", "B", "Strong", "I", "Em", "U", "A"] as const
+export type TextNodeKey = (typeof textNodeKeys)[number]
+
+class TextModifierRenderable extends SpanRenderable {
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions, modifier?: string) {
+ super(null, options)
+
+ // Set appropriate attributes based on modifier type
+ if (modifier === "b" || modifier === "strong") {
+ this.attributes = (this.attributes || 0) | TextAttributes.BOLD
+ } else if (modifier === "i" || modifier === "em") {
+ this.attributes = (this.attributes || 0) | TextAttributes.ITALIC
+ } else if (modifier === "u") {
+ this.attributes = (this.attributes || 0) | TextAttributes.UNDERLINE
+ }
+ }
+}
+
+export class BoldSpanRenderable extends TextModifierRenderable {
+ constructor(ctx: RenderContext | null, options: TextNodeOptions) {
+ super(ctx, options, "b")
+ }
+}
+
+export class ItalicSpanRenderable extends TextModifierRenderable {
+ constructor(ctx: RenderContext | null, options: TextNodeOptions) {
+ super(ctx, options, "i")
+ }
+}
+
+export class UnderlineSpanRenderable extends TextModifierRenderable {
+ constructor(ctx: RenderContext | null, options: TextNodeOptions) {
+ super(ctx, options, "u")
+ }
+}
+
+export class LineBreakRenderable extends SpanRenderable {
+ constructor(_ctx: RenderContext | null, options: TextNodeOptions) {
+ super(null, options)
+ this.add()
+ }
+
+ public override add(): number {
+ return super.add("\n")
+ }
+}
+
+export interface LinkOptions extends TextNodeOptions {
+ href: string
+}
+
+export class LinkRenderable extends SpanRenderable {
+ constructor(_ctx: RenderContext | null, options: LinkOptions) {
+ const linkOptions: TextNodeOptions = {
+ ...options,
+ link: { url: options.href },
+ }
+ super(null, linkOptions)
+ }
+}
+
export const elements = {
- asciiFontRenderable: ASCIIFontRenderable,
- boxRenderable: BoxRenderable,
- inputRenderable: InputRenderable,
- selectRenderable: SelectRenderable,
- tabSelectRenderable: TabSelectRenderable,
- textRenderable: TextRenderable,
- scrollBoxRenderable: ScrollBoxRenderable,
+ box: BoxRenderable,
+ Text: TextRenderable,
+ Input: InputRenderable,
+ Select: SelectRenderable,
+ Textarea: TextareaRenderable,
+ scrollbox: ScrollBoxRenderable,
+ Code: CodeRenderable,
+ diff: DiffRenderable,
+ "ascii-font": ASCIIFontRenderable,
+ "tab-select": TabSelectRenderable,
+ "line-number": LineNumberRenderable,
+ Span: SpanRenderable,
+ B: BoldSpanRenderable,
+ Strong: BoldSpanRenderable,
+ I: ItalicSpanRenderable,
+ Em: ItalicSpanRenderable,
+ U: UnderlineSpanRenderable,
+ Br: LineBreakRenderable,
+ A: LinkRenderable,
}
export type Element = keyof typeof elements
+
+export const baseComponents = {
+ box: BoxRenderable,
+ Text: TextRenderable,
+ Input: InputRenderable,
+ Select: SelectRenderable,
+ Textarea: TextareaRenderable,
+ scrollbox: ScrollBoxRenderable,
+ Code: CodeRenderable,
+ diff: DiffRenderable,
+ "ascii-font": ASCIIFontRenderable,
+ "tab-select": TabSelectRenderable,
+ "line-number": LineNumberRenderable,
+ Span: SpanRenderable,
+ B: BoldSpanRenderable,
+ Strong: BoldSpanRenderable,
+ I: ItalicSpanRenderable,
+ Em: ItalicSpanRenderable,
+ U: UnderlineSpanRenderable,
+ Br: LineBreakRenderable,
+ A: LinkRenderable,
+}
diff --git a/packages/vue/src/noOps.ts b/packages/vue/src/noOps.ts
index 2eef1ec9a..b38653d9a 100644
--- a/packages/vue/src/noOps.ts
+++ b/packages/vue/src/noOps.ts
@@ -1,9 +1,66 @@
-import { Renderable, TextRenderable } from "@opentui/core"
-import { TextNode, type OpenTUINode, ChunkToTextNodeMap } from "./nodes"
+import {
+ Renderable,
+ StyledText,
+ TextNodeRenderable,
+ TextRenderable,
+ type RenderContext,
+ type TextChunk,
+ type TextOptions,
+} from "@opentui/core"
+import { TextNode, WhiteSpaceNode, CommentNode, type OpenTUINode, ChunkToTextNodeMap } from "./nodes"
import { getNextId } from "./utils"
const GHOST_NODE_TAG = "text-ghost" as const
+export class GhostTextRenderable extends TextRenderable {
+ constructor(ctx: RenderContext, options: TextOptions) {
+ super(ctx, options)
+ }
+}
+
+function isPlaceholderChunk(chunk: TextChunk): boolean {
+ return chunk.text === "" && !ChunkToTextNodeMap.has(chunk)
+}
+
+function isEffectivelyEmptyStyledText(styledText: StyledText): boolean {
+ if (styledText.chunks.length === 0) return true
+ return styledText.chunks.length === 1 && isPlaceholderChunk(styledText.chunks[0]!)
+}
+
+function createPlaceholderStyledText(): StyledText {
+ return new StyledText([{ __isChunk: true, text: "" }])
+}
+
+function insertChunk(styledText: StyledText, chunk: TextChunk, index?: number): StyledText {
+ const chunks = styledText.chunks.slice()
+
+ if (index === undefined) {
+ chunks.push(chunk)
+ } else {
+ chunks.splice(index, 0, chunk)
+ }
+
+ return new StyledText(chunks)
+}
+
+function replaceChunk(styledText: StyledText, next: TextChunk, prev: TextChunk): StyledText {
+ const index = styledText.chunks.indexOf(prev)
+ if (index === -1) return insertChunk(styledText, next)
+
+ const chunks = styledText.chunks.slice()
+ chunks[index] = next
+ return new StyledText(chunks)
+}
+
+function removeChunk(styledText: StyledText, chunk: TextChunk): StyledText {
+ const index = styledText.chunks.indexOf(chunk)
+ if (index === -1) return styledText
+
+ const chunks = styledText.chunks.slice()
+ chunks.splice(index, 1)
+ return new StyledText(chunks)
+}
+
function getOrCreateTextGhostNode(parent: Renderable, anchor?: OpenTUINode | null): TextRenderable {
if (anchor instanceof TextNode && anchor.textParent) {
return anchor.textParent
@@ -14,22 +71,56 @@ function getOrCreateTextGhostNode(parent: Renderable, anchor?: OpenTUINode | nul
if (anchor instanceof Renderable) {
const anchorIndex = children.findIndex((el) => el.id === anchor.id)
const beforeAnchor = children[anchorIndex - 1]
- if (beforeAnchor instanceof TextRenderable && beforeAnchor.id.startsWith(GHOST_NODE_TAG)) {
+ if (beforeAnchor instanceof GhostTextRenderable) {
return beforeAnchor
}
}
const lastChild = children.at(-1)
- if (lastChild instanceof TextRenderable && lastChild.id.startsWith(GHOST_NODE_TAG)) {
+ if (lastChild instanceof GhostTextRenderable) {
return lastChild
}
- const ghostNode = new TextRenderable(parent.ctx, { id: getNextId(GHOST_NODE_TAG) })
+ const ghostNode = new GhostTextRenderable(parent.ctx, { id: getNextId(GHOST_NODE_TAG) })
insertNode(parent, ghostNode, anchor)
return ghostNode
}
function insertTextNode(parent: OpenTUINode, node: TextNode, anchor?: OpenTUINode | null): void {
+ if (parent instanceof TextNodeRenderable) {
+ const textNodeRenderable =
+ node.nodeRenderable ??
+ new TextNodeRenderable({
+ id: node.id,
+ fg: node.chunk.fg,
+ bg: node.chunk.bg,
+ attributes: node.chunk.attributes,
+ link: node.chunk.link,
+ })
+
+ if (!node.nodeRenderable) {
+ textNodeRenderable.add(node.chunk.text)
+ node.nodeRenderable = textNodeRenderable
+ }
+
+ node.parent = parent
+
+ const normalizedAnchor =
+ anchor instanceof TextNode
+ ? (anchor.nodeRenderable ?? null)
+ : anchor instanceof TextNodeRenderable
+ ? anchor
+ : null
+
+ if (normalizedAnchor) {
+ parent.insertBefore(textNodeRenderable, normalizedAnchor)
+ } else {
+ parent.add(textNodeRenderable)
+ }
+
+ return
+ }
+
if (!(parent instanceof Renderable)) {
console.warn(`[WARN] Attempted to attach text node ${node.id} to a non-renderable parent ${parent.id}.`)
return
@@ -51,13 +142,14 @@ function insertTextNode(parent: OpenTUINode, node: TextNode, anchor?: OpenTUINod
console.warn(`[WARN] TextNode anchor not found for node ${node.id}.`)
return
}
- styledText = styledText.insert(node.chunk, anchorIndex)
+ styledText = insertChunk(styledText, node.chunk, anchorIndex)
} else {
- const firstChunk = textParent.content.chunks[0]
- if (firstChunk && !ChunkToTextNodeMap.has(firstChunk)) {
- styledText = styledText.replace(node.chunk, firstChunk)
+ const chunks = textParent.content.chunks
+ const firstChunk = chunks.length > 0 ? chunks[0] : undefined
+ if (firstChunk && isPlaceholderChunk(firstChunk)) {
+ styledText = replaceChunk(styledText, node.chunk, firstChunk)
} else {
- styledText = styledText.insert(node.chunk)
+ styledText = insertChunk(styledText, node.chunk)
}
}
@@ -67,24 +159,59 @@ function insertTextNode(parent: OpenTUINode, node: TextNode, anchor?: OpenTUINod
}
function removeTextNode(parent: OpenTUINode, node: TextNode): void {
+ if (parent instanceof TextNodeRenderable) {
+ ChunkToTextNodeMap.delete(node.chunk)
+
+ if (node.nodeRenderable) {
+ try {
+ parent.remove(node.nodeRenderable.id)
+ } catch { }
+ }
+
+ node.nodeRenderable = undefined
+ return
+ }
+
if (!(parent instanceof Renderable)) {
ChunkToTextNodeMap.delete(node.chunk)
return
}
- if (parent === node.textParent && parent instanceof TextRenderable) {
+ const textParent = node.textParent
+ if (!textParent) {
ChunkToTextNodeMap.delete(node.chunk)
- parent.content = parent.content.remove(node.chunk)
- } else if (node.textParent) {
+ return
+ }
+
+ if (parent === textParent && parent instanceof TextRenderable) {
ChunkToTextNodeMap.delete(node.chunk)
- let styledText = node.textParent.content
- styledText = styledText.remove(node.chunk)
+ const next = removeChunk(parent.content, node.chunk)
- if (styledText.chunks.length > 0) {
- node.textParent.content = styledText
+ if (parent instanceof GhostTextRenderable && isEffectivelyEmptyStyledText(next)) {
+ const container = parent.parent
+ if (container) {
+ container.remove(parent.id)
+ }
+ parent.destroyRecursively()
+ node.textParent = undefined
+ return
+ }
+
+ parent.content = isEffectivelyEmptyStyledText(next) ? createPlaceholderStyledText() : next
+ } else {
+ ChunkToTextNodeMap.delete(node.chunk)
+ let styledText = textParent.content
+ styledText = removeChunk(styledText, node.chunk)
+
+ if (!isEffectivelyEmptyStyledText(styledText)) {
+ textParent.content = styledText
} else {
- node.parent?.remove(node.textParent.id)
- node.textParent.destroyRecursively()
+ const container = textParent.parent
+ if (container) {
+ container.remove(textParent.id)
+ }
+ textParent.destroyRecursively()
+ node.textParent = undefined
}
}
}
@@ -94,11 +221,46 @@ export function insertNode(parent: OpenTUINode, node: OpenTUINode, anchor?: Open
return insertTextNode(parent, node, anchor)
}
+ if (parent instanceof TextNodeRenderable) {
+ if (!(node instanceof TextNodeRenderable)) {
+ console.warn(`[WARN] Attempted to insert node ${node.id} into a text-node parent ${parent.id}.`)
+ return
+ }
+
+ const normalizedAnchor =
+ anchor instanceof TextNode
+ ? (anchor.nodeRenderable ?? null)
+ : anchor instanceof TextNodeRenderable
+ ? anchor
+ : null
+
+ if (normalizedAnchor) {
+ parent.insertBefore(node, normalizedAnchor)
+ } else {
+ parent.add(node)
+ }
+
+ return
+ }
+
if (!(parent instanceof Renderable)) {
console.warn(`[WARN] Attempted to insert node ${node.id} into a non-renderable parent ${parent.id}.`)
return
}
+ // TextRenderable.add() delegates to TextNodeRenderable.add() which only accepts
+ // strings, TextNodeRenderable instances, or StyledText instances.
+ // Skip non-compatible nodes like WhiteSpaceNode and CommentNode.
+ if (parent instanceof TextRenderable) {
+ if (node instanceof WhiteSpaceNode || node instanceof CommentNode) {
+ return
+ }
+ if (!(node instanceof TextNodeRenderable)) {
+ console.warn(`[WARN] Attempted to insert non-text node ${node.id} into TextRenderable ${parent.id}.`)
+ return
+ }
+ }
+
if (anchor) {
const anchorIndex = parent.getChildren().findIndex((el) => {
if (anchor instanceof TextNode) {
@@ -117,6 +279,13 @@ export function removeNode(parent: OpenTUINode, node: OpenTUINode): void {
return removeTextNode(parent, node)
}
+ if (parent instanceof TextNodeRenderable && node instanceof TextNodeRenderable) {
+ try {
+ parent.remove(node.id)
+ } catch { }
+ return
+ }
+
if (parent instanceof Renderable && node instanceof Renderable) {
parent.remove(node.id)
node.destroyRecursively()
diff --git a/packages/vue/src/nodes.ts b/packages/vue/src/nodes.ts
index 4deb8dcbd..830204853 100644
--- a/packages/vue/src/nodes.ts
+++ b/packages/vue/src/nodes.ts
@@ -1,4 +1,12 @@
-import { Renderable, TextRenderable, RootRenderable, type TextChunk, type CliRenderer } from "@opentui/core"
+import {
+ BaseRenderable,
+ Renderable,
+ RootRenderable,
+ TextNodeRenderable,
+ TextRenderable,
+ type CliRenderer,
+ type TextChunk,
+} from "@opentui/core"
import { getNextId } from "./utils"
import type { elements } from "./elements"
@@ -7,8 +15,9 @@ export const ChunkToTextNodeMap = new WeakMap()
export class TextNode {
id: string
chunk: TextChunk
- parent?: Renderable
+ parent?: BaseRenderable
textParent?: TextRenderable
+ nodeRenderable?: TextNodeRenderable
constructor(chunk: TextChunk) {
this.id = getNextId("text-node")
@@ -22,6 +31,12 @@ export class WhiteSpaceNode extends Renderable {
}
}
-export type OpenTUINode = Renderable | TextNode
+export class CommentNode extends Renderable {
+ constructor(cliRenderer: CliRenderer) {
+ super(cliRenderer, { id: getNextId("comment"), visible: false, width: 0, height: 0 })
+ }
+}
+
+export type OpenTUINode = BaseRenderable | TextNode
type ElementConstructor = (typeof elements)[keyof typeof elements]
export type OpenTUIElement = InstanceType | RootRenderable
diff --git a/packages/vue/src/renderer.ts b/packages/vue/src/renderer.ts
index ac45259ef..8110687b2 100644
--- a/packages/vue/src/renderer.ts
+++ b/packages/vue/src/renderer.ts
@@ -7,13 +7,22 @@ import {
TabSelectRenderable,
TabSelectRenderableEvents,
TextRenderable,
+ TextareaRenderable,
StyledText,
+ TextNodeRenderable,
type TextChunk,
Renderable,
type CliRenderer,
} from "@opentui/core"
import { getNextId } from "./utils"
-import { type OpenTUINode, type OpenTUIElement, TextNode, WhiteSpaceNode, ChunkToTextNodeMap } from "./nodes"
+import {
+ type OpenTUINode,
+ type OpenTUIElement,
+ CommentNode,
+ TextNode,
+ WhiteSpaceNode,
+ ChunkToTextNodeMap,
+} from "./nodes"
import { elements, type Element } from "./elements"
import { insertNode, removeNode } from "./noOps"
@@ -29,22 +38,27 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
typeof value === "object" && "__isChunk" in value
? value
: {
- __isChunk: true,
- text: `${value}`,
- }
+ __isChunk: true,
+ text: `${value}`,
+ }
const textNode = new TextNode(chunk)
ChunkToTextNodeMap.set(chunk, textNode)
return textNode
}
return createRenderer({
- createElement(type: string, _isSVG: undefined, _anchor: any, props) {
+ createElement(
+ type: string,
+ _namespace?: string,
+ _isCustomizedBuiltIn?: string,
+ vnodeProps?: Record | null,
+ ) {
const RenderableClass = elements[type as Element]
if (!RenderableClass) throw new Error(`${type} is not a valid element`)
const id = getNextId(type)
//we don't pass content directly, we handle it in patchProp
- const { style = {}, content, ...options } = props || {}
+ const { style = {}, content, ...options } = vnodeProps || {}
return new RenderableClass(cliRenderer, { id, ...style, ...options })
},
@@ -65,10 +79,12 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
switch (key) {
case "focused":
- if (nextValue) {
- el.focus()
- } else {
- el.blur()
+ if (el instanceof Renderable) {
+ if (nextValue) {
+ el.focus()
+ } else {
+ el.blur()
+ }
}
break
@@ -92,9 +108,11 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
break
case "onSelect":
- let selectEvent: SelectRenderableEvents.ITEM_SELECTED | undefined = undefined
+ let selectEvent: string | undefined = undefined
if (el instanceof SelectRenderable) {
selectEvent = SelectRenderableEvents.ITEM_SELECTED
+ } else if (el instanceof TabSelectRenderable) {
+ selectEvent = TabSelectRenderableEvents.ITEM_SELECTED
}
if (selectEvent) {
if (prevValue) {
@@ -125,6 +143,26 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
if (nextValue) {
el.on(InputRenderableEvents.ENTER, nextValue)
}
+ } else if (el instanceof TextareaRenderable) {
+ el.onSubmit = nextValue
+ }
+ break
+
+ case "onKeyDown":
+ if (el instanceof Renderable) {
+ el.onKeyDown = nextValue
+ }
+ break
+
+ case "onContentChange":
+ if (el instanceof TextareaRenderable) {
+ el.onContentChange = nextValue
+ }
+ break
+
+ case "onCursorChange":
+ if (el instanceof TextareaRenderable) {
+ el.onCursorChange = nextValue
}
break
@@ -190,6 +228,8 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
},
remove(el) {
+ if (!el) return
+
const parent = el.parent
if (parent) {
removeNode(parent, el)
@@ -211,6 +251,12 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
setText(node, text) {
if (node instanceof TextNode) {
+ if (node.nodeRenderable) {
+ node.nodeRenderable.children = [text]
+ node.nodeRenderable.requestRender()
+ return
+ }
+
const textParent = node.textParent
if (textParent instanceof TextRenderable) {
textParent.content = text
@@ -222,14 +268,37 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
parentNode: (node) => node.parent! as OpenTUIElement,
nextSibling(node) {
+ if (!node) return null
+
const parent = node.parent
if (!parent) return null
- if (node instanceof TextNode && parent instanceof TextRenderable) {
- const siblings = parent.content.chunks
- const index = siblings.indexOf(node.chunk)
- const nextChunk = siblings[index + 1]
- return nextChunk ? ChunkToTextNodeMap.get(nextChunk) || null : null
+ if (node instanceof TextNode) {
+ if (parent instanceof TextNodeRenderable && node.nodeRenderable) {
+ const siblings = parent.getChildren()
+ const index = siblings.findIndex((child) => child.id === node.nodeRenderable?.id)
+ return siblings[index + 1] || null
+ }
+
+ const textParent = node.textParent
+
+ if (textParent instanceof TextRenderable) {
+ const chunks = textParent.content.chunks
+ const index = chunks.indexOf(node.chunk)
+ const nextChunk = chunks[index + 1]
+ if (nextChunk) {
+ return ChunkToTextNodeMap.get(nextChunk) || null
+ }
+
+ const container = textParent.parent
+ if (!container) return null
+
+ const siblings = container.getChildren()
+ const textParentIndex = siblings.findIndex((child) => child.id === textParent.id)
+ return siblings[textParentIndex + 1] || null
+ }
+
+ return null
}
const siblings = parent.getChildren()
@@ -248,7 +317,6 @@ export function createOpenTUIRenderer(cliRenderer: CliRenderer) {
return cloned
},
- //@ts-expect-error : we don't do anything we comments
- createComment: () => null,
+ createComment: () => new CommentNode(cliRenderer),
})
}
diff --git a/packages/vue/src/resolver.ts b/packages/vue/src/resolver.ts
new file mode 100644
index 000000000..9dd3c28e8
--- /dev/null
+++ b/packages/vue/src/resolver.ts
@@ -0,0 +1,49 @@
+export interface ComponentResolverResult {
+ name: string
+ from: string
+}
+
+export interface ComponentResolver {
+ type: "component"
+ resolve: (name: string) => ComponentResolverResult | undefined
+}
+
+export function OpenTUIResolver(): ComponentResolver {
+ const componentNames = new Set([
+ "box",
+ "Text",
+ "Input",
+ "Select",
+ "Textarea",
+ "scrollbox",
+ "Code",
+ "diff",
+ "ascii-font",
+ "tab-select",
+ "line-number",
+ "Span",
+ "B",
+ "Strong",
+ "I",
+ "Em",
+ "U",
+ "Br",
+ "A",
+ ])
+
+ return {
+ type: "component",
+ resolve: (name: string): ComponentResolverResult | undefined => {
+ if (componentNames.has(name)) {
+ return {
+ name,
+ from: "@opentui/vue",
+ }
+ }
+
+ return undefined
+ },
+ }
+}
+
+export default OpenTUIResolver
diff --git a/packages/vue/src/test-utils.ts b/packages/vue/src/test-utils.ts
new file mode 100644
index 000000000..aa952068f
--- /dev/null
+++ b/packages/vue/src/test-utils.ts
@@ -0,0 +1,63 @@
+import { nextTick, type Component } from "vue"
+import { createTestRenderer, type TestRendererOptions } from "@opentui/core/testing"
+import { createOpenTUIRenderer } from "./renderer"
+import { cliRendererKey } from "../index"
+
+export async function testRender(component: Component, renderConfig: TestRendererOptions = {}) {
+ const userOnDestroy = renderConfig.onDestroy
+ const testSetup = await createTestRenderer({
+ useThread: false,
+ ...renderConfig,
+ onDestroy: undefined,
+ })
+
+ const baseRenderOnce = testSetup.renderOnce
+ const renderOnce = async () => {
+ await nextTick()
+ await baseRenderOnce()
+ }
+
+ const baseMockInput = testSetup.mockInput
+ const mockInput = {
+ ...baseMockInput,
+ pressKeys: async (...args: Parameters<(typeof baseMockInput)["pressKeys"]>) => {
+ await nextTick()
+ return baseMockInput.pressKeys(...args)
+ },
+ typeText: async (...args: Parameters<(typeof baseMockInput)["typeText"]>) => {
+ await nextTick()
+ return baseMockInput.typeText(...args)
+ },
+ pasteBracketedText: async (...args: Parameters<(typeof baseMockInput)["pasteBracketedText"]>) => {
+ await nextTick()
+ return baseMockInput.pasteBracketedText(...args)
+ },
+ }
+
+ const renderer = createOpenTUIRenderer(testSetup.renderer)
+ const app = renderer.createApp(component)
+ app.provide(cliRendererKey, testSetup.renderer)
+ app.mount(testSetup.renderer.root)
+
+ let didUnmount = false
+ const originalDestroy = testSetup.renderer.destroy.bind(testSetup.renderer)
+ testSetup.renderer.destroy = () => {
+ if (!didUnmount) {
+ didUnmount = true
+ try {
+ app.unmount()
+ } catch {}
+ try {
+ userOnDestroy?.()
+ } catch {}
+ }
+
+ return originalDestroy()
+ }
+
+ return {
+ ...testSetup,
+ renderOnce,
+ mockInput,
+ }
+}
diff --git a/packages/vue/src/utils/createContext.ts b/packages/vue/src/utils/createContext.ts
new file mode 100644
index 000000000..252c4ca66
--- /dev/null
+++ b/packages/vue/src/utils/createContext.ts
@@ -0,0 +1,40 @@
+import { provide, inject, type InjectionKey } from "vue"
+
+/**
+ * Create a typed context provider/injector pair.
+ * Inspired by Solid's createContextProvider pattern.
+ *
+ * @example
+ * ```ts
+ * const [injectMyContext, provideMyContext] = createContext('MyContext')
+ *
+ * // In parent component
+ * provideMyContext({ value: 42 })
+ *
+ * // In child component
+ * const ctx = injectMyContext()
+ * ```
+ */
+export function createContext(
+ contextName: string,
+ defaultValue?: T,
+): [injectContext: (fallback?: T) => T, provideContext: (value: T) => T] {
+ const key: InjectionKey = Symbol(contextName)
+
+ const injectContext = (fallback?: T): T => {
+ const context = inject(key, fallback ?? defaultValue)
+ if (context === undefined) {
+ throw new Error(
+ `[OpenTUI] ${contextName} context not found. ` + `Make sure to wrap your component with a provider.`,
+ )
+ }
+ return context
+ }
+
+ const provideContext = (value: T): T => {
+ provide(key, value)
+ return value
+ }
+
+ return [injectContext, provideContext]
+}
diff --git a/packages/vue/src/utils/index.ts b/packages/vue/src/utils/index.ts
new file mode 100644
index 000000000..38e61c3e7
--- /dev/null
+++ b/packages/vue/src/utils/index.ts
@@ -0,0 +1 @@
+export { createContext } from "./createContext"
diff --git a/packages/vue/tests/control-flow.test.ts b/packages/vue/tests/control-flow.test.ts
new file mode 100644
index 000000000..d32fd835b
--- /dev/null
+++ b/packages/vue/tests/control-flow.test.ts
@@ -0,0 +1,1016 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref, nextTick, onErrorCaptured, Fragment } from "vue"
+import { testRender } from "../src/test-utils"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Control Flow Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ describe("List Rendering", () => {
+ it("should render items with .map()", async () => {
+ const items = ["First", "Second", "Third"]
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.map((item, index) => h("Text", { key: item }, `${index + 1}. ${item}`)),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+
+ expect(frame).toContain("1. First")
+ expect(frame).toContain("2. Second")
+ expect(frame).toContain("3. Third")
+
+ const children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(3)
+ })
+
+ it("should handle reactive array updates", async () => {
+ const items = ref(["A", "B"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.value.map((item) => h("Text", { key: item }, `Item: ${item}`)),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(2)
+
+ items.value = ["A", "B", "C", "D"]
+ await nextTick()
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(4)
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Item: A")
+ expect(frame).toContain("Item: D")
+ })
+
+ it("should handle empty arrays", async () => {
+ const items = ref(["Item"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.value.map((item) => h("Text", { key: item }, item)),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(1)
+
+ items.value = []
+ await nextTick()
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(0)
+ })
+
+ it("should handle complex objects in arrays", async () => {
+ const todos = ref([
+ { id: 1, text: "Learn Vue", done: false },
+ { id: 2, text: "Build TUI", done: true },
+ ])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ todos.value.map((todo, index) =>
+ h("Text", { key: todo.id }, `${index + 1}. ${todo.done ? "[x]" : "[ ]"} ${todo.text}`),
+ ),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 10 })
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+
+ expect(frame).toContain("1. [ ] Learn Vue")
+ expect(frame).toContain("2. [x] Build TUI")
+ })
+
+ it("should handle array item removal", async () => {
+ const items = ref(["A", "B", "C", "D"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.value.map((item) => h("Text", { key: item }, `Item: ${item}`)),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(4)
+
+ items.value = ["A", "D"]
+ await nextTick()
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(2)
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Item: A")
+ expect(frame).toContain("Item: D")
+ expect(frame).not.toContain("Item: B")
+ expect(frame).not.toContain("Item: C")
+ })
+
+ it("should handle array reordering", async () => {
+ const items = ref(["First", "Second", "Third"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ { id: "container" },
+ items.value.map((item) =>
+ h("box", { key: item, id: `item-${item}` }, [h("Text", {}, item)]),
+ ),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ const container = testSetup.renderer.root.findDescendantById("container")!
+ let children = container.getChildren()
+ expect(children[0]?.id).toBe("item-First")
+ expect(children[1]?.id).toBe("item-Second")
+ expect(children[2]?.id).toBe("item-Third")
+
+ items.value = ["Third", "Second", "First"]
+ await nextTick()
+ await testSetup.renderOnce()
+
+ children = container.getChildren()
+ expect(children[0]?.id).toBe("item-Third")
+ expect(children[1]?.id).toBe("item-Second")
+ expect(children[2]?.id).toBe("item-First")
+ })
+ })
+
+ describe("Conditional Rendering", () => {
+ it("should conditionally render content with ternary operator", async () => {
+ const showContent = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ showContent.value ? h("Text", {}, "Main content") : h("Text", {}, "Fallback content"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Main content")
+ expect(frame).not.toContain("Fallback content")
+
+ showContent.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Fallback content")
+ expect(frame).not.toContain("Main content")
+ })
+
+ it("should handle && operator for conditional rendering", async () => {
+ const visible = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ [
+ visible.value && h("Text", {}, "Visible content"),
+ h("Text", {}, "Always visible"),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 8 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Visible content")
+ expect(frame).toContain("Always visible")
+
+ visible.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).not.toContain("Visible content")
+ expect(frame).toContain("Always visible")
+ })
+
+ it("should handle reactive condition changes", async () => {
+ const count = ref(5)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ count.value > 3
+ ? h("Text", {}, `Count is high: ${count.value}`)
+ : h("Text", {}, "Count too low"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 25, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Count is high: 5")
+
+ count.value = 2
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Count too low")
+ expect(frame).not.toContain("Count is high")
+ })
+
+ it("should verify correct ordering when conditionally rendering", async () => {
+ const showContent = ref(false)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ { id: "container" },
+ [
+ h("box", { id: "first" }),
+ showContent.value ? h("box", { id: "second" }) : null,
+ h("box", { id: "third" }),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ showContent.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ const container = testSetup.renderer.root.findDescendantById("container")!
+ const children = container.getChildren()
+
+ expect(children.length).toBe(3)
+ expect(children[0]!.id).toBe("first")
+ expect(children[1]!.id).toBe("second")
+ expect(children[2]!.id).toBe("third")
+ })
+
+ it("should conditionally render content in fragment with correct order", async () => {
+ const showContent = ref(false)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ { id: "fragment-container" },
+ [
+ h("box", { id: "first" }),
+ showContent.value ? h("box", { id: "second" }) : null,
+ h("box", { id: "third" }),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ showContent.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ const container = testSetup.renderer.root.findDescendantById("fragment-container")!
+ const children = container.getChildren()
+
+ expect(children.length).toBe(3)
+ expect(children[0]!.id).toBe("first")
+ expect(children[1]!.id).toBe("second")
+ expect(children[2]!.id).toBe("third")
+ })
+
+ it("should handle null/undefined in children array", async () => {
+ const showFirst = ref(true)
+ const showSecond = ref(false)
+ const showThird = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ [
+ showFirst.value ? h("Text", {}, "First") : null,
+ showSecond.value ? h("Text", {}, "Second") : undefined,
+ showThird.value ? h("Text", {}, "Third") : null,
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 8 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("First")
+ expect(frame).not.toContain("Second")
+ expect(frame).toContain("Third")
+
+ showFirst.value = false
+ showSecond.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).not.toContain("First")
+ expect(frame).toContain("Second")
+ expect(frame).toContain("Third")
+ })
+ })
+
+ describe("Switch/Match Equivalent", () => {
+ it("should render based on value matching using switch statement", async () => {
+ const value = ref("option1")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ (() => {
+ switch (value.value) {
+ case "option1":
+ return h("Text", {}, "Option 1 selected")
+ case "option2":
+ return h("Text", {}, "Option 2 selected")
+ default:
+ return h("Text", {}, "No match")
+ }
+ })(),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 25, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Option 1 selected")
+ expect(frame).not.toContain("Option 2 selected")
+
+ value.value = "option2"
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Option 2 selected")
+ expect(frame).not.toContain("Option 1 selected")
+
+ value.value = "unknown"
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("No match")
+ })
+
+ it("should handle reactive conditions with if/else chains", async () => {
+ const score = ref(85)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ (() => {
+ if (score.value >= 90) {
+ return h("Text", {}, "Grade: A")
+ } else if (score.value >= 80) {
+ return h("Text", {}, "Grade: B")
+ } else if (score.value >= 70) {
+ return h("Text", {}, "Grade: C")
+ } else {
+ return h("Text", {}, "Grade: F")
+ }
+ })(),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 15, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Grade: B")
+
+ score.value = 95
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Grade: A")
+
+ score.value = 65
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Grade: F")
+ })
+
+ it("should handle object-based matching pattern", async () => {
+ type Status = "loading" | "success" | "error"
+ const status = ref("loading")
+
+ const renderStatus = (s: Status) => {
+ const renderers: Record ReturnType> = {
+ loading: () => h("Text", {}, "Loading..."),
+ success: () => h("Text", {}, "Success!"),
+ error: () => h("Text", {}, "Error occurred"),
+ }
+ return renderers[s]()
+ }
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () => h("box", {}, [renderStatus(status.value)])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Loading...")
+
+ status.value = "success"
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Success!")
+
+ status.value = "error"
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Error occurred")
+ })
+ })
+
+ describe("Error Handling", () => {
+ it("should catch and handle errors with onErrorCaptured", async () => {
+ const shouldError = ref(false)
+ const errorMessage = ref("")
+
+ const ErrorComponent = defineComponent({
+ props: {
+ shouldError: { type: Boolean, default: false },
+ },
+ setup(props) {
+ return () => {
+ if (props.shouldError) {
+ throw new Error("Test error")
+ }
+ return h("Text", {}, "Normal content")
+ }
+ },
+ })
+
+ const TestComponent = defineComponent({
+ setup() {
+ onErrorCaptured((err: Error) => {
+ errorMessage.value = err.message
+ return false
+ })
+
+ return () =>
+ h("box", {}, [
+ errorMessage.value
+ ? h("Text", {}, `Error caught: ${errorMessage.value}`)
+ : h(ErrorComponent, { shouldError: shouldError.value }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Normal content")
+ expect(frame).not.toContain("Error caught")
+
+ shouldError.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Error caught: Test error")
+ expect(frame).not.toContain("Normal content")
+ })
+ })
+
+ describe("Combined Control Flow", () => {
+ it("should handle list inside conditional", async () => {
+ const showList = ref(true)
+ const items = ref(["A", "B", "C"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ showList.value
+ ? items.value.map((item) => h("Text", { key: item }, `Item: ${item}`))
+ : [h("Text", {}, "List is hidden")],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(3)
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Item: A")
+ expect(frame).toContain("Item: C")
+
+ showList.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(1)
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("List is hidden")
+ expect(frame).not.toContain("Item: A")
+ })
+
+ it("should handle conditional inside list (filtering)", async () => {
+ const items = ["A", "B", "C", "D"]
+ const visibleItems = ref(new Set(["A", "C"]))
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items
+ .filter((item) => visibleItems.value.has(item))
+ .map((item) => h("Text", { key: item }, `Item: ${item}`)),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Item: A")
+ expect(frame).toContain("Item: C")
+ expect(frame).not.toContain("Item: B")
+ expect(frame).not.toContain("Item: D")
+
+ visibleItems.value = new Set(["B", "D"])
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Item: B")
+ expect(frame).toContain("Item: D")
+ expect(frame).not.toContain("Item: A")
+ expect(frame).not.toContain("Item: C")
+ })
+
+ it("should handle nested conditionals", async () => {
+ const showOuter = ref(true)
+ const showInner = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ showOuter.value
+ ? h("box", {}, [
+ h("Text", {}, "Outer content"),
+ showInner.value
+ ? h("Text", {}, "Inner content")
+ : h("Text", {}, "Inner hidden"),
+ ])
+ : h("Text", {}, "Outer hidden"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Outer content")
+ expect(frame).toContain("Inner content")
+
+ showInner.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Outer content")
+ expect(frame).toContain("Inner hidden")
+ expect(frame).not.toContain("Inner content")
+
+ showOuter.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Outer hidden")
+ expect(frame).not.toContain("Outer content")
+ })
+
+ it("should handle switch with list inside matches", async () => {
+ const mode = ref<"list" | "grid">("list")
+ const items = ["One", "Two", "Three"]
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ (() => {
+ switch (mode.value) {
+ case "list":
+ return h(
+ Fragment,
+ items.map((item) => h("Text", { key: `list-${item}` }, `* ${item}`)),
+ )
+ case "grid":
+ return h(
+ Fragment,
+ items.map((item) => h("Text", { key: `grid-${item}` }, `[${item}]`)),
+ )
+ default:
+ return h("Text", {}, "Unknown mode")
+ }
+ })(),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 25, height: 10 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("* One")
+ expect(frame).toContain("* Two")
+ expect(frame).toContain("* Three")
+
+ mode.value = "grid"
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("[One]")
+ expect(frame).toContain("[Two]")
+ expect(frame).toContain("[Three]")
+ expect(frame).not.toContain("* One")
+ })
+
+ it("should handle complex dynamic data with mixed control flow", async () => {
+ interface Item {
+ id: string
+ name: string
+ visible: boolean
+ children?: string[]
+ }
+
+ const items = ref- ([
+ { id: "1", name: "Parent 1", visible: true, children: ["Child A", "Child B"] },
+ { id: "2", name: "Parent 2", visible: false },
+ { id: "3", name: "Parent 3", visible: true, children: ["Child C"] },
+ ])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.value
+ .filter((item) => item.visible)
+ .map((item) =>
+ h("box", { key: item.id }, [
+ h("Text", {}, `Name: ${item.name}`),
+ ...(item.children
+ ? item.children.map((child) => h("Text", { key: child }, ` - ${child}`))
+ : []),
+ ]),
+ ),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 25, height: 15 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Name: Parent 1")
+ expect(frame).toContain("- Child A")
+ expect(frame).toContain("- Child B")
+ expect(frame).not.toContain("Name: Parent 2")
+ expect(frame).toContain("Name: Parent 3")
+ expect(frame).toContain("- Child C")
+
+ items.value = [
+ { id: "1", name: "Parent 1", visible: false },
+ { id: "2", name: "Parent 2", visible: true, children: ["Child D"] },
+ { id: "3", name: "Parent 3", visible: true, children: ["Child C", "Child E"] },
+ ]
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).not.toContain("Name: Parent 1")
+ expect(frame).toContain("Name: Parent 2")
+ expect(frame).toContain("- Child D")
+ expect(frame).toContain("- Child E")
+ })
+
+ it("should find descendants by id through conditional renderables", async () => {
+ const showContent = ref(false)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ { id: "parent-box" },
+ [
+ h("box", { id: "always-visible", style: { border: true }, title: "Always" }),
+ showContent.value
+ ? h("box", { id: "conditional-child", style: { border: true }, title: "Conditional" }, [
+ h("box", { id: "nested-child", style: { border: true }, title: "Nested" }),
+ ])
+ : null,
+ h("box", { id: "another-visible", style: { border: true }, title: "Another" }),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 15 })
+ await testSetup.renderOnce()
+
+ const parentBox = testSetup.renderer.root.findDescendantById("parent-box")
+ expect(parentBox).toBeDefined()
+
+ const anotherVisible = parentBox?.findDescendantById("another-visible")
+ expect(anotherVisible).toBeDefined()
+ expect(anotherVisible?.id).toBe("another-visible")
+ })
+
+ it("should handle rapid reactive updates", async () => {
+ const count = ref(0)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ count.value % 2 === 0
+ ? h("Text", {}, `Even: ${count.value}`)
+ : h("Text", {}, `Odd: ${count.value}`),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ for (let i = 0; i < 10; i++) {
+ count.value = i
+ await nextTick()
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ if (i % 2 === 0) {
+ expect(frame).toContain(`Even: ${i}`)
+ } else {
+ expect(frame).toContain(`Odd: ${i}`)
+ }
+ }
+ })
+
+ it("should handle conditional text elements", async () => {
+ const showExtra = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ [
+ h("Text", {}, "Base text"),
+ showExtra.value ? h("Text", { style: { fg: "red" } }, "Extra text") : null,
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 5 })
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Base text")
+ expect(frame).toContain("Extra text")
+
+ showExtra.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Base text")
+ expect(frame).not.toContain("Extra text")
+ })
+ })
+
+ describe("Edge Cases", () => {
+ it("should handle empty conditional blocks", async () => {
+ const show = ref(false)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ [
+ h("Text", {}, "Before"),
+ show.value ? h("Text", {}, "Conditional") : null,
+ h("Text", {}, "After"),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Before")
+ expect(frame).toContain("After")
+ expect(frame).not.toContain("Conditional")
+
+ const children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(2)
+ })
+
+ it("should handle deeply nested lists", async () => {
+ const data = ref([
+ {
+ id: "1",
+ items: [
+ { id: "1-1", values: ["a", "b"] },
+ { id: "1-2", values: ["c"] },
+ ],
+ },
+ { id: "2", items: [{ id: "2-1", values: ["d", "e", "f"] }] },
+ ])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ data.value.map((group) =>
+ h(
+ "box",
+ { key: group.id },
+ group.items.map((item) =>
+ h(
+ "box",
+ { key: item.id },
+ item.values.map((value) => h("Text", { key: value }, value)),
+ ),
+ ),
+ ),
+ ),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 15 })
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("a")
+ expect(frame).toContain("b")
+ expect(frame).toContain("c")
+ expect(frame).toContain("d")
+ expect(frame).toContain("e")
+ expect(frame).toContain("f")
+ })
+
+ it("should handle boolean false in array (not rendered)", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ [
+ h("Text", {}, "First"),
+ false,
+ h("Text", {}, "Second"),
+ null,
+ h("Text", {}, "Third"),
+ ].filter(Boolean),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+ await testSetup.renderOnce()
+
+ const children = testSetup.renderer.root.getChildren()[0]!.getChildren()
+ expect(children.length).toBe(3)
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("First")
+ expect(frame).toContain("Second")
+ expect(frame).toContain("Third")
+ })
+ })
+})
diff --git a/packages/vue/tests/devtools-hook.test.ts b/packages/vue/tests/devtools-hook.test.ts
new file mode 100644
index 000000000..ef0b3a3fb
--- /dev/null
+++ b/packages/vue/tests/devtools-hook.test.ts
@@ -0,0 +1,106 @@
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"
+import { defineComponent, h, nextTick, ref } from "vue"
+import { testRender } from "../src/test-utils"
+import { initDevtoolsGlobalHook } from "../src/devtools/connect"
+
+type DevtoolsHook = {
+ emit: (event: string, ...args: unknown[]) => void
+ apps?: unknown[]
+}
+
+function getGlobalDevtoolsHook(): DevtoolsHook {
+ const hook = (globalThis as Record).__VUE_DEVTOOLS_GLOBAL_HOOK__ as DevtoolsHook | undefined
+ if (!hook) throw new Error("Expected __VUE_DEVTOOLS_GLOBAL_HOOK__ to be set")
+ return hook
+}
+
+async function withHiddenGlobalDevtoolsHook(fn: () => Promise): Promise {
+ const target = globalThis as Record
+ const original = Object.getOwnPropertyDescriptor(target, "__VUE_DEVTOOLS_GLOBAL_HOOK__")
+
+ Object.defineProperty(target, "__VUE_DEVTOOLS_GLOBAL_HOOK__", {
+ configurable: true,
+ get: () => undefined,
+ })
+
+ try {
+ return await fn()
+ } finally {
+ if (original) {
+ Object.defineProperty(target, "__VUE_DEVTOOLS_GLOBAL_HOOK__", original)
+ } else {
+ delete target.__VUE_DEVTOOLS_GLOBAL_HOOK__
+ }
+ }
+}
+
+describe("DevTools integration | hook timing", () => {
+ let testSetup: Awaited> | null = null
+
+ beforeAll(async () => {
+ await initDevtoolsGlobalHook()
+ })
+
+ afterEach(() => {
+ if (testSetup) testSetup.renderer.destroy()
+ testSetup = null
+ })
+
+ afterAll(() => {
+ // Keep the global hook installed to avoid async unmount handlers throwing.
+ })
+
+ it("emits component updates when DevTools hook exists before renderer creation", async () => {
+ const hook = getGlobalDevtoolsHook()
+
+ const calls: Array<{ event: string; args: unknown[] }> = []
+ const originalEmit = hook.emit
+ hook.emit = (event, ...args) => {
+ calls.push({ event, args })
+ originalEmit.call(hook, event, ...args)
+ }
+
+ const count = ref(0)
+ const TestComponent = defineComponent({
+ setup() {
+ return () => h("Text", { content: `count:${count.value}` })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 3 })
+
+ calls.length = 0
+ count.value++
+ await testSetup.renderOnce()
+
+ expect(calls.some((c) => c.event === "component:updated")).toBe(true)
+
+ hook.emit = originalEmit
+ })
+
+ it("does not emit component updates if DevTools is initialized after renderer creation", async () => {
+ const count = ref(0)
+ const TestComponent = defineComponent({
+ setup() {
+ return () => h("Text", { content: `count:${count.value}` })
+ },
+ })
+
+ testSetup = await withHiddenGlobalDevtoolsHook(() => testRender(TestComponent, { width: 20, height: 3 }))
+ const hook = getGlobalDevtoolsHook()
+
+ const calls: Array<{ event: string; args: unknown[] }> = []
+ const originalEmit = hook.emit
+ hook.emit = (event, ...args) => {
+ calls.push({ event, args })
+ originalEmit.call(hook, event, ...args)
+ }
+
+ count.value++
+ await nextTick()
+
+ expect(calls.some((c) => c.event === "component:updated")).toBe(false)
+
+ hook.emit = originalEmit
+ })
+})
diff --git a/packages/vue/tests/diff.test.ts b/packages/vue/tests/diff.test.ts
new file mode 100644
index 000000000..01e0ca325
--- /dev/null
+++ b/packages/vue/tests/diff.test.ts
@@ -0,0 +1,474 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref, nextTick } from "vue"
+import { testRender } from "../src/test-utils"
+import { SyntaxStyle, RGBA } from "@opentui/core"
+
+let testSetup: Awaited>
+
+describe("DiffRenderable with Vue", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ it("renders unified diff without glitching", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ function: { fg: RGBA.fromValues(0.51, 0.67, 1, 1) },
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
+ })
+
+ const diffContent = `--- a/test.js
++++ b/test.js
+@@ -1,7 +1,11 @@
+ function add(a, b) {
+ return a + b;
+ }
+
++function subtract(a, b) {
++ return a - b;
++}
++
+ function multiply(a, b) {
+- return a * b;
++ return a * b * 1;
+ }`
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ h("diff", {
+ id: "test-diff",
+ diff: diffContent,
+ view: "unified",
+ filetype: "javascript",
+ syntaxStyle,
+ showLineNumbers: true,
+ width: "100%",
+ height: "100%",
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ // Wait for automatic initial render
+ await Bun.sleep(50)
+
+ const box = testSetup.renderer.root.getRenderable("root")
+ const diff = box?.getRenderable("test-diff") as any
+ const leftSide = diff?.getRenderable("test-diff-left") as any
+ const gutterAfterAutoRender = leftSide?.["gutter"]
+ const widthAfterAutoRender = gutterAfterAutoRender?.width
+
+ // First explicit render
+ await testSetup.renderOnce()
+ const firstFrame = testSetup.captureCharFrame()
+ const widthAfterFirst = leftSide?.["gutter"]?.width
+
+ // Second render to check stability
+ await testSetup.renderOnce()
+ const secondFrame = testSetup.captureCharFrame()
+ const widthAfterSecond = leftSide?.["gutter"]?.width
+
+ // EXPECTATION: No width glitch - width should be correct from auto render
+ expect(widthAfterAutoRender).toBeDefined()
+ expect(widthAfterFirst).toBeDefined()
+ expect(widthAfterSecond).toBeDefined()
+ expect(widthAfterAutoRender).toBe(widthAfterFirst)
+ expect(widthAfterFirst).toBe(widthAfterSecond)
+ expect(widthAfterFirst!).toBeGreaterThan(0)
+
+ // Frames should be identical (no visual changes)
+ expect(firstFrame).toBe(secondFrame)
+
+ // Check content is present
+ expect(firstFrame).toContain("function add")
+ expect(firstFrame).toContain("function subtract")
+ expect(firstFrame).toContain("function multiply")
+
+ // Check for diff markers
+ expect(firstFrame).toContain("+")
+ expect(firstFrame).toContain("-")
+ })
+
+ it("renders split diff correctly", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ function: { fg: RGBA.fromValues(0.51, 0.67, 1, 1) },
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
+ })
+
+ const diffContent = `--- a/test.js
++++ b/test.js
+@@ -1,3 +1,3 @@
+ function hello() {
+- console.log("Hello");
++ console.log("Hello, World!");
+ }`
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ h("diff", {
+ id: "test-diff",
+ diff: diffContent,
+ view: "split",
+ filetype: "javascript",
+ syntaxStyle,
+ showLineNumbers: true,
+ width: "100%",
+ height: "100%",
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+
+ // Both sides should be visible
+ expect(frame).toContain("function hello")
+ expect(frame).toContain("console.log")
+ expect(frame).toContain("Hello")
+ })
+
+ it("handles double-digit line numbers with proper left padding", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
+ })
+
+ const diffWith10PlusLines = `--- a/test.js
++++ b/test.js
+@@ -8,10 +8,12 @@
+ line8
+ line9
+ line10
+-line11_old
++line11_new
+ line12
++line13_added
++line14_added
+ line15
+ line16
+-line17_old
++line17_new
+ line18
+ line19`
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ h("diff", {
+ id: "test-diff",
+ diff: diffWith10PlusLines,
+ view: "unified",
+ syntaxStyle,
+ showLineNumbers: true,
+ width: "100%",
+ height: "100%",
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ const frameLines = frame.split("\n")
+
+ // Find lines with single and double digit numbers
+ const line8 = frameLines.find((l) => l.includes("line8"))
+ const line10 = frameLines.find((l) => l.includes("line10"))
+ const line16 = frameLines.find((l) => l.includes("line16"))
+
+ // All lines should have proper left padding
+ if (!line8 || !line10 || !line16) {
+ throw new Error("Expected lines not found in output")
+ }
+
+ // Verify proper left padding for single-digit line numbers
+ const line8Match = line8.match(/^( +)\d+ /)
+ if (!line8Match || !line8Match[1]) throw new Error("Line 8 format incorrect")
+ expect(line8Match[1].length).toBeGreaterThanOrEqual(1)
+
+ // Verify proper left padding for double-digit line numbers (line10)
+ const line10Match = line10.match(/^( +)\d+ /)
+ if (!line10Match || !line10Match[1]) throw new Error("Line 10 format incorrect")
+ expect(line10Match[1].length).toBeGreaterThanOrEqual(1)
+
+ // Verify proper left padding for double-digit line numbers (line16)
+ const line16Match = line16.match(/^( +)\d+ /)
+ if (!line16Match || !line16Match[1]) throw new Error("Line 16 format incorrect")
+ expect(line16Match[1].length).toBeGreaterThanOrEqual(1)
+ })
+
+ it("handles conditional removal of diff element", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ function: { fg: RGBA.fromValues(0.51, 0.67, 1, 1) },
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
+ })
+
+ const diffContent = `--- a/test.js
++++ b/test.js
+@@ -1,7 +1,11 @@
+ function add(a, b) {
+ return a + b;
+ }
+
++function subtract(a, b) {
++ return a - b;
++}
++
+ function multiply(a, b) {
+- return a * b;
++ return a * b * 1;
+ }`
+
+ const showDiff = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ showDiff.value
+ ? h("diff", {
+ id: "test-diff",
+ diff: diffContent,
+ view: "unified",
+ filetype: "javascript",
+ syntaxStyle,
+ showLineNumbers: true,
+ width: "100%",
+ height: "100%",
+ })
+ : h("Text", { id: "fallback-text", width: "100%", height: "100%" }, "No diff to display"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+
+ // Initially shows diff content
+ expect(frame).toContain("function add")
+ expect(frame).toContain("function subtract")
+ expect(frame).toContain("+")
+ expect(frame).toContain("-")
+
+ // Toggle to hide diff - this should trigger destruction of DiffRenderable
+ showDiff.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+
+ // Should show fallback text
+ expect(frame).toContain("No diff to display")
+ // Diff content should not be present
+ expect(frame).not.toContain("function add")
+ expect(frame).not.toContain("function subtract")
+
+ // Toggle back to show diff - this should create a new DiffRenderable
+ showDiff.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+
+ // Diff should be visible again
+ expect(frame).toContain("function add")
+ expect(frame).toContain("function subtract")
+ })
+
+ it("handles conditional removal of split diff element", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ function: { fg: RGBA.fromValues(0.51, 0.67, 1, 1) },
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
+ })
+
+ const diffContent = `--- a/test.js
++++ b/test.js
+@@ -1,3 +1,3 @@
+ function hello() {
+- console.log("Hello");
++ console.log("Hello, World!");
+ }`
+
+ const showDiff = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ showDiff.value
+ ? h("diff", {
+ id: "test-diff",
+ diff: diffContent,
+ view: "split",
+ filetype: "javascript",
+ syntaxStyle,
+ showLineNumbers: true,
+ width: "100%",
+ height: "100%",
+ })
+ : h("Text", { id: "fallback-text", width: "100%", height: "100%" }, "No diff to display"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+
+ // Initially shows diff content in split view
+ expect(frame).toContain("function hello")
+ expect(frame).toContain("console.log")
+
+ // Toggle to hide diff - this should trigger destruction of DiffRenderable with split view
+ showDiff.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+
+ // Should show fallback text
+ expect(frame).toContain("No diff to display")
+ // Diff content should not be present
+ expect(frame).not.toContain("function hello")
+
+ // Toggle back to show diff - this should create a new DiffRenderable
+ showDiff.value = true
+ await nextTick()
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+
+ // Diff should be visible again
+ expect(frame).toContain("function hello")
+ })
+
+ it("split diff with word wrapping: toggling vs setting from start should match", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ "keyword.import": { fg: RGBA.fromValues(0.78, 0.57, 0.92, 1) },
+ string: { fg: RGBA.fromValues(0.65, 0.84, 1, 1) },
+ comment: { fg: RGBA.fromValues(0.55, 0.58, 0.62, 1), italic: true },
+ function: { fg: RGBA.fromValues(0.51, 0.67, 1, 1) },
+ default: { fg: RGBA.fromValues(0.9, 0.93, 0.95, 1) },
+ })
+
+ // Use the actual diff content from the demo
+ const diffContent = `Index: packages/core/src/examples/index.ts
+===================================================================
+--- packages/core/src/examples/index.ts before
++++ packages/core/src/examples/index.ts after
+@@ -56,6 +56,7 @@
+ import * as terminalDemo from "./terminal"
+ import * as diffDemo from "./diff-demo"
+ import * as keypressDebugDemo from "./keypress-debug-demo"
++import * as textTruncationDemo from "./text-truncation-demo"
+ import { setupCommonDemoKeys } from "./lib/standalone-keys"
+
+ interface Example {
+@@ -85,6 +86,12 @@
+ destroy: textSelectionExample.destroy,
+ },
+ {
++ name: "Text Truncation Demo",
++ description: "Middle truncation with ellipsis - toggle with 'T' key and resize to test responsive behavior",
++ run: textTruncationDemo.run,
++ destroy: textTruncationDemo.destroy,
++ },
++ {
+ name: "ASCII Font Selection Demo",
+ description: "Text selection with ASCII fonts - precise character-level selection across different font types",
+ run: asciiFontSelectionExample.run,`
+
+ const wrapMode = ref<"none" | "word">("none")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ h("diff", {
+ id: "test-diff-toggle",
+ diff: diffContent,
+ view: "split",
+ filetype: "typescript",
+ syntaxStyle,
+ showLineNumbers: true,
+ wrapMode: wrapMode.value,
+ width: "100%",
+ height: "100%",
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent)
+
+ await testSetup.renderOnce()
+ wrapMode.value = "word"
+ await nextTick()
+ await Bun.sleep(10)
+ await testSetup.renderer.idle()
+
+ const frameAfterToggle = testSetup.captureCharFrame()
+
+ testSetup.renderer.destroy()
+
+ // Create a new test with word wrap from the start
+ const TestComponentFromStart = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", width: "100%", height: "100%" }, [
+ h("diff", {
+ id: "test-diff-from-start",
+ diff: diffContent,
+ view: "split",
+ filetype: "typescript",
+ syntaxStyle,
+ showLineNumbers: true,
+ wrapMode: "word",
+ width: "100%",
+ height: "100%",
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponentFromStart)
+
+ await Bun.sleep(10)
+ await testSetup.renderer.idle()
+
+ const frameFromStart = testSetup.captureCharFrame()
+
+ expect(frameAfterToggle).toBe(frameFromStart)
+ })
+})
diff --git a/packages/vue/tests/events.test.ts b/packages/vue/tests/events.test.ts
new file mode 100644
index 000000000..118241a4d
--- /dev/null
+++ b/packages/vue/tests/events.test.ts
@@ -0,0 +1,757 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref } from "vue"
+import { testRender } from "../src/test-utils"
+import { createSpy } from "@opentui/core/testing"
+import type { PasteEvent } from "@opentui/core"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Event Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ describe("Input Events", () => {
+ it("should handle input onInput events", async () => {
+ const onInputSpy = createSpy()
+ const value = ref("")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: (val: string) => {
+ onInputSpy(val)
+ value.value = val
+ },
+ }),
+ h("Text", { content: `Value: ${value.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ await testSetup.mockInput.typeText("hello")
+
+ expect(onInputSpy.callCount()).toBe(5)
+ expect(onInputSpy.calls[0]?.[0]).toBe("h")
+ expect(onInputSpy.calls[4]?.[0]).toBe("hello")
+ expect(value.value).toBe("hello")
+ })
+
+ it("should handle input onSubmit events", async () => {
+ const onSubmitSpy = createSpy()
+ const submittedValue = ref("")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: (val: string) => {
+ submittedValue.value = val
+ },
+ onSubmit: (val: string) => onSubmitSpy(val),
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ await testSetup.mockInput.typeText("test input")
+ testSetup.mockInput.pressEnter()
+
+ expect(onSubmitSpy.callCount()).toBe(1)
+ expect(onSubmitSpy.calls[0]?.[0]).toBe("test input")
+ expect(submittedValue.value).toBe("test input")
+ })
+
+ it("should handle input onChange events on blur", async () => {
+ const onChangeSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ id: "input",
+ focused: true,
+ onChange: (val: string) => onChangeSpy(val),
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ await testSetup.mockInput.typeText("test")
+ expect(onChangeSpy.callCount()).toBe(0)
+
+ const input = testSetup.renderer.root.findDescendantById("input")
+ input?.blur()
+ expect(onChangeSpy.callCount()).toBe(1)
+ expect(onChangeSpy.calls[0]?.[0]).toBe("test")
+ })
+
+ it("should handle event handler attachment", async () => {
+ const inputSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: inputSpy,
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ await testSetup.mockInput.typeText("test")
+
+ expect(inputSpy.callCount()).toBe(4)
+ expect(inputSpy.calls[0]?.[0]).toBe("t")
+ expect(inputSpy.calls[3]?.[0]).toBe("test")
+ })
+ })
+
+ describe("Select Events", () => {
+ it("should handle select onChange events", async () => {
+ const onChangeSpy = createSpy()
+ const selectedIndex = ref(0)
+
+ const options = [
+ { name: "Option 1", value: 1, description: "First option" },
+ { name: "Option 2", value: 2, description: "Second option" },
+ { name: "Option 3", value: 3, description: "Third option" },
+ ]
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Select", {
+ focused: true,
+ options,
+ onChange: (index: number, option: (typeof options)[0]) => {
+ onChangeSpy(index, option)
+ selectedIndex.value = index
+ },
+ }),
+ h("Text", { content: `Selected: ${selectedIndex.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 10 })
+
+ testSetup.mockInput.pressArrow("down")
+
+ expect(onChangeSpy.callCount()).toBe(1)
+ expect(onChangeSpy.calls[0]?.[0]).toBe(1)
+ expect(onChangeSpy.calls[0]?.[1]).toEqual(options[1])
+ expect(selectedIndex.value).toBe(1)
+ })
+
+ it("should handle select onSelect events", async () => {
+ const onSelectSpy = createSpy()
+
+ const options = [
+ { name: "Option 1", value: "opt1", description: "First option" },
+ { name: "Option 2", value: "opt2", description: "Second option" },
+ ]
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Select", {
+ focused: true,
+ options,
+ onSelect: (index: number, option: (typeof options)[0]) => {
+ onSelectSpy(index, option)
+ },
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 10 })
+
+ // Navigate to second option
+ testSetup.mockInput.pressArrow("down")
+ // Select it
+ testSetup.mockInput.pressEnter()
+
+ expect(onSelectSpy.callCount()).toBe(1)
+ expect(onSelectSpy.calls[0]?.[0]).toBe(1)
+ expect(onSelectSpy.calls[0]?.[1]?.value).toBe("opt2")
+ })
+
+ it("should handle keyboard navigation on select components", async () => {
+ const changeSpy = createSpy()
+ const selectedValue = ref("")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Select", {
+ focused: true,
+ options: [
+ { name: "Option 1", value: "opt1", description: "First option" },
+ { name: "Option 2", value: "opt2", description: "Second option" },
+ { name: "Option 3", value: "opt3", description: "Third option" },
+ ],
+ onChange: (index: number, option: { value: string }) => {
+ changeSpy(index, option)
+ selectedValue.value = option?.value || ""
+ },
+ }),
+ h("Text", { content: `Selected: ${selectedValue.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 25, height: 10 })
+
+ testSetup.mockInput.pressArrow("down")
+
+ expect(changeSpy.callCount()).toBe(1)
+ expect(changeSpy.calls[0]?.[0]).toBe(1)
+ expect(changeSpy.calls[0]?.[1]?.value).toBe("opt2")
+ expect(selectedValue.value).toBe("opt2")
+
+ testSetup.mockInput.pressArrow("down")
+
+ expect(changeSpy.callCount()).toBe(2)
+ expect(changeSpy.calls[1]?.[0]).toBe(2)
+ expect(changeSpy.calls[1]?.[1]?.value).toBe("opt3")
+ expect(selectedValue.value).toBe("opt3")
+ })
+ })
+
+ describe("TabSelect Events", () => {
+ it("should handle tab-select onSelect events", async () => {
+ const onSelectSpy = createSpy()
+ const activeTab = ref(0)
+
+ const tabs = [{ title: "Tab 1" }, { title: "Tab 2" }, { title: "Tab 3" }]
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("tab-select", {
+ focused: true,
+ options: tabs.map((tab, index) => ({
+ name: tab.title,
+ value: index,
+ description: "",
+ })),
+ onSelect: (index: number) => {
+ onSelectSpy(index)
+ activeTab.value = index
+ },
+ }),
+ h("Text", { content: `Active tab: ${activeTab.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 40, height: 8 })
+
+ testSetup.mockInput.pressArrow("right")
+ testSetup.mockInput.pressArrow("right")
+ testSetup.mockInput.pressEnter()
+
+ expect(onSelectSpy.callCount()).toBe(1)
+ expect(onSelectSpy.calls[0]?.[0]).toBe(2)
+ expect(activeTab.value).toBe(2)
+ })
+
+ it("should handle tabSelect onChange events", async () => {
+ const onChangeSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("tab-select", {
+ focused: true,
+ options: [
+ { name: "Tab 1", value: 0, description: "" },
+ { name: "Tab 2", value: 1, description: "" },
+ ],
+ onChange: (index: number) => {
+ onChangeSpy(index)
+ },
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 40, height: 8 })
+
+ testSetup.mockInput.pressArrow("right")
+
+ expect(onChangeSpy.callCount()).toBe(1)
+ expect(onChangeSpy.calls[0]?.[0]).toBe(1)
+ })
+ })
+
+ describe("Focus Management", () => {
+ it("should handle focus management between inputs", async () => {
+ const input1Spy = createSpy()
+ const input2Spy = createSpy()
+ const input1Focused = ref(true)
+ const input2Focused = ref(false)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: input1Focused.value,
+ onInput: input1Spy,
+ }),
+ h("Input", {
+ focused: input2Focused.value,
+ onInput: input2Spy,
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 8 })
+
+ await testSetup.mockInput.typeText("first")
+
+ expect(input1Spy.callCount()).toBe(5)
+ expect(input2Spy.callCount()).toBe(0)
+
+ // Switch focus
+ input1Focused.value = false
+ input2Focused.value = true
+
+ input1Spy.reset()
+ input2Spy.reset()
+
+ await testSetup.mockInput.typeText("second")
+
+ expect(input1Spy.callCount()).toBe(0)
+ expect(input2Spy.callCount()).toBe(6)
+ })
+ })
+
+ describe("Textarea Events", () => {
+ it("should handle textarea onSubmit events", async () => {
+ const onSubmitSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Textarea", {
+ focused: true,
+ initialValue: "test content",
+ onSubmit: () => onSubmitSpy(),
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ testSetup.mockInput.pressKey("RETURN", { meta: true })
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(onSubmitSpy.callCount()).toBe(1)
+ })
+
+ it("should handle textarea onContentChange events", async () => {
+ const onContentChangeSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Textarea", {
+ focused: true,
+ initialValue: "",
+ onContentChange: (content: string) => onContentChangeSpy(content),
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ await testSetup.mockInput.typeText("hello")
+
+ expect(onContentChangeSpy.callCount()).toBeGreaterThan(0)
+ })
+ })
+
+ describe("Global preventDefault", () => {
+ it("should handle global preventDefault for keyboard events", async () => {
+ const inputSpy = createSpy()
+ const globalHandlerSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: inputSpy,
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ // Register global handler that prevents 'a' key
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ globalHandlerSpy(event.name)
+ if (event.name === "a") {
+ event.preventDefault()
+ }
+ })
+
+ await testSetup.mockInput.typeText("abc")
+
+ // Global handler should be called for all keys
+ expect(globalHandlerSpy.callCount()).toBe(3)
+ expect(globalHandlerSpy.calls[0]?.[0]).toBe("a")
+ expect(globalHandlerSpy.calls[1]?.[0]).toBe("b")
+ expect(globalHandlerSpy.calls[2]?.[0]).toBe("c")
+
+ // Input should only receive 'b' and 'c' (not 'a')
+ expect(inputSpy.callCount()).toBe(2)
+ expect(inputSpy.calls[0]?.[0]).toBe("b")
+ expect(inputSpy.calls[1]?.[0]).toBe("bc")
+ })
+
+ it("should handle global handler registered after component mount", async () => {
+ const inputSpy = createSpy()
+ const value = ref("")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: (val: string) => {
+ inputSpy(val)
+ value.value = val
+ },
+ }),
+ h("Text", { content: `Value: ${value.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ // Type before global handler exists
+ await testSetup.mockInput.typeText("hello")
+ expect(inputSpy.callCount()).toBe(5)
+ expect(value.value).toBe("hello")
+
+ inputSpy.reset()
+
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ if (/^[0-9]$/.test(event.name)) {
+ event.preventDefault()
+ }
+ })
+
+ // Type mixed content
+ await testSetup.mockInput.typeText("abc123xyz")
+
+ // Only letters should reach the input
+ expect(inputSpy.callCount()).toBe(6) // a, b, c, x, y, z (not 1, 2, 3)
+ expect(value.value).toBe("helloabcxyz")
+ })
+
+ it("should handle dynamic preventDefault conditions", async () => {
+ const inputSpy = createSpy()
+ let preventNumbers = false
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: inputSpy,
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ // Register handler with dynamic condition
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ if (preventNumbers && /^[0-9]$/.test(event.name)) {
+ event.preventDefault()
+ }
+ })
+
+ // Initially allow numbers
+ await testSetup.mockInput.typeText("a1")
+ expect(inputSpy.callCount()).toBe(2)
+ expect(inputSpy.calls[1]?.[0]).toBe("a1")
+
+ // Enable number prevention
+ preventNumbers = true
+ inputSpy.reset()
+
+ // Now numbers should be prevented
+ await testSetup.mockInput.typeText("b2c3")
+ expect(inputSpy.callCount()).toBe(2) // Only 'b' and 'c'
+ expect(inputSpy.calls[0]?.[0]).toBe("a1b")
+ expect(inputSpy.calls[1]?.[0]).toBe("a1bc")
+
+ // Disable prevention again
+ preventNumbers = false
+ inputSpy.reset()
+
+ // Numbers should work again
+ await testSetup.mockInput.typeText("4")
+ expect(inputSpy.callCount()).toBe(1)
+ expect(inputSpy.calls[0]?.[0]).toBe("a1bc4")
+ })
+
+ it("should handle preventDefault for select components", async () => {
+ const changeSpy = createSpy()
+ const globalHandlerSpy = createSpy()
+ const selectedIndex = ref(0)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Select", {
+ focused: true,
+ wrapSelection: true,
+ options: [
+ { name: "Option 1", value: 1, description: "First" },
+ { name: "Option 2", value: 2, description: "Second" },
+ { name: "Option 3", value: 3, description: "Third" },
+ ],
+ onChange: (index: number, option: { value: number }) => {
+ changeSpy(index, option)
+ selectedIndex.value = index
+ },
+ }),
+ h("Text", { content: `Selected: ${selectedIndex.value}` }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 10 })
+
+ // Register global handler that prevents down arrow
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ globalHandlerSpy(event.name)
+ if (event.name === "down") {
+ event.preventDefault()
+ }
+ })
+
+ // Try to press down arrow - should be prevented
+ testSetup.mockInput.pressArrow("down")
+ expect(globalHandlerSpy.callCount()).toBe(1)
+ expect(changeSpy.callCount()).toBe(0) // Should not change
+ expect(selectedIndex.value).toBe(0) // Should remain at 0
+
+ // Up arrow should still work
+ testSetup.mockInput.pressArrow("up")
+ expect(globalHandlerSpy.callCount()).toBe(2)
+ expect(changeSpy.callCount()).toBe(1) // Should wrap to last option
+ expect(selectedIndex.value).toBe(2) // Should be at last option
+ })
+
+ it("should handle multiple global handlers with preventDefault", async () => {
+ const inputSpy = createSpy()
+ const firstHandlerSpy = createSpy()
+ const secondHandlerSpy = createSpy()
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onInput: inputSpy,
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 5 })
+
+ // First handler prevents 'x'
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ firstHandlerSpy(event.name)
+ if (event.name === "x") {
+ event.preventDefault()
+ }
+ })
+
+ // Second handler also runs but can't undo preventDefault
+ testSetup.renderer.keyInput.on("keypress", (event) => {
+ secondHandlerSpy(event.name)
+ })
+
+ await testSetup.mockInput.typeText("xyz")
+
+ // Both handlers should be called for all keys
+ expect(firstHandlerSpy.callCount()).toBe(3)
+ expect(secondHandlerSpy.callCount()).toBe(3)
+
+ // But input should only receive 'y' and 'z'
+ expect(inputSpy.callCount()).toBe(2)
+ expect(inputSpy.calls[0]?.[0]).toBe("y")
+ expect(inputSpy.calls[1]?.[0]).toBe("yz")
+ })
+ })
+
+ describe("Paste Events", () => {
+ it("should handle global preventDefault for paste events", async () => {
+ const pasteSpy = createSpy()
+ const globalHandlerSpy = createSpy()
+ const pastedText = ref("")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Input", {
+ focused: true,
+ onPaste: (val: PasteEvent) => {
+ pasteSpy(val)
+ pastedText.value = val.text
+ },
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 5 })
+
+ // Register global handler that prevents paste containing "forbidden"
+ testSetup.renderer.keyInput.on("paste", (event: PasteEvent) => {
+ globalHandlerSpy(event.text)
+ if (event.text.includes("forbidden")) {
+ event.preventDefault()
+ }
+ })
+
+ // First paste should go through
+ await testSetup.mockInput.pasteBracketedText("allowed content")
+ expect(globalHandlerSpy.callCount()).toBe(1)
+ expect(pasteSpy.callCount()).toBe(1)
+ expect(pastedText.value).toBe("allowed content")
+
+ // Reset spies
+ globalHandlerSpy.reset()
+ pasteSpy.reset()
+
+ // Second paste should be prevented
+ await testSetup.mockInput.pasteBracketedText("forbidden content")
+ expect(globalHandlerSpy.callCount()).toBe(1)
+ expect(globalHandlerSpy.calls[0]?.[0]).toBe("forbidden content")
+ expect(pasteSpy.callCount()).toBe(0)
+ expect(pastedText.value).toBe("allowed content") // Should remain unchanged
+ })
+ })
+
+ describe("Dynamic Content", () => {
+ it("should handle dynamic arrays and list updates", async () => {
+ const items = ref(["Item 1", "Item 2"])
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h(
+ "box",
+ {},
+ items.value.map((item) => h("Text", { content: item })),
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 20, height: 10 })
+
+ let children = testSetup.renderer.root.getChildren()
+ expect(children.length).toBe(1)
+ let boxChildren = children[0]!.getChildren()
+ expect(boxChildren.length).toBe(2)
+
+ items.value = ["Item 1", "Item 2", "Item 3"]
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()
+ boxChildren = children[0]!.getChildren()
+ expect(boxChildren.length).toBe(3)
+
+ items.value = ["Item 1", "Item 3"]
+ await testSetup.renderOnce()
+
+ children = testSetup.renderer.root.getChildren()
+ boxChildren = children[0]!.getChildren()
+ expect(boxChildren.length).toBe(2)
+ })
+
+ it("should handle dynamic text content", async () => {
+ const dynamicText = ref("Initial")
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("Text", { content: `Static: ${dynamicText.value}` }),
+ h("Text", { content: "Direct content" }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 8 })
+
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Static: Initial")
+ expect(frame).toContain("Direct content")
+
+ dynamicText.value = "Updated"
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Static: Updated")
+ expect(frame).toContain("Direct content")
+ })
+ })
+})
diff --git a/packages/vue/tests/layout.test.ts b/packages/vue/tests/layout.test.ts
new file mode 100644
index 000000000..b0231b2b1
--- /dev/null
+++ b/packages/vue/tests/layout.test.ts
@@ -0,0 +1,429 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, Fragment } from "vue"
+import { testRender } from "../src/test-utils"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Layout Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ describe("Basic Text Rendering", () => {
+ it("should render simple text correctly", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", {}, "Hello World")
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 20,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render multiline text correctly", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h("box", {}, [
+ h("Text", {}, "Line 1"),
+ h("Text", {}, "Line 2"),
+ h("Text", {}, "Line 3"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 15,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render text with dynamic content", async () => {
+ const counter = 42
+
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", {}, `Counter: ${counter}`)
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 20,
+ height: 3,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Box Layout Rendering", () => {
+ it("should render basic box layout correctly", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 20, height: 5, border: true },
+ },
+ [h("Text", {}, "Inside Box")],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render nested boxes correctly", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 30, height: 10, border: true },
+ title: "Parent Box",
+ },
+ [
+ h(
+ "box",
+ {
+ style: { left: 2, top: 2, width: 10, height: 3, border: true },
+ },
+ [h("Text", {}, "Nested")],
+ ),
+ h(
+ "Text",
+ {
+ style: { left: 15, top: 2 },
+ },
+ "Sibling",
+ ),
+ ],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 35,
+ height: 12,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render absolute positioned boxes", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(Fragment, [
+ h(
+ "box",
+ {
+ style: {
+ position: "absolute",
+ left: 0,
+ top: 0,
+ width: 10,
+ height: 3,
+ border: true,
+ backgroundColor: "red",
+ },
+ },
+ [h("Text", {}, "Box 1")],
+ ),
+ h(
+ "box",
+ {
+ style: {
+ position: "absolute",
+ left: 12,
+ top: 2,
+ width: 10,
+ height: 3,
+ border: true,
+ backgroundColor: "blue",
+ },
+ },
+ [h("Text", {}, "Box 2")],
+ ),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should auto-enable border when borderStyle is set", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 20, height: 5 },
+ borderStyle: "single",
+ },
+ [h("Text", {}, "With Border")],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should auto-enable border when borderColor is set", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 20, height: 5 },
+ borderColor: "cyan",
+ },
+ [h("Text", {}, "Colored Border")],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Complex Layouts", () => {
+ it("should render complex nested layout correctly", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 40, border: true },
+ title: "Complex Layout",
+ },
+ [
+ h(
+ "box",
+ {
+ style: { left: 2, width: 15, height: 5, border: true, backgroundColor: "#333" },
+ },
+ [
+ h("Text", { style: { fg: "cyan" }, wrapMode: "none" }, "Header Section"),
+ h("Text", { style: { fg: "yellow" }, wrapMode: "none" }, "Menu Item 1"),
+ h("Text", { style: { fg: "yellow" }, wrapMode: "none" }, "Menu Item 2"),
+ ],
+ ),
+ h(
+ "box",
+ {
+ style: { left: 18, width: 18, height: 8, border: true, backgroundColor: "#222" },
+ },
+ [
+ h("Text", { style: { fg: "green" }, wrapMode: "none" }, "Content Area"),
+ h("Text", { style: { fg: "white" }, wrapMode: "none" }, "Some content here"),
+ h("Text", { style: { fg: "white" }, wrapMode: "none" }, "More content"),
+ h("Text", { style: { fg: "magenta" }, wrapMode: "none" }, "Footer text"),
+ ],
+ ),
+ h("Text", { style: { left: 2, fg: "gray" } }, "Status: Ready"),
+ ],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 45,
+ height: 18,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render text with mixed styling and layout", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ style: { width: 35, height: 8, border: true },
+ },
+ [
+ h("Text", {}, [
+ h("Span", { style: { fg: "red", bold: true } }, "ERROR:"),
+ " Something went wrong",
+ ]),
+ h("Text", {}, [
+ h("Span", { style: { fg: "yellow" } }, "WARNING:"),
+ " Check your settings",
+ ]),
+ h("Text", {}, [
+ h("Span", { style: { fg: "green" } }, "SUCCESS:"),
+ " All systems operational",
+ ]),
+ ],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render scrollbox with sticky scroll and spacer", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ {
+ maxHeight: "100%",
+ maxWidth: "100%",
+ },
+ [
+ h(
+ "scrollbox",
+ {
+ scrollbarOptions: { visible: false },
+ stickyScroll: true,
+ stickyStart: "bottom",
+ paddingTop: 1,
+ paddingBottom: 1,
+ title: "scroll area",
+ rootOptions: {
+ flexGrow: 0,
+ },
+ border: true,
+ },
+ [h("box", { border: true, height: 10, title: "hi" })],
+ ),
+ h(
+ "box",
+ {
+ border: true,
+ height: 10,
+ title: "spacer",
+ flexShrink: 0,
+ },
+ [h("Text", {}, "spacer")],
+ ),
+ ],
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 30,
+ height: 25,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Empty and Edge Cases", () => {
+ it("should handle empty component", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(Fragment, [])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 10,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should handle component with no children", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h("box", { style: { width: 10, height: 5 } })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 15,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should handle very small dimensions", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", {}, "Hi")
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 5,
+ height: 3,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+})
diff --git a/packages/vue/tests/line-number.test.ts b/packages/vue/tests/line-number.test.ts
new file mode 100644
index 000000000..ce6a83cca
--- /dev/null
+++ b/packages/vue/tests/line-number.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref, nextTick } from "vue"
+import { testRender } from "../src/test-utils"
+import { SyntaxStyle } from "@opentui/core"
+import { MockTreeSitterClient } from "@opentui/core/testing"
+
+let testSetup: Awaited>
+let mockTreeSitterClient: MockTreeSitterClient
+
+describe("Vue Renderer | LineNumberRenderable Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ mockTreeSitterClient = new MockTreeSitterClient()
+ mockTreeSitterClient.setMockResult({ highlights: [] })
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ it("renders code with line numbers", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: "#C792EA" },
+ function: { fg: "#82AAFF" },
+ default: { fg: "#FFFFFF" },
+ })
+
+ const codeContent = `function test() {
+ return 42
+}
+console.log(test())`
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", style: { width: "100%", height: "100%" } }, [
+ h(
+ "line-number",
+ {
+ id: "line-numbers",
+ fg: "#888888",
+ bg: "#000000",
+ minWidth: 3,
+ paddingRight: 1,
+ style: { width: "100%", height: "100%" },
+ },
+ [
+ h("Code", {
+ id: "code-content",
+ content: codeContent,
+ filetype: "javascript",
+ syntaxStyle: syntaxStyle,
+ treeSitterClient: mockTreeSitterClient,
+ style: { width: "100%", height: "100%" },
+ }),
+ ],
+ ),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+
+ mockTreeSitterClient.resolveAllHighlightOnce()
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+
+ // Basic checks
+ expect(frame).toContain("function test()")
+ expect(frame).toContain(" 1 ") // Line number 1
+ expect(frame).toContain(" 2 ") // Line number 2
+ expect(frame).toContain(" 3 ") // Line number 3
+ expect(frame).toContain(" 4 ") // Line number 4
+ })
+
+ it("handles conditional removal of line number element", async () => {
+ const syntaxStyle = SyntaxStyle.fromStyles({
+ keyword: { fg: "#C792EA" },
+ function: { fg: "#82AAFF" },
+ default: { fg: "#FFFFFF" },
+ })
+
+ const codeContent = `function test() {
+ return 42
+}
+console.log(test())`
+
+ const showLineNumbers = ref(true)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { id: "root", style: { width: "100%", height: "100%" } }, [
+ showLineNumbers.value
+ ? h(
+ "line-number",
+ {
+ id: "line-numbers",
+ fg: "#888888",
+ bg: "#000000",
+ minWidth: 3,
+ paddingRight: 1,
+ style: { width: "100%", height: "100%" },
+ },
+ [
+ h("Code", {
+ id: "code-content",
+ content: codeContent,
+ filetype: "javascript",
+ syntaxStyle: syntaxStyle,
+ treeSitterClient: mockTreeSitterClient,
+ style: { width: "100%", height: "100%" },
+ }),
+ ],
+ )
+ : h("Code", {
+ id: "code-content-no-lines",
+ content: codeContent,
+ filetype: "javascript",
+ syntaxStyle: syntaxStyle,
+ treeSitterClient: mockTreeSitterClient,
+ style: { width: "100%", height: "100%" },
+ }),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ mockTreeSitterClient.resolveAllHighlightOnce()
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await testSetup.renderOnce()
+
+ let frame = testSetup.captureCharFrame()
+
+ // Initially shows line numbers
+ expect(frame).toContain(" 1 ")
+ expect(frame).toContain(" 2 ")
+
+ // Toggle to hide line numbers - this should trigger destruction of LineNumberRenderable
+ showLineNumbers.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+ mockTreeSitterClient.resolveAllHighlightOnce()
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ await testSetup.renderOnce()
+
+ frame = testSetup.captureCharFrame()
+
+ // Should still show code but without line numbers
+ expect(frame).toContain("function test()")
+ // Line numbers should not be present
+ expect(frame).not.toContain(" 1 function")
+ })
+})
diff --git a/packages/vue/tests/link.test.ts b/packages/vue/tests/link.test.ts
new file mode 100644
index 000000000..2ec0e15e5
--- /dev/null
+++ b/packages/vue/tests/link.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { link, t, underline, blue } from "@opentui/core"
+import { defineComponent, h } from "vue"
+import { testRender } from "../src/test-utils"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Link Rendering Tests", () => {
+ beforeEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ it("should render link with href correctly", async () => {
+ const styledText = t`Visit ${link("https://opentui.com")("opentui.com")} for more info`
+
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", { content: styledText })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 50,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+
+ expect(frame).toContain("Visit opentui.com for more info")
+ })
+
+ it("should render styled link with underline", async () => {
+ const styledText = t`${underline(blue(link("https://opentui.com")("opentui.com")))}`
+
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", { content: styledText })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 50,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+
+ expect(frame).toContain("opentui.com")
+ })
+
+ it("should render link inside text with other elements", async () => {
+ const styledText = t`Check out ${link("https://github.com/sst/opentui")("GitHub")} and ${link("https://opentui.com")("our website")}`
+
+ const TestComponent = defineComponent({
+ render() {
+ return h("Text", { content: styledText })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 60,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+
+ expect(frame).toContain("GitHub")
+ expect(frame).toContain("our website")
+ })
+})
diff --git a/packages/vue/tests/portal.test.ts b/packages/vue/tests/portal.test.ts
new file mode 100644
index 000000000..7c986ed2e
--- /dev/null
+++ b/packages/vue/tests/portal.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref, nextTick } from "vue"
+import { testRender } from "../src/test-utils"
+import { Portal } from "../src/components/Portal"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Portal Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ describe("Basic Portal Rendering", () => {
+ it("should render content to default mount point (root)", async () => {
+ const TestComponent = defineComponent({
+ components: { Portal },
+ render() {
+ return h("box", {}, [
+ h("Text", {}, "Before portal"),
+ h(Portal, {}, () => [h("Text", {}, "Portal content")]),
+ h("Text", {}, "After portal"),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Portal content")
+ expect(frame).toContain("Before portal")
+ expect(frame).toContain("After portal")
+ })
+
+ it("should render content to custom mount point", async () => {
+ const TestComponent = defineComponent({
+ components: { Portal },
+ render() {
+ return h("box", {}, [
+ h(Portal, {}, () => [
+ h("box", { style: { border: true }, title: "Portal Box" }, [
+ h("Text", {}, "Portal content"),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ await nextTick()
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Portal content")
+ })
+
+ it("should handle complex nested content in portal", async () => {
+ const TestComponent = defineComponent({
+ components: { Portal },
+ render() {
+ return h("box", {}, [
+ h(Portal, {}, () => [h("Text", {}, "Nested text 1"), h("Text", {}, "Nested text 2")]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 30,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Nested text 1")
+ expect(frame).toContain("Nested text 2")
+ })
+
+ it("should handle portal cleanup on unmount", async () => {
+ const showPortal = ref(true)
+
+ const TestComponent = defineComponent({
+ components: { Portal },
+ setup() {
+ return { showPortal }
+ },
+ render() {
+ return h("box", {}, [
+ showPortal.value ? h(Portal, {}, () => [h("Text", {}, "Portal content")]) : null,
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 20,
+ height: 5,
+ })
+
+ await testSetup.renderOnce()
+ let frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Portal content")
+
+ showPortal.value = false
+ await nextTick()
+
+ try {
+ await testSetup.renderOnce()
+ } catch {}
+
+ frame = testSetup.captureCharFrame()
+ expect(frame).not.toContain("Portal content")
+ })
+
+ it("should handle multiple portals", async () => {
+ const TestComponent = defineComponent({
+ components: { Portal },
+ render() {
+ return h("box", {}, [
+ h(Portal, {}, () => [h("Text", {}, "First portal")]),
+ h(Portal, {}, () => [h("Text", {}, "Second portal")]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 25,
+ height: 8,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("First portal")
+ expect(frame).toContain("Second portal")
+ })
+ })
+})
diff --git a/packages/vue/tests/text-nodes.test.ts b/packages/vue/tests/text-nodes.test.ts
new file mode 100644
index 000000000..d20351d01
--- /dev/null
+++ b/packages/vue/tests/text-nodes.test.ts
@@ -0,0 +1,81 @@
+import { afterEach, beforeEach, describe, expect, it } from "bun:test"
+import { TextRenderable } from "@opentui/core"
+import { defineComponent, h, nextTick, ref } from "vue"
+import { testRender } from "../src/test-utils"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | TextNode Tests", () => {
+ beforeEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ it("renders text nodes under non-text renderables", async () => {
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ { id: "container", style: { width: 20, height: 5, border: true } },
+ "Hello",
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 22, height: 7 })
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toContain("Hello")
+
+ const container = testSetup.renderer.root.findDescendantById("container")!
+ const children = container.getChildren()
+ expect(children.length).toBe(1)
+ expect(children[0]).toBeInstanceOf(TextRenderable)
+ })
+
+ it("removes text-ghost nodes when text is removed", async () => {
+ const show = ref(true)
+
+ const TestComponent = defineComponent({
+ render() {
+ return h(
+ "box",
+ { id: "container", style: { width: 20, height: 5, border: true } },
+ show.value ? "Hi" : undefined,
+ )
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 22, height: 7 })
+ await testSetup.renderOnce()
+
+ const container = testSetup.renderer.root.findDescendantById("container")!
+ expect(container.getChildren().length).toBe(1)
+
+ show.value = false
+ await nextTick()
+ await testSetup.renderOnce()
+
+ const remaining = container.getChildren()
+ if (remaining.length === 0) {
+ return
+ }
+
+ expect(remaining.length).toBe(1)
+ const ghost = remaining[0]!
+ expect(ghost).toBeInstanceOf(TextRenderable)
+ const text = ghost as TextRenderable
+ const contentChunks = text.content.chunks
+ const combinedText = contentChunks.map((c) => c.text).join("")
+ const trimmedText = combinedText.trim()
+ expect(trimmedText).toBe("")
+ })
+})
diff --git a/packages/vue/tests/textarea.test.ts b/packages/vue/tests/textarea.test.ts
new file mode 100644
index 000000000..43bd664dd
--- /dev/null
+++ b/packages/vue/tests/textarea.test.ts
@@ -0,0 +1,575 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test"
+import { defineComponent, h, ref } from "vue"
+import { testRender } from "../src/test-utils"
+import { TextAttributes } from "@opentui/core"
+
+let testSetup: Awaited>
+
+describe("Vue Renderer | Textarea Layout Tests", () => {
+ beforeEach(async () => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ afterEach(() => {
+ if (testSetup) {
+ testSetup.renderer.destroy()
+ }
+ })
+
+ describe("Basic Textarea Rendering", () => {
+ it("should render simple textarea correctly", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("Textarea", {
+ initialValue: "Hello World",
+ width: 20,
+ height: 5,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 30,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render multiline textarea content", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("Textarea", {
+ initialValue: "Line 1\nLine 2\nLine 3",
+ width: 20,
+ height: 10,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 30,
+ height: 15,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render textarea with word wrapping", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("Textarea", {
+ initialValue: "This is a very long line that should wrap to multiple lines when word wrapping is enabled",
+ wrapMode: "word",
+ width: 20,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 30,
+ height: 15,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render textarea with placeholder", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("Textarea", {
+ initialValue: "",
+ placeholder: "Type something here...",
+ placeholderColor: "#666666",
+ width: 30,
+ height: 5,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ })
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Prompt-like Layout", () => {
+ it("should render textarea in prompt-style layout with indicator", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, borderColor: "#444444" }, [
+ h("box", { flexDirection: "row" }, [
+ h(
+ "box",
+ { width: 3, justifyContent: "center", alignItems: "center", backgroundColor: "#2d2d2d" },
+ [h("Text", { attributes: TextAttributes.BOLD, fg: "#00ff00" }, ">")],
+ ),
+ h("box", { paddingTop: 1, paddingBottom: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue: "Hello from the prompt",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ cursorColor: "#00ff00",
+ }),
+ ]),
+ h("box", { backgroundColor: "#1e1e1e", width: 1 }),
+ ]),
+ h("box", { flexDirection: "row", justifyContent: "space-between" }, [
+ h("Text", { wrapMode: "none" }, [
+ h("Span", { style: { fg: "#888888" } }, "provider"),
+ " ",
+ h("Span", { style: { bold: true } }, "model-name"),
+ ]),
+ h("Text", { fg: "#888888" }, "ctrl+p commands"),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 60,
+ height: 15,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render textarea with long wrapping text in prompt layout", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, borderColor: "#444444", width: "100%" }, [
+ h("box", { flexDirection: "row", width: "100%" }, [
+ h(
+ "box",
+ { width: 3, justifyContent: "center", alignItems: "center", backgroundColor: "#2d2d2d" },
+ [h("Text", { attributes: TextAttributes.BOLD, fg: "#00ff00" }, ">")],
+ ),
+ h("box", { paddingTop: 1, paddingBottom: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue:
+ "This is a very long prompt that will wrap across multiple lines in the textarea. It should maintain proper layout with the indicator on the left.",
+ wrapMode: "word",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ }),
+ ]),
+ h("box", { backgroundColor: "#1e1e1e", width: 1 }),
+ ]),
+ h("box", { flexDirection: "row" }, [
+ h("Text", { wrapMode: "none" }, [
+ h("Span", { style: { fg: "#888888" } }, "openai"),
+ " ",
+ h("Span", { style: { bold: true } }, "gpt-4"),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 50,
+ height: 20,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render textarea in shell mode with different indicator", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, borderColor: "#ff9900" }, [
+ h("box", { flexDirection: "row" }, [
+ h(
+ "box",
+ { width: 3, justifyContent: "center", alignItems: "center", backgroundColor: "#2d2d2d" },
+ [h("Text", { attributes: TextAttributes.BOLD, fg: "#ff9900" }, "!")],
+ ),
+ h("box", { paddingTop: 1, paddingBottom: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue: "ls -la",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ cursorColor: "#ff9900",
+ }),
+ ]),
+ h("box", { backgroundColor: "#1e1e1e", width: 1 }),
+ ]),
+ h("box", { flexDirection: "row" }, [h("Text", { fg: "#888888" }, "shell mode")]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 50,
+ height: 12,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Complex Layouts with Multiple Textareas", () => {
+ it("should render multiple textareas in a column layout", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, title: "Chat" }, [
+ h("box", { border: true, borderColor: "#00ff00", marginBottom: 1 }, [
+ h("box", { flexDirection: "row" }, [
+ h("box", { width: 5, backgroundColor: "#2d2d2d" }, [
+ h("Text", { fg: "#00ff00" }, "User"),
+ ]),
+ h("box", { paddingLeft: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue: "What is the weather like today?",
+ wrapMode: "word",
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ }),
+ ]),
+ ]),
+ ]),
+ h("box", { border: true, borderColor: "#0088ff" }, [
+ h("box", { flexDirection: "row" }, [
+ h("box", { width: 5, backgroundColor: "#2d2d2d" }, [
+ h("Text", { fg: "#0088ff" }, "AI"),
+ ]),
+ h("box", { paddingLeft: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue:
+ "I don't have access to real-time weather data, but I can help you find that information through various weather services.",
+ wrapMode: "word",
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ }),
+ ]),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 60,
+ height: 25,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should handle nested boxes with textareas at different positions", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { style: { width: 50, border: true }, title: "Layout Test" }, [
+ h("box", { flexDirection: "row", gap: 1 }, [
+ h("box", { width: 20, border: true, borderColor: "#00ff00" }, [
+ h("Text", { fg: "#00ff00" }, "Input 1:"),
+ h("Textarea", {
+ initialValue: "Left panel content",
+ wrapMode: "word",
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ flexShrink: 1,
+ }),
+ ]),
+ h("box", { flexGrow: 1, border: true, borderColor: "#0088ff" }, [
+ h("Text", { fg: "#0088ff" }, "Input 2:"),
+ h("Textarea", {
+ initialValue: "Right panel with longer content that may wrap",
+ wrapMode: "word",
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ flexShrink: 1,
+ }),
+ ]),
+ ]),
+ h("box", { border: true, borderColor: "#ff9900", marginTop: 1 }, [
+ h("Text", { fg: "#ff9900" }, "Bottom input:"),
+ h("Textarea", {
+ initialValue: "Bottom panel spanning full width",
+ wrapMode: "word",
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ flexShrink: 1,
+ }),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 55,
+ height: 25,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("FlexShrink Regression Tests", () => {
+ it("should not shrink box when width is set via setter", async () => {
+ const indicatorWidth = ref(undefined)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true }, [
+ h("box", { flexDirection: "row" }, [
+ h("box", { width: indicatorWidth.value, backgroundColor: "#f00" }, [
+ h("Text", {}, ">"),
+ ]),
+ h("box", { backgroundColor: "#0f0", flexGrow: 1 }, [
+ h("Text", {}, "Content that takes up space"),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 5 })
+
+ await testSetup.renderOnce()
+
+ indicatorWidth.value = 5
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should not shrink box when height is set via setter in column layout", async () => {
+ const headerHeight = ref(undefined)
+
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, width: 25, height: 10 }, [
+ h("box", { flexDirection: "column", height: "100%" }, [
+ h("box", { height: headerHeight.value, backgroundColor: "#f00" }, [
+ h("Text", {}, "Header"),
+ ]),
+ h("box", { backgroundColor: "#0f0", flexGrow: 1 }, [
+ h("Textarea", { initialValue: "Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8" }),
+ ]),
+ h("box", { height: 2, backgroundColor: "#00f" }, [h("Text", {}, "Footer")]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, { width: 30, height: 15 })
+
+ await testSetup.renderOnce()
+
+ headerHeight.value = 3
+ await testSetup.renderOnce()
+
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+
+ describe("Edge Cases and Styling", () => {
+ it("should render textarea with focused colors", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true }, [
+ h("box", { flexDirection: "row" }, [
+ h("box", { width: 3, backgroundColor: "#2d2d2d" }, [h("Text", {}, ">")]),
+ h("box", { backgroundColor: "#1e1e1e", flexGrow: 1, paddingTop: 1, paddingBottom: 1 }, [
+ h("Textarea", {
+ initialValue: "Focused textarea",
+ backgroundColor: "#1e1e1e",
+ textColor: "#888888",
+ focusedBackgroundColor: "#2d2d2d",
+ focusedTextColor: "#ffffff",
+ flexShrink: 1,
+ }),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 10,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render empty textarea with placeholder in prompt layout", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true, borderColor: "#444444" }, [
+ h("box", { flexDirection: "row" }, [
+ h(
+ "box",
+ { width: 3, justifyContent: "center", alignItems: "center", backgroundColor: "#2d2d2d" },
+ [h("Text", { attributes: TextAttributes.BOLD, fg: "#00ff00" }, ">")],
+ ),
+ h("box", { paddingTop: 1, paddingBottom: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue: "",
+ placeholder: "Enter your prompt here...",
+ placeholderColor: "#666666",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ }),
+ ]),
+ h("box", { backgroundColor: "#1e1e1e", width: 1 }),
+ ]),
+ h("box", { flexDirection: "row" }, [h("Text", { fg: "#888888" }, "Ready to chat")]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 50,
+ height: 12,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render textarea with very long single line", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", { border: true }, [
+ h("box", { flexDirection: "row" }, [
+ h("box", { width: 3, backgroundColor: "#2d2d2d" }, [h("Text", {}, ">")]),
+ h("box", { backgroundColor: "#1e1e1e", flexGrow: 1, paddingTop: 1, paddingBottom: 1 }, [
+ h("Textarea", {
+ initialValue: "ThisIsAVeryLongLineWithNoSpacesThatWillWrapByCharacterWhenCharWrappingIsEnabled",
+ wrapMode: "char",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ }),
+ ]),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 40,
+ height: 15,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+
+ it("should render full prompt-like layout with all components", async () => {
+ const TestComponent = defineComponent({
+ setup() {
+ return () =>
+ h("box", {}, [
+ h("box", { border: true, borderColor: "#444444" }, [
+ h("box", { flexDirection: "row" }, [
+ h(
+ "box",
+ { width: 3, justifyContent: "center", alignItems: "center", backgroundColor: "#2d2d2d" },
+ [h("Text", { attributes: TextAttributes.BOLD, fg: "#00ff00" }, ">")],
+ ),
+ h("box", { paddingTop: 1, paddingBottom: 1, backgroundColor: "#1e1e1e", flexGrow: 1 }, [
+ h("Textarea", {
+ initialValue: "Explain how async/await works in JavaScript and provide some examples",
+ wrapMode: "word",
+ flexShrink: 1,
+ backgroundColor: "#1e1e1e",
+ textColor: "#ffffff",
+ cursorColor: "#00ff00",
+ }),
+ ]),
+ h("box", {
+ backgroundColor: "#1e1e1e",
+ width: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ }),
+ ]),
+ h("box", { flexDirection: "row", justifyContent: "space-between" }, [
+ h("Text", { flexShrink: 0, wrapMode: "none" }, [
+ h("Span", { style: { fg: "#888888" } }, "openai"),
+ " ",
+ h("Span", { style: { bold: true } }, "gpt-4-turbo"),
+ ]),
+ h("Text", {}, ["ctrl+p ", h("Span", { style: { fg: "#888888" } }, "commands")]),
+ ]),
+ ]),
+ h("box", { marginTop: 1 }, [
+ h(
+ "Text",
+ { fg: "#666666", wrapMode: "word" },
+ "Tip: Use arrow keys to navigate through history when cursor is at the start",
+ ),
+ ]),
+ ])
+ },
+ })
+
+ testSetup = await testRender(TestComponent, {
+ width: 70,
+ height: 20,
+ })
+
+ await testSetup.renderOnce()
+ const frame = testSetup.captureCharFrame()
+ expect(frame).toMatchSnapshot()
+ })
+ })
+})
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
index 2589b5c34..64df75a51 100644
--- a/packages/vue/tsconfig.json
+++ b/packages/vue/tsconfig.json
@@ -26,7 +26,7 @@
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
- "types": ["node"],
+ "types": ["node", "bun-types"],
"skipLibCheck": true
},
"include": ["src/**/*", "example/**/*", "types"],
diff --git a/packages/vue/tsconfig.typecheck.json b/packages/vue/tsconfig.typecheck.json
new file mode 100644
index 000000000..86ccaae6b
--- /dev/null
+++ b/packages/vue/tsconfig.typecheck.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["bun-types", "node"],
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "@opentui/core": ["../core/dist"],
+ "@opentui/core/*": ["../core/dist/*"]
+ }
+ }
+}
diff --git a/packages/vue/types/opentui.d.ts b/packages/vue/types/opentui.d.ts
index 8d58e7c13..cc8238b64 100644
--- a/packages/vue/types/opentui.d.ts
+++ b/packages/vue/types/opentui.d.ts
@@ -3,19 +3,38 @@ export {}
import type { DefineComponent } from "vue"
import type {
ASCIIFontOptions,
+ BaseRenderable,
BoxOptions,
+ CodeOptions,
+ DiffRenderableOptions,
InputRenderableOptions,
+ KeyEvent,
+ LineNumberOptions,
RenderableOptions,
+ RenderContext,
+ ScrollBoxOptions,
SelectOption,
SelectRenderableOptions,
StyledText,
TabSelectOption,
TabSelectRenderableOptions,
+ TextareaOptions,
TextChunk,
+ TextNodeOptions,
TextOptions,
- ScrollBoxOptions,
} from "@opentui/core"
+// ============================================================================
+// Core Type System
+// ============================================================================
+
+/** Base type for any renderable constructor */
+export type RenderableConstructor = new (
+ ctx: RenderContext,
+ options: any,
+) => TRenderable
+
+/** Properties that should not be included in the style prop */
type NonStyledProps = "buffered" | "live" | "enableLayout" | "selectable"
type ContainerProps = TOptions
@@ -24,6 +43,26 @@ type VueComponentProps = TOptions &
style?: Partial>
}
+/** Extract the options type from a renderable constructor */
+type ExtractRenderableOptions = TConstructor extends new (
+ ctx: RenderContext,
+ options: infer TOptions,
+) => any
+ ? TOptions
+ : never
+
+/** Convert renderable constructor to component props with proper style exclusions */
+export type ExtendedComponentProps = TConstructor extends new (
+ ctx: RenderContext,
+ options: infer TOptions,
+) => any
+ ? TOptions & { style?: Partial }
+ : never
+
+// ============================================================================
+// Built-in Component Props
+// ============================================================================
+
export type TextProps = Omit, "content"> & {
children?:
| string
@@ -61,6 +100,31 @@ export type TabSelectProps = VueComponentProps void
}
+export type TextareaProps = VueComponentProps & {
+ focused?: boolean
+ onKeyDown?: (event: KeyEvent) => void
+ onContentChange?: (content: string) => void
+ onCursorChange?: (position: { line: number; visualColumn: number }) => void
+}
+
+export type CodeProps = VueComponentProps
+
+export type DiffProps = VueComponentProps
+
+export type LineNumberProps = VueComponentProps & {
+ focused?: boolean
+}
+
+export type SpanProps = VueComponentProps
+
+export type LinkProps = SpanProps & {
+ href: string
+}
+
+// ============================================================================
+// Extended/Dynamic Component System
+// ============================================================================
+
export type ExtendedIntrinsicElements> = {
[TComponentName in keyof TComponentCatalogue]: ExtendedComponentProps
}
@@ -69,29 +133,28 @@ export interface OpenTUIComponents {
[componentName: string]: RenderableConstructor
}
-export function extend>(components: T): void
+export function extend>(components: T): void
declare module "@vue/runtime-core" {
export interface GlobalComponents extends ExtendedIntrinsicElements {
- asciiFontRenderable: DefineComponent
- boxRenderable: DefineComponent
- inputRenderable: DefineComponent
- selectRenderable: DefineComponent
- tabSelectRenderable: DefineComponent
- textRenderable: DefineComponent
- scrollBoxRenderable: DefineComponent
- }
-}
-
-// Augment for JSX/TSX support in Vue
-declare module "@vue/runtime-dom" {
- export interface IntrinsicElementAttributes extends ExtendedIntrinsicElements {
- asciiFontRenderable: AsciiFontProps
- boxRenderable: BoxProps
- inputRenderable: InputProps
- selectRenderable: SelectProps
- tabSelectRenderable: TabSelectProps
- textRenderable: TextProps
- scrollBoxRenderable: ScrollBoxProps
+ "ascii-font": DefineComponent
+ box: DefineComponent
+ Input: DefineComponent
+ Select: DefineComponent
+ "tab-select": DefineComponent
+ Text: DefineComponent
+ scrollbox: DefineComponent
+ Textarea: DefineComponent
+ Code: DefineComponent
+ diff: DefineComponent
+ "line-number": DefineComponent
+ Span: DefineComponent
+ Strong: DefineComponent
+ B: DefineComponent
+ Em: DefineComponent
+ I: DefineComponent
+ U: DefineComponent
+ Br: DefineComponent<{}>
+ A: DefineComponent
}
}