feat(runtime): Vue terminal renderer with Ink-aligned API

Custom Vue 3 renderer for terminal UIs, built on yoga-layout for
flexbox and chalk for styling.

Components: Box, Text, Newline, Spacer, Static, Transform
Composables: useExit, useInput, useFocus, useFocusManager,
  useStdin, useStdout, useStderr, useTerminalSize
Entry: createApp(root) → app.mount(options) → app.waitUntilExit()
Focus: Ink-aligned Tab/Shift+Tab/Escape with Focusable[] ring
Internal subpath: @vue-tui/runtime/internal (yogaNodeTracker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-24 18:13:33 +08:00
parent def33588e3
commit ff82f6e064
38 changed files with 2373 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# @vue-tui/runtime
Vue-idiomatic terminal renderer in the spirit of [React Ink](https://github.com/vadimdemedes/ink). Platform-specific runtime parallel to `@vue/runtime-dom`.
## Install
```bash
pnpm add @vue-tui/runtime vue
```
## Quickstart
```ts
import { defineComponent, h, ref } from "vue";
import { createApp, Box, Text, useInput } from "@vue-tui/runtime";
const Counter = defineComponent({
setup() {
const count = ref(0);
useInput((input) => {
if (input === "+") count.value++;
if (input === "-") count.value--;
});
return () => h(Box, null, h(Text, null, `Count: ${count.value}`));
},
});
createApp(Counter).mount();
```
## API
Single entry point — `createApp(root, rootProps?)`. Returns a `TuiApp` (`Omit<VueApp<TuiNode>, "mount">` plus four TUI methods).
```ts
import { createApp } from "@vue-tui/runtime";
const app = createApp(App, { initialState }).use(myPlugin).provide(themeKey, dark);
app.mount(); // all defaults (process.*)
app.mount({ debug: true }); // just flags
app.mount({ stdout: customWritable }); // partial stream override
app.mount({ stdout, stdin, stderr }); // explicit streams (testing)
await app.waitUntilExit();
app.unmount(); // optional — see "Cleanup" below
```
### Cleanup
`mount()` registers a `process.on("exit")` listener that runs the same teardown
as `unmount()`. So you only need `app.unmount()` if you want to tear down the
app before the process is ready to exit (mid-program, in tests, or when one
process hosts multiple UIs sequentially). For a normal CLI that exits when the
user quits or the script ends, cleanup happens automatically.
### `TuiApp` interface
```ts
interface TuiApp extends VueApp<TuiNode> {
mount(options?: MountOptions): ComponentPublicInstance;
unmount(): void;
waitUntilExit(): Promise<void>;
}
interface MountOptions {
stdout?: NodeJS.WriteStream; // default: process.stdout
stdin?: NodeJS.ReadStream; // default: process.stdin
stderr?: NodeJS.WriteStream; // default: process.stderr
debug?: boolean; // default: false
exitOnCtrlC?: boolean; // default: true
}
```
All fields optional with per-field fallback. Matches Ink's `RenderOptions` shape.
### Components
`Box`, `Text`, `Newline`, `Spacer`, `Static`, `Transform`.
### Composables
`useExit`, `useInput`, `useFocus`, `useFocusManager`, `useStdin`, `useStdout`, `useStderr`, `useTerminalSize`.
Tab / Shift+Tab / Escape are handled automatically when any component uses `useFocus`. `useFocus` returns `{ isFocused, focus }` and manages raw mode. `useFocusManager` exposes `activeId` in addition to `focusNext` / `focusPrevious` / `focus` / `enableFocus` / `disableFocus`.
## Waiting on exit / handling errors
```ts
// Fire-and-forget (most common):
createApp(App).mount();
// Wait for the app to exit:
const app = createApp(App);
app.mount();
await app.waitUntilExit();
// Catch errors thrown from setup / render / useExit(err):
const app = createApp(App);
app.mount();
app.waitUntilExit().catch((err) => {
console.error(err);
process.exitCode = 1;
});
```
See `docs/superpowers/specs/2026-05-18-vue-tui-core-design.md` for the full design.
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@vue-tui/runtime",
"version": "0.0.0",
"description": "Vue-idiomatic terminal renderer in the spirit of React Ink.",
"license": "MIT",
"files": [
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.mjs",
"./internal": "./dist/internal.mjs",
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"test": "vp test",
"check": "vp check",
"prepublishOnly": "vp run build"
},
"dependencies": {
"@vue/runtime-core": "^3.4.0",
"chalk": "^5.3.0",
"cli-boxes": "^3.0.0",
"log-update": "^6.0.0",
"slice-ansi": "^7.1.0",
"string-width": "^7.2.0",
"wrap-ansi": "^9.0.0",
"yoga-layout": "^3.1.0"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@vitejs/plugin-vue-jsx": "catalog:",
"typescript": "^6.0.3",
"vite-plus": "^0.1.20",
"vue": "^3.4.0"
},
"peerDependencies": {
"vue": "^3.4.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { defineComponent, h, type PropType } from "vue";
type Spacing = number;
type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";
type FlexWrap = "nowrap" | "wrap" | "wrap-reverse";
type Align = "flex-start" | "center" | "flex-end" | "stretch";
type AlignSelf = "auto" | "flex-start" | "center" | "flex-end";
type Justify =
| "flex-start"
| "center"
| "flex-end"
| "space-between"
| "space-around"
| "space-evenly";
type BorderStyle =
| "single"
| "double"
| "round"
| "bold"
| "singleDouble"
| "doubleSingle"
| "classic"
| "arrow";
export const Box = defineComponent({
name: "Box",
props: {
flexDirection: String as PropType<FlexDirection>,
flexGrow: Number,
flexShrink: Number,
flexBasis: [Number, String],
flexWrap: String as PropType<FlexWrap>,
alignItems: String as PropType<Align>,
alignSelf: String as PropType<AlignSelf>,
justifyContent: String as PropType<Justify>,
gap: Number,
columnGap: Number,
rowGap: Number,
width: [Number, String],
height: [Number, String],
minWidth: [Number, String],
minHeight: [Number, String],
margin: Number as PropType<Spacing>,
marginX: Number,
marginY: Number,
marginTop: Number,
marginBottom: Number,
marginLeft: Number,
marginRight: Number,
padding: Number,
paddingX: Number,
paddingY: Number,
paddingTop: Number,
paddingBottom: Number,
paddingLeft: Number,
paddingRight: Number,
borderStyle: String as PropType<BorderStyle>,
borderColor: [String, Array],
borderDimColor: Boolean,
borderTop: { type: Boolean, default: true },
borderBottom: { type: Boolean, default: true },
borderLeft: { type: Boolean, default: true },
borderRight: { type: Boolean, default: true },
borderTopColor: [String, Array],
borderBottomColor: [String, Array],
borderLeftColor: [String, Array],
borderRightColor: [String, Array],
backgroundColor: [String, Array],
overflow: String as PropType<"visible" | "hidden">,
overflowX: String as PropType<"visible" | "hidden">,
overflowY: String as PropType<"visible" | "hidden">,
display: String as PropType<"flex" | "none">,
},
setup(props, { slots }) {
return () => h("box", props as never, slots.default?.());
},
});
@@ -0,0 +1,9 @@
import { defineComponent, h } from "vue";
export const Newline = defineComponent({
name: "Newline",
props: { count: { type: Number, default: 1 } },
setup(props) {
return () => h("virtual-text", {}, "\n".repeat(props.count));
},
});
@@ -0,0 +1,8 @@
import { defineComponent, h } from "vue";
export const Spacer = defineComponent({
name: "Spacer",
setup() {
return () => h("box", { flexGrow: 1, flexShrink: 1 });
},
});
+16
View File
@@ -0,0 +1,16 @@
import { defineComponent, h } from "vue";
export const Static = defineComponent({
name: "Static",
props: {
items: { type: Array, required: true },
},
setup(props, { slots }) {
return () =>
h(
"static",
{},
(props.items as unknown[]).map((item, index) => slots.default?.({ item, index })),
);
},
});
+36
View File
@@ -0,0 +1,36 @@
import { defineComponent, getCurrentInstance, h, type PropType } from "vue";
type Color = string | [number, number, number];
type WrapMode = "wrap" | "truncate" | "truncate-end" | "truncate-middle" | "truncate-start";
export const Text = defineComponent({
name: "Text",
props: {
color: [String, Array] as PropType<Color>,
backgroundColor: [String, Array] as PropType<Color>,
dimColor: Boolean,
bold: Boolean,
italic: Boolean,
underline: Boolean,
strikethrough: Boolean,
inverse: Boolean,
wrap: { type: String as PropType<WrapMode>, default: "wrap" },
},
setup(props, { slots }) {
return () => {
const insideText = isInsideText();
const elementType = insideText ? "virtual-text" : "text";
return h(elementType, props as never, slots.default?.());
};
},
});
function isInsideText(): boolean {
let parent = getCurrentInstance()?.parent;
while (parent) {
const name = parent.type && (parent.type as { name?: string }).name;
if (name === "Text") return true;
parent = parent.parent;
}
return false;
}
@@ -0,0 +1,13 @@
import { defineComponent, h, type PropType } from "vue";
type TransformFn = (line: string, lineIndex: number) => string;
export const Transform = defineComponent({
name: "Transform",
props: {
transform: { type: Function as PropType<TransformFn>, required: true },
},
setup(props, { slots }) {
return () => h("transform", { transform: props.transform }, slots.default?.());
},
});
@@ -0,0 +1,13 @@
import { inject } from "vue";
import { AppContextKey } from "../context.ts";
/**
* Returns the exit function so a component can end the app from inside the
* tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); call
* with no args to resolve cleanly.
*/
export function useExit(): (error?: Error) => void {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree");
return ctx.exit;
}
@@ -0,0 +1,75 @@
import {
inject,
onScopeDispose,
shallowRef,
toValue,
watch,
type MaybeRefOrGetter,
type ShallowRef,
} from "vue";
import { FocusContextKey, StdinContextKey } from "../context.ts";
let nextAutoId = 0;
export interface UseFocusOptions {
autoFocus?: boolean;
isActive?: MaybeRefOrGetter<boolean>;
id?: string;
}
export function useFocus(options: UseFocusOptions = {}): {
isFocused: ShallowRef<boolean>;
focus: (id: string) => void;
} {
const ctx = inject(FocusContextKey);
const stdin = inject(StdinContextKey);
if (!ctx) throw new Error("useFocus() must be called inside a vue-tui render tree");
const id = options.id ?? `__auto-${nextAutoId++}`;
const isFocused = shallowRef(false);
const unsubscribe = ctx.subscribe(id, (v) => {
isFocused.value = v;
});
ctx.add(id, { autoFocus: options.autoFocus });
const isActive = options.isActive ?? true;
let rawModeAcquired = false;
function acquireRaw() {
if (!rawModeAcquired && stdin) {
stdin.acquireRawMode();
rawModeAcquired = true;
}
}
function releaseRaw() {
if (rawModeAcquired && stdin) {
stdin.releaseRawMode();
rawModeAcquired = false;
}
}
watch(
() => toValue(isActive),
(active) => {
if (active) {
ctx.activate(id);
acquireRaw();
} else {
ctx.deactivate(id);
releaseRaw();
}
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(() => {
unsubscribe();
ctx.remove(id);
releaseRaw();
});
return { isFocused, focus: ctx.focus };
}
@@ -0,0 +1,22 @@
import { inject, type ShallowRef } from "vue";
import { FocusContextKey } from "../context.ts";
export function useFocusManager(): {
enableFocus: () => void;
disableFocus: () => void;
focusNext: () => void;
focusPrevious: () => void;
focus: (id: string) => void;
activeId: ShallowRef<string | null>;
} {
const ctx = inject(FocusContextKey);
if (!ctx) throw new Error("useFocusManager() must be called inside a vue-tui render tree");
return {
enableFocus: ctx.enableFocus.bind(ctx),
disableFocus: ctx.disableFocus.bind(ctx),
focusNext: ctx.focusNext.bind(ctx),
focusPrevious: ctx.focusPrevious.bind(ctx),
focus: ctx.focus.bind(ctx),
activeId: ctx.activeIdRef,
};
}
@@ -0,0 +1,51 @@
import { inject, onScopeDispose, toValue, watch, type MaybeRefOrGetter } from "vue";
import { AppContextKey, StdinContextKey } from "../context.ts";
import { parseKey, type Key } from "../io/key-parser.ts";
export type { Key };
export interface UseInputOptions {
isActive?: MaybeRefOrGetter<boolean>;
}
export function useInput(
handler: (input: string, key: Key) => void,
options: UseInputOptions = {},
): void {
const app = inject(AppContextKey);
const stdin = inject(StdinContextKey);
if (!app || !stdin) throw new Error("useInput() must be called inside a vue-tui render tree");
let attached = false;
function listener(chunk: string) {
const { input, key } = parseKey(chunk);
handler(input, key);
}
function attach() {
if (attached) return;
attached = true;
stdin!.acquireRawMode();
stdin!.internal_eventEmitter.on("data", listener);
}
function detach() {
if (!attached) return;
attached = false;
stdin!.internal_eventEmitter.off("data", listener);
stdin!.releaseRawMode();
}
const isActive = options.isActive ?? true;
watch(
() => toValue(isActive),
(value) => {
if (value) attach();
else detach();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(detach);
}
@@ -0,0 +1,8 @@
import { inject } from "vue";
import { AppContextKey } from "../context.ts";
export function useStderr(): { stderr: NodeJS.WriteStream; write: (data: string) => void } {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useStderr() must be called inside a vue-tui render tree");
return { stderr: ctx.stderr, write: (data) => ctx.stderr.write(data) };
}
@@ -0,0 +1,8 @@
import { inject } from "vue";
import { StdinContextKey, type StdinContext } from "../context.ts";
export function useStdin(): StdinContext {
const ctx = inject(StdinContextKey);
if (!ctx) throw new Error("useStdin() must be called inside a vue-tui render tree");
return ctx;
}
@@ -0,0 +1,8 @@
import { inject } from "vue";
import { AppContextKey } from "../context.ts";
export function useStdout(): { stdout: NodeJS.WriteStream; write: (data: string) => void } {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useStdout() must be called inside a vue-tui render tree");
return { stdout: ctx.stdout, write: (data) => ctx.stdout.write(data) };
}
@@ -0,0 +1,16 @@
import { inject, onScopeDispose, shallowRef, type ShallowRef } from "vue";
import { AppContextKey } from "../context.ts";
export function useTerminalSize(): { columns: ShallowRef<number>; rows: ShallowRef<number> } {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useTerminalSize() must be called inside a vue-tui render tree");
const columns = shallowRef(ctx.stdout.columns ?? 80);
const rows = shallowRef(ctx.stdout.rows ?? 24);
function onResize() {
columns.value = ctx!.stdout.columns ?? 80;
rows.value = ctx!.stdout.rows ?? 24;
}
ctx.stdout.on("resize", onResize);
onScopeDispose(() => ctx.stdout.off("resize", onResize));
return { columns, rows };
}
+42
View File
@@ -0,0 +1,42 @@
import type { InjectionKey, ShallowRef } from "vue";
import type { EventEmitter } from "node:events";
export interface AppContext {
exit: (error?: Error) => void;
stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream;
stdin: NodeJS.ReadStream;
debug: boolean;
isRawModeSupported: boolean;
setRawMode: (mode: boolean) => void;
}
export interface FocusContext {
activeId: string | null;
activeIdRef: ShallowRef<string | null>;
enabled: boolean;
enableFocus: () => void;
disableFocus: () => void;
focusNext: () => void;
focusPrevious: () => void;
focus: (id: string) => void;
blur: () => void;
add: (id: string, options: { autoFocus?: boolean }) => void;
remove: (id: string) => void;
activate: (id: string) => void;
deactivate: (id: string) => void;
subscribe: (id: string, fn: (focused: boolean) => void) => () => void;
}
export interface StdinContext {
stdin: NodeJS.ReadStream;
setRawMode: (mode: boolean) => void;
isRawModeSupported: boolean;
internal_eventEmitter: EventEmitter;
acquireRawMode: () => void;
releaseRawMode: () => void;
}
export const AppContextKey: InjectionKey<AppContext> = Symbol("vue-tui:app");
export const FocusContextKey: InjectionKey<FocusContext> = Symbol("vue-tui:focus");
export const StdinContextKey: InjectionKey<StdinContext> = Symbol("vue-tui:stdin");
+245
View File
@@ -0,0 +1,245 @@
import { type RendererOptions } from "@vue/runtime-core";
import {
createBox,
createComment as createCommentNode,
createStatic,
createText,
createTextLeaf,
createTransform,
createVirtualText,
isContainer,
type TuiContainer,
type TuiNode,
} from "./nodes.ts";
import {
attachYoga,
detachYoga,
insertYogaChild,
removeYogaChild,
applyYogaProp,
isYogaProp,
bindTextMeasure,
markTextDirty,
} from "./yoga.ts";
export interface TtyRendererOptions {
onCommit: () => void;
}
const STYLE_PROPS = new Set([
"color",
"backgroundColor",
"dimColor",
"bold",
"italic",
"underline",
"strikethrough",
"inverse",
"wrap",
// Border visual style — also a yoga prop (sets border widths); stored here
// so the paint pass can look up borderStyle from el.props.
"borderStyle",
"borderColor",
"borderDimColor",
"borderTopColor",
"borderBottomColor",
"borderLeftColor",
"borderRightColor",
// Per-edge toggles are dual: yoga uses them to size border space, paint
// uses them to decide which edges to draw.
"borderTop",
"borderBottom",
"borderLeft",
"borderRight",
]);
export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNode, TuiNode> {
const { onCommit } = options;
function createElement(type: string): TuiNode {
switch (type) {
case "box": {
const n = createBox();
attachYoga(n);
return n;
}
case "text": {
const n = createText();
attachYoga(n);
bindTextMeasure(n);
return n;
}
case "virtual-text":
return createVirtualText();
case "static": {
const n = createStatic();
attachYoga(n);
return n;
}
case "transform":
return createTransform((line) => line); // overwritten by patchProp
default:
throw new Error(`Unknown vue-tui element type: ${type}`);
}
}
function createTextNode(text: string): TuiNode {
return createTextLeaf(text);
}
function setText(node: TuiNode, text: string): void {
if (node.type !== "text-leaf") {
throw new Error(`Cannot setText on ${node.type}`);
}
node.value = text;
// Bubble dirty up to nearest TuiText so yoga remeasures.
let p = node.parent;
while (p && p.type !== "text") p = p.parent;
if (p) markTextDirty(p);
onCommit();
}
function setElementText(el: TuiNode, text: string): void {
if (!isContainer(el)) return;
// Remove existing children first (copy since remove mutates the array).
for (const child of Array.from(el.children)) remove(child);
insert(createTextLeaf(text), el, null);
if (el.type === "text") {
markTextDirty(el);
}
}
function insert(child: TuiNode, parent: TuiNode, anchor: TuiNode | null): void {
if (!isContainer(parent)) {
throw new Error(`Cannot insert into ${parent.type}`);
}
const parentC = parent as TuiContainer;
// Move semantics: if the child is already mounted (Vue's keyed reorder
// emits insert(existingChild, parent, newAnchor) without a prior remove),
// detach it from its current DOM and yoga positions before re-inserting.
if (child.parent) {
const oldParent = child.parent;
const oldIdx = oldParent.children.indexOf(child as never);
if (oldIdx >= 0) oldParent.children.splice(oldIdx, 1);
removeYogaChild(oldParent, child);
}
const idx = anchor ? parentC.children.indexOf(anchor as never) : parentC.children.length;
parentC.children.splice(idx < 0 ? parentC.children.length : idx, 0, child as never);
child.parent = parentC as never;
insertYogaChild(parentC, child, idx);
onCommit();
}
function remove(child: TuiNode): void {
const parent = child.parent;
if (!parent) return;
const idx = parent.children.indexOf(child as never);
if (idx >= 0) parent.children.splice(idx, 1);
removeYogaChild(parent, child);
// Free yoga nodes for this subtree (descendants first, then this node).
freeSubtreeYoga(child);
child.parent = null as never;
onCommit();
}
/** Recursively free yoga nodes for all yoga-carrying descendants, then the node itself. */
function freeSubtreeYoga(node: TuiNode): void {
if (isContainer(node)) {
for (const child of (node as { children: TuiNode[] }).children) {
freeSubtreeYoga(child);
}
}
if (node.type === "box" || node.type === "text" || node.type === "static") {
detachYoga(node);
}
}
function parentNode(node: TuiNode): TuiNode | null {
return node.parent ?? null;
}
function nextSibling(node: TuiNode): TuiNode | null {
const p = node.parent;
if (!p) return null;
const i = p.children.indexOf(node as never);
if (i < 0) return null;
return (p.children[i + 1] as TuiNode | undefined) ?? null;
}
function patchProp(el: TuiNode, key: string, _prev: unknown, next: unknown): void {
if (el.type === "transform") {
if (key === "transform" && typeof next === "function") {
el.transform = next as (line: string, idx: number) => string;
}
onCommit();
return;
}
if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") {
if (isYogaProp(key)) {
applyYogaProp(el, key, next);
// Some yoga props also need to be stored in el.props for the paint pass.
if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
}
// Special case: borderStyle resets all four yoga border-edge widths to
// 1 (or 0). If per-edge toggles (borderTop/Bottom/Left/Right) were
// already applied before this patch, their values were clobbered.
// Re-apply any per-edge toggles that are stored in el.props so that
// yoga reflects the user's explicit per-edge settings.
//
// Only re-apply when `next` is truthy (i.e. a border style is actually
// being set). When borderStyle is cleared/undefined, applyYogaProp sets
// all edges to 0 which is the correct final state — there is nothing to
// restore, and re-applying per-edge defaults (e.g. borderTop:true from
// Box component defaults) would incorrectly reserve border space even
// though no border is drawn.
if (key === "borderStyle" && next) {
const props = (el as { props: Record<string, unknown> }).props;
for (const edge of ["borderTop", "borderBottom", "borderLeft", "borderRight"] as const) {
if (props[edge] !== undefined) {
applyYogaProp(el, edge, props[edge]);
}
}
}
} else if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
} else if (key === "key" || key === "ref" || key.startsWith("on")) {
// Reserved by Vue / event keys, ignore.
} else if (process.env["NODE_ENV"] !== "production") {
// eslint-disable-next-line no-console
console.warn(`[vue-tui] unknown prop "${key}" on <${el.type}>`);
}
onCommit();
return;
}
if (el.type === "virtual-text" && STYLE_PROPS.has(key)) {
(el.props as Record<string, unknown>)[key] = next;
onCommit();
}
}
const nodeOps: RendererOptions<TuiNode, TuiNode> = {
createElement: createElement as never,
createText: createTextNode as never,
createComment: (text: string) => createCommentNode(text) as never,
setText: setText as never,
setElementText: setElementText as never,
patchProp: patchProp as never,
insert: insert as never,
remove: remove as never,
parentNode: parentNode as never,
nextSibling: nextSibling as never,
querySelector: () => null,
setScopeId: () => {},
cloneNode: () => {
throw new Error("cloneNode not supported by @vue-tui/runtime");
},
insertStaticContent: () => {
throw new Error("insertStaticContent not supported by @vue-tui/runtime");
},
};
return nodeOps;
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "vite-plus/test";
import { createBox, createTextLeaf, createTransform, isContainer } from "./nodes.ts";
test("createBox returns shape with empty children + paintDirty true", () => {
const box = createBox();
expect(box.type).toBe("box");
expect(box.children).toEqual([]);
expect(box.paintDirty).toBe(true);
expect(box.parent).toBe(null);
expect(box.props).toEqual({});
});
test("createTextLeaf carries its value", () => {
const leaf = createTextLeaf("hello");
expect(leaf.type).toBe("text-leaf");
expect(leaf.value).toBe("hello");
expect(leaf.parent).toBe(null);
});
test("createTransform stores its transform function", () => {
const fn = (line: string) => line.toUpperCase();
const node = createTransform(fn);
expect(node.type).toBe("transform");
expect(node.transform).toBe(fn);
});
test("isContainer rejects text-leaf and accepts box", () => {
expect(isContainer(createBox())).toBe(true);
expect(isContainer(createTextLeaf("x"))).toBe(false);
});
+159
View File
@@ -0,0 +1,159 @@
import type { AppContext } from "../context.ts";
import type { Node as YogaNode } from "yoga-layout";
export type YogaNodeRef = YogaNode;
export interface BoxProps {
[k: string]: unknown;
}
export interface TextProps {
color?: unknown;
backgroundColor?: unknown;
dimColor?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
wrap?: "wrap" | "truncate" | "truncate-end" | "truncate-middle" | "truncate-start";
}
interface NodeBase {
parent: TuiContainer | null;
}
export interface TuiRoot extends NodeBase {
type: "root";
parent: null;
children: TuiNode[];
yoga: YogaNodeRef;
appContext: AppContext;
}
export interface TuiBox extends NodeBase {
type: "box";
children: TuiNode[];
yoga: YogaNodeRef;
props: BoxProps;
paintDirty: boolean;
}
export interface TuiText extends NodeBase {
type: "text";
children: TuiInlineNode[];
yoga: YogaNodeRef;
props: TextProps;
measuredCache?: string;
}
export interface TuiVirtualText extends NodeBase {
type: "virtual-text";
parent: TuiText | TuiVirtualText | null;
children: TuiInlineNode[];
props: TextProps;
}
export interface TuiTextLeaf extends NodeBase {
type: "text-leaf";
parent: TuiText | TuiVirtualText | null;
value: string;
}
/** Placeholder comment node used by Vue's renderer for v-if / null renders. */
export interface TuiComment extends NodeBase {
type: "comment";
value: string;
}
export interface TuiStatic extends NodeBase {
type: "static";
children: TuiNode[];
yoga: YogaNodeRef;
writtenCount: number;
}
export interface TuiTransform extends NodeBase {
type: "transform";
children: TuiNode[];
transform: (line: string, lineIndex: number) => string;
}
export type TuiInlineNode = TuiVirtualText | TuiTextLeaf;
export type TuiContainer = TuiRoot | TuiBox | TuiStatic | TuiTransform | TuiText | TuiVirtualText;
export type TuiNode = TuiContainer | TuiTextLeaf | TuiComment;
// Constructors take the bare minimum and leave yoga binding to yoga.ts.
// The `yoga` field is set to a sentinel and replaced by `attachYoga(node)`.
const UNATTACHED_YOGA = Symbol("vue-tui:yoga-unattached") as unknown as YogaNodeRef;
export function createRoot(appContext: AppContext): TuiRoot {
return {
type: "root",
parent: null,
children: [],
yoga: UNATTACHED_YOGA,
appContext,
};
}
export function createBox(): TuiBox {
return {
type: "box",
parent: null,
children: [],
yoga: UNATTACHED_YOGA,
props: {},
paintDirty: true,
};
}
export function createText(): TuiText {
return {
type: "text",
parent: null,
children: [],
yoga: UNATTACHED_YOGA,
props: {},
};
}
export function createVirtualText(): TuiVirtualText {
return {
type: "virtual-text",
parent: null,
children: [],
props: {},
};
}
export function createTextLeaf(value: string): TuiTextLeaf {
return { type: "text-leaf", parent: null, value };
}
export function createStatic(): TuiStatic {
return {
type: "static",
parent: null,
children: [],
yoga: UNATTACHED_YOGA,
writtenCount: 0,
};
}
export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform {
return {
type: "transform",
parent: null,
children: [],
transform: fn,
};
}
export function createComment(value: string): TuiComment {
return { type: "comment", parent: null, value };
}
export function isContainer(node: TuiNode): node is TuiContainer {
return node.type !== "text-leaf" && node.type !== "comment";
}
@@ -0,0 +1,34 @@
import { expect, test } from "vite-plus/test";
import { createText, createTextLeaf, createVirtualText } from "./nodes.ts";
import { flattenLeaves, wrapText } from "./text-measure.ts";
test("flattenLeaves concatenates a flat text node", () => {
const t = createText();
const a = createTextLeaf("hello ");
const b = createTextLeaf("world");
a.parent = t;
b.parent = t;
t.children = [a, b];
expect(flattenLeaves(t)).toBe("hello world");
});
test("flattenLeaves recurses into virtual-text", () => {
const t = createText();
const v = createVirtualText();
const a = createTextLeaf("a");
const b = createTextLeaf("b");
v.children = [b];
b.parent = v;
v.parent = t;
t.children = [a, v];
a.parent = t;
expect(flattenLeaves(t)).toBe("ab");
});
test("wrapText splits on width", () => {
expect(wrapText("hello world", 5, "wrap")).toEqual(["hello", "world"]);
});
test("wrapText truncate-end cuts with ellipsis", () => {
expect(wrapText("abcdefgh", 5, "truncate-end")).toEqual(["abcd…"]);
});
+58
View File
@@ -0,0 +1,58 @@
import sliceAnsi from "slice-ansi";
import stringWidth from "string-width";
import wrapAnsi from "wrap-ansi";
import type { TextProps, TuiText, TuiVirtualText } from "./nodes.ts";
export function flattenLeaves(node: TuiText | TuiVirtualText): string {
let out = "";
for (const child of node.children) {
if (child.type === "text-leaf") {
out += child.value;
} else {
out += flattenLeaves(child);
}
}
return out;
}
export type WrapMode = NonNullable<TextProps["wrap"]>;
export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] {
if (width <= 0) return [""];
if (mode === "wrap") {
return wrapAnsi(text, width, { hard: true, trim: true, wordWrap: true }).split("\n");
}
// truncate variants: collapse newlines, then slice from the appropriate side.
const single = text.replace(/\n/g, " ");
if (stringWidth(single) <= width) return [single];
const ellipsis = "…";
const room = Math.max(0, width - stringWidth(ellipsis));
switch (mode) {
case "truncate":
case "truncate-end":
return [sliceAnsi(single, 0, room) + ellipsis];
case "truncate-start":
return [ellipsis + sliceAnsi(single, stringWidth(single) - room)];
case "truncate-middle": {
const half = Math.floor(room / 2);
const left = sliceAnsi(single, 0, half);
const right = sliceAnsi(single, stringWidth(single) - (room - half));
return [left + ellipsis + right];
}
}
}
export function measureText(
text: string,
width: number,
mode: WrapMode = "wrap",
): { width: number; height: number } {
const wrapped = wrapText(text, width, mode);
return {
width: wrapped.reduce((max, line) => Math.max(max, stringWidth(line)), 0),
height: wrapped.length,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { expect, test } from "vite-plus/test";
import { isYogaProp } from "./yoga.ts";
test("isYogaProp recognises layout props and rejects style props", () => {
expect(isYogaProp("padding")).toBe(true);
expect(isYogaProp("flexDirection")).toBe(true);
expect(isYogaProp("color")).toBe(false);
expect(isYogaProp("bold")).toBe(false);
});
+213
View File
@@ -0,0 +1,213 @@
import Yoga from "yoga-layout";
import type { Node as YogaNode, Align, FlexDirection, Justify, Wrap } from "yoga-layout";
import type { TuiBox, TuiContainer, TuiNode, TuiRoot, TuiStatic, TuiText } from "./nodes.ts";
type YogaCarrier = TuiRoot | TuiBox | TuiText | TuiStatic;
// --- yoga node lifecycle seam --------------------------------------------
let _createCount = 0;
let _freeCount = 0;
export function createYogaNode(): YogaNode {
_createCount++;
return Yoga.Node.create();
}
export function freeYogaNode(node: YogaNode): void {
_freeCount++;
node.free();
}
export const yogaNodeTracker = {
reset(): void {
_createCount = 0;
_freeCount = 0;
},
snapshot(): { created: number; freed: number; live: number } {
return {
created: _createCount,
freed: _freeCount,
live: _createCount - _freeCount,
};
},
};
// -------------------------------------------------------------------------
function hasYoga(node: TuiNode): node is YogaCarrier {
return (
node.type === "root" || node.type === "box" || node.type === "text" || node.type === "static"
);
}
export function attachYoga(node: YogaCarrier): void {
node.yoga = createYogaNode();
}
export function detachYoga(node: YogaCarrier): void {
freeYogaNode(node.yoga as YogaNode);
}
// Returns the yoga index a child should occupy when added to `parent`.
// Skips any siblings that don't carry a yoga node (virtual-text, transform).
function yogaIndexFor(parent: TuiContainer, child: TuiNode): number {
let yIdx = 0;
for (const sibling of parent.children) {
if (sibling === child) return yIdx;
if (hasYoga(sibling)) yIdx++;
}
return yIdx;
}
export function insertYogaChild(parent: TuiContainer, child: TuiNode, _domIndex: number): void {
if (!hasYoga(parent) || !hasYoga(child)) return;
const yIdx = yogaIndexFor(parent, child);
(parent.yoga as YogaNode).insertChild(child.yoga as YogaNode, yIdx);
}
export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
if (!hasYoga(parent) || !hasYoga(child)) return;
(parent.yoga as YogaNode).removeChild(child.yoga as YogaNode);
}
// --- prop application ----------------------------------------------------
const YOGA_PROP_SETTERS: Record<string, (n: YogaNode, v: unknown) => void> = {
width: (n, v) => n.setWidth(v as number | "auto" | `${number}%`),
height: (n, v) => n.setHeight(v as number | "auto" | `${number}%`),
minWidth: (n, v) => n.setMinWidth(v as number | `${number}%`),
minHeight: (n, v) => n.setMinHeight(v as number | `${number}%`),
flexGrow: (n, v) => n.setFlexGrow(v as number),
flexShrink: (n, v) => n.setFlexShrink(v as number),
flexBasis: (n, v) => n.setFlexBasis(v as number | "auto" | `${number}%`),
flexDirection: (n, v) => n.setFlexDirection(toFlexDirection(v as string)),
flexWrap: (n, v) => n.setFlexWrap(toFlexWrap(v as string)),
alignItems: (n, v) => n.setAlignItems(toAlign(v as string)),
alignSelf: (n, v) => n.setAlignSelf(toAlign(v as string)),
justifyContent: (n, v) => n.setJustifyContent(toJustify(v as string)),
gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v as number),
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v as number),
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v as number),
margin: (n, v) => n.setMargin(Yoga.EDGE_ALL, v as number),
marginX: (n, v) => {
n.setMargin(Yoga.EDGE_LEFT, v as number);
n.setMargin(Yoga.EDGE_RIGHT, v as number);
},
marginY: (n, v) => {
n.setMargin(Yoga.EDGE_TOP, v as number);
n.setMargin(Yoga.EDGE_BOTTOM, v as number);
},
marginTop: (n, v) => n.setMargin(Yoga.EDGE_TOP, v as number),
marginBottom: (n, v) => n.setMargin(Yoga.EDGE_BOTTOM, v as number),
marginLeft: (n, v) => n.setMargin(Yoga.EDGE_LEFT, v as number),
marginRight: (n, v) => n.setMargin(Yoga.EDGE_RIGHT, v as number),
padding: (n, v) => n.setPadding(Yoga.EDGE_ALL, v as number),
paddingX: (n, v) => {
n.setPadding(Yoga.EDGE_LEFT, v as number);
n.setPadding(Yoga.EDGE_RIGHT, v as number);
},
paddingY: (n, v) => {
n.setPadding(Yoga.EDGE_TOP, v as number);
n.setPadding(Yoga.EDGE_BOTTOM, v as number);
},
paddingTop: (n, v) => n.setPadding(Yoga.EDGE_TOP, v as number),
paddingBottom: (n, v) => n.setPadding(Yoga.EDGE_BOTTOM, v as number),
paddingLeft: (n, v) => n.setPadding(Yoga.EDGE_LEFT, v as number),
paddingRight: (n, v) => n.setPadding(Yoga.EDGE_RIGHT, v as number),
borderStyle: (n, v) => {
// Border occupies 1 cell on every side when a style is set.
const w = v ? 1 : 0;
n.setBorder(Yoga.EDGE_TOP, w);
n.setBorder(Yoga.EDGE_BOTTOM, w);
n.setBorder(Yoga.EDGE_LEFT, w);
n.setBorder(Yoga.EDGE_RIGHT, w);
},
borderTop: (n, v) => n.setBorder(Yoga.EDGE_TOP, v ? 1 : 0),
borderBottom: (n, v) => n.setBorder(Yoga.EDGE_BOTTOM, v ? 1 : 0),
borderLeft: (n, v) => n.setBorder(Yoga.EDGE_LEFT, v ? 1 : 0),
borderRight: (n, v) => n.setBorder(Yoga.EDGE_RIGHT, v ? 1 : 0),
display: (n, v) => n.setDisplay(v === "none" ? Yoga.DISPLAY_NONE : Yoga.DISPLAY_FLEX),
overflow: (n, v) => n.setOverflow(v === "hidden" ? Yoga.OVERFLOW_HIDDEN : Yoga.OVERFLOW_VISIBLE),
// Yoga does not support per-axis overflow; these are accepted silently.
overflowX: (_n, _v) => {},
overflowY: (_n, _v) => {},
};
function toFlexDirection(v: string): FlexDirection {
return {
row: Yoga.FLEX_DIRECTION_ROW,
"row-reverse": Yoga.FLEX_DIRECTION_ROW_REVERSE,
column: Yoga.FLEX_DIRECTION_COLUMN,
"column-reverse": Yoga.FLEX_DIRECTION_COLUMN_REVERSE,
}[v]!;
}
function toFlexWrap(v: string): Wrap {
return {
nowrap: Yoga.WRAP_NO_WRAP,
wrap: Yoga.WRAP_WRAP,
"wrap-reverse": Yoga.WRAP_WRAP_REVERSE,
}[v]!;
}
function toAlign(v: string): Align {
return {
auto: Yoga.ALIGN_AUTO,
"flex-start": Yoga.ALIGN_FLEX_START,
center: Yoga.ALIGN_CENTER,
"flex-end": Yoga.ALIGN_FLEX_END,
stretch: Yoga.ALIGN_STRETCH,
}[v]!;
}
function toJustify(v: string): Justify {
return {
"flex-start": Yoga.JUSTIFY_FLEX_START,
center: Yoga.JUSTIFY_CENTER,
"flex-end": Yoga.JUSTIFY_FLEX_END,
"space-between": Yoga.JUSTIFY_SPACE_BETWEEN,
"space-around": Yoga.JUSTIFY_SPACE_AROUND,
"space-evenly": Yoga.JUSTIFY_SPACE_EVENLY,
}[v]!;
}
export function isYogaProp(key: string): boolean {
return key in YOGA_PROP_SETTERS;
}
export function applyYogaProp(node: YogaCarrier, key: string, value: unknown): void {
const setter = YOGA_PROP_SETTERS[key];
if (!setter) return;
// Vue calls patchProp with `undefined` for every declared prop a user
// didn't set. Forwarding undefined to yoga's setters corrupts state:
// setAlignItems / setFlexDirection / setJustifyContent → 0 (AUTO/COLUMN/
// FLEX_START), and the dimension setters (setWidth, setMargin, setBorder,
// …) write NaN. Skip undefined so yoga keeps its documented defaults.
//
// Exception: borderStyle is the one prop with intentional undefined
// semantics — undefined means "no border", which the setter implements
// by zeroing all four edge widths.
if (value === undefined && key !== "borderStyle") return;
setter(node.yoga as YogaNode, value);
}
// --- text measure binding ------------------------------------------------
import { flattenLeaves, measureText } from "./text-measure.ts";
export function bindTextMeasure(text: TuiText): void {
text.yoga.setMeasureFunc((availableWidth) => {
const raw = flattenLeaves(text);
text.measuredCache = raw;
return measureText(raw, availableWidth, text.props.wrap ?? "wrap");
});
}
export function markTextDirty(text: TuiText): void {
text.yoga.markDirty();
}
+17
View File
@@ -0,0 +1,17 @@
export { createApp, type TuiApp, type MountOptions } from "./render.ts";
export { Box } from "./components/Box.ts";
export { Text } from "./components/Text.ts";
export { Newline } from "./components/Newline.ts";
export { Spacer } from "./components/Spacer.ts";
export { Static } from "./components/Static.ts";
export { Transform } from "./components/Transform.ts";
export { useExit } from "./composables/useExit.ts";
export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts";
export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts";
export { useFocusManager } from "./composables/useFocusManager.ts";
export { useStdin } from "./composables/useStdin.ts";
export { useStdout } from "./composables/useStdout.ts";
export { useStderr } from "./composables/useStderr.ts";
export { useTerminalSize } from "./composables/useTerminalSize.ts";
+1
View File
@@ -0,0 +1 @@
export { yogaNodeTracker } from "./host/yoga.ts";
@@ -0,0 +1,17 @@
import { PassThrough } from "node:stream";
import { expect, test } from "vite-plus/test";
import { createFrameWriter } from "./frame-writer.ts";
test("debug mode writes complete frames terminated by newline", () => {
const writes: string[] = [];
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stream, { columns: 80, rows: 24, isTTY: true });
stream.on("data", (chunk) => writes.push(chunk.toString()));
const writer = createFrameWriter(stream, { debug: true });
writer.write("hello");
writer.write("hello"); // identical frame skipped
writer.write("world");
expect(writes).toEqual(["hello\n", "world\n"]);
});
+35
View File
@@ -0,0 +1,35 @@
import { createLogUpdate } from "log-update";
export interface FrameWriter {
write: (frame: string) => void;
done: () => void;
clear: () => void;
}
export function createFrameWriter(
stream: NodeJS.WriteStream,
options: { debug?: boolean },
): FrameWriter {
let lastFrame = "";
const debug = options.debug ?? false;
const update = debug ? null : createLogUpdate(stream);
return {
write(frame: string) {
if (frame === lastFrame) return;
lastFrame = frame;
if (debug) {
stream.write(frame + "\n");
} else {
update!(frame);
}
},
done() {
if (update) update.done();
},
clear() {
lastFrame = "";
if (update) update.clear();
},
};
}
@@ -0,0 +1,16 @@
import { expect, test } from "vite-plus/test";
import { parseKey } from "./key-parser.ts";
test("Shift+Tab (\\x1b[Z) sets tab=true and shift=true", () => {
const result = parseKey("\x1b[Z");
expect(result.input).toBe("");
expect(result.key.tab).toBe(true);
expect(result.key.shift).toBe(true);
});
test("plain Tab (\\t) sets tab=true, shift=false", () => {
const result = parseKey("\t");
expect(result.input).toBe("");
expect(result.key.tab).toBe(true);
expect(result.key.shift).toBe(false);
});
+74
View File
@@ -0,0 +1,74 @@
export interface Key {
upArrow: boolean;
downArrow: boolean;
leftArrow: boolean;
rightArrow: boolean;
pageDown: boolean;
pageUp: boolean;
return: boolean;
escape: boolean;
ctrl: boolean;
shift: boolean;
tab: boolean;
backspace: boolean;
delete: boolean;
meta: boolean;
}
const empty: Key = {
upArrow: false,
downArrow: false,
leftArrow: false,
rightArrow: false,
pageDown: false,
pageUp: false,
return: false,
escape: false,
ctrl: false,
shift: false,
tab: false,
backspace: false,
delete: false,
meta: false,
};
export function parseKey(input: string): { input: string; key: Key } {
const key: Key = { ...empty };
if (input === "\r" || input === "\n") {
key.return = true;
return { input: "", key };
}
if (input === "\x1b") {
key.escape = true;
return { input: "", key };
}
if (input === "\t") {
key.tab = true;
return { input: "", key };
}
if (input === "\x7f" || input === "\b") {
key.backspace = true;
return { input: "", key };
}
if (input === "\x1b[A") return { input: "", key: { ...key, upArrow: true } };
if (input === "\x1b[B") return { input: "", key: { ...key, downArrow: true } };
if (input === "\x1b[C") return { input: "", key: { ...key, rightArrow: true } };
if (input === "\x1b[D") return { input: "", key: { ...key, leftArrow: true } };
if (input === "\x1b[5~") return { input: "", key: { ...key, pageUp: true } };
if (input === "\x1b[6~") return { input: "", key: { ...key, pageDown: true } };
if (input === "\x1b[3~") return { input: "", key: { ...key, delete: true } };
if (input === "\x1b[Z") return { input: "", key: { ...key, tab: true, shift: true } };
// Bare forms (no leading ESC) for test pipelines that strip it.
if (input === "[A") return { input: "", key: { ...key, upArrow: true } };
if (input === "[B") return { input: "", key: { ...key, downArrow: true } };
if (input === "[C") return { input: "", key: { ...key, rightArrow: true } };
if (input === "[D") return { input: "", key: { ...key, leftArrow: true } };
// Ctrl+letter -> codes 1..26
if (input.length === 1 && input.charCodeAt(0) >= 1 && input.charCodeAt(0) <= 26) {
return {
input: String.fromCharCode(input.charCodeAt(0) + 96),
key: { ...key, ctrl: true },
};
}
return { input, key };
}
+310
View File
@@ -0,0 +1,310 @@
import stringWidth from "string-width";
import cliBoxes from "cli-boxes";
import { applyChalk } from "./text-style.ts";
import Yoga from "yoga-layout";
import type {
TuiNode,
TuiContainer,
TextProps,
TuiText,
TuiVirtualText,
BoxProps,
} from "../host/nodes.ts";
import { createRoot as createIsoRoot } from "../host/nodes.ts";
import { wrapText } from "../host/text-measure.ts";
import { attachYoga, detachYoga } from "../host/yoga.ts";
export type Transformer = (line: string, lineIndex: number) => string;
interface WriteOp {
x: number;
y: number;
lines: string[];
transformers: Transformer[];
}
class Output {
readonly width: number;
readonly height: number;
private ops: WriteOp[] = [];
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
write(x: number, y: number, lines: string[], transformers: Transformer[]): void {
this.ops.push({ x, y, lines, transformers });
}
get(): string {
// Build a sparse grid of cells, write each op left-to-right.
const grid: string[][] = Array.from({ length: this.height }, () =>
Array.from({ length: this.width }, () => " "),
);
for (const op of this.ops) {
for (let lineIdx = 0; lineIdx < op.lines.length; lineIdx++) {
let line = op.lines[lineIdx]!;
for (const tf of op.transformers) line = tf(line, lineIdx);
placeLine(grid, op.x, op.y + lineIdx, line);
}
}
return grid.map((row) => row.join("").trimEnd()).join("\n");
}
}
function placeLine(grid: string[][], x: number, y: number, line: string): void {
if (y < 0 || y >= grid.length) return;
const row = grid[y]!;
// We walk the line as visual cells. ANSI sequences are kept attached to the
// cell that follows them; emoji/wide chars consume two cells.
let col = x;
let i = 0;
let pendingAnsi = "";
while (i < line.length && col < row.length) {
const ch = line[i]!;
// ANSI CSI sequence: ESC[...m — accumulate as a prefix for the next cell.
if (ch === "\x1b" && line[i + 1] === "[") {
const end = line.indexOf("m", i);
if (end >= 0) {
pendingAnsi += line.slice(i, end + 1);
i = end + 1;
continue;
}
}
const segment = ch;
const w = stringWidth(segment);
if (col >= 0 && col < row.length) {
row[col] = pendingAnsi + segment;
pendingAnsi = "";
}
if (w === 2 && col + 1 < row.length) row[col + 1] = "";
col += Math.max(1, w);
i++;
}
// If the line ended with a trailing escape (e.g. reset), attach it to the
// last written cell so it's not lost.
if (pendingAnsi && col > x) {
const lastCol = Math.min(col - 1, row.length - 1);
if (lastCol >= 0) row[lastCol] = (row[lastCol] ?? "") + pendingAnsi;
}
}
function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextProps = {}): string {
const merged: TextProps = { ...acc, ...node.props };
let out = "";
for (const child of node.children) {
if (child.type === "text-leaf") {
out += applyChalk(child.value, merged);
} else {
out += renderTextWithInlineStyles(child, merged);
}
}
return out;
}
type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes];
function drawBorder(
output: Output,
x: number,
y: number,
w: number,
h: number,
props: BoxProps,
transformers: Transformer[],
): void {
const style = props["borderStyle"] as string | undefined;
if (!style) return;
const chars = (cliBoxes as unknown as Record<string, BoxStyle | undefined>)[style];
if (!chars) return;
if (w < 2 || h < 2) return;
// Per-edge toggles default to true when borderStyle is set.
const top = props["borderTop"] !== false;
const bottom = props["borderBottom"] !== false;
const left = props["borderLeft"] !== false;
const right = props["borderRight"] !== false;
// Corners require both adjacent edges to be enabled; otherwise the
// adjacent edge character is used as a "stub" so the visible edge still
// terminates cleanly.
if (top) {
const tl = left ? chars.topLeft : chars.top;
const tr = right ? chars.topRight : chars.top;
output.write(x, y, [tl + chars.top.repeat(w - 2) + tr], transformers);
}
if (bottom) {
const bl = left ? chars.bottomLeft : chars.bottom;
const br = right ? chars.bottomRight : chars.bottom;
output.write(x, y + h - 1, [bl + chars.bottom.repeat(w - 2) + br], transformers);
}
for (let i = 1; i < h - 1; i++) {
if (left) output.write(x, y + i, [chars.left], transformers);
if (right) output.write(x + w - 1, y + i, [chars.right], transformers);
}
}
function fillBackground(
output: Output,
x: number,
y: number,
w: number,
h: number,
color: unknown,
transformers: Transformer[],
): void {
if (!color) return;
const line = applyChalk(" ".repeat(w), { backgroundColor: color });
for (let i = 0; i < h; i++) output.write(x, y + i, [line], transformers);
}
export function paint(root: TuiNode): string {
if (root.type !== "root") throw new Error("paint expects TuiRoot");
const layout = root.yoga.getComputedLayout();
const width = Math.max(1, Math.floor(layout.width));
const height = Math.max(1, Math.floor(layout.height));
const output = new Output(width, height);
paintNode(root, output, 0, 0, []);
return output.get();
}
function paintNode(
node: TuiNode,
output: Output,
x0: number,
y0: number,
transformers: Transformer[],
): void {
switch (node.type) {
case "root": {
for (const child of node.children) paintNode(child, output, x0, y0, transformers);
return;
}
case "box": {
const layout = node.yoga.getComputedLayout();
const x = x0 + layout.left;
const y = y0 + layout.top;
const w = Math.max(0, Math.floor(layout.width));
const h = Math.max(0, Math.floor(layout.height));
if (node.props["backgroundColor"]) {
fillBackground(output, x, y, w, h, node.props["backgroundColor"], transformers);
}
if (node.props["borderStyle"]) {
drawBorder(output, x, y, w, h, node.props, transformers);
}
for (const child of node.children) paintNode(child, output, x, y, transformers);
return;
}
case "text": {
const layout = node.yoga.getComputedLayout();
const text = renderTextWithInlineStyles(node);
const wrapped = wrapText(
text,
Math.max(1, Math.floor(layout.width)),
node.props.wrap ?? "wrap",
);
output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers);
return;
}
case "static": {
// Static is rendered through the static channel (written before frame), so
// it does not contribute to the dynamic frame paint.
return;
}
case "transform": {
const next = [...transformers, node.transform];
for (const child of node.children) paintNode(child, output, x0, y0, next);
return;
}
case "virtual-text":
case "text-leaf":
case "comment":
// virtual-text and text-leaf are handled inside renderTextWithInlineStyles.
// Comments are invisible.
return;
}
}
export function paintContainer(container: TuiContainer): string {
// Used by Static channel and tests.
if (container.type === "root") return paint(container);
throw new Error("paintContainer currently only supports root");
}
export function paintIsolated(nodes: TuiNode[], width: number): string {
const iso = createIsoRoot({} as never);
attachYoga(iso);
iso.yoga.setWidth(width);
// Track which nodes we successfully added to iso's yoga tree so we can
// remove them afterwards. Nodes that are already parented in another yoga
// tree are first removed from that parent before insertion.
//
// IMPORTANT: We deliberately do NOT mutate each node's DOM .parent field.
// The children remain logically owned by their original Static parent — only
// yoga parentage is temporarily transferred to iso for layout calculation.
// Mutating .parent would leave the original tree with broken back-links and
// cause renderer.remove() to skip yoga cleanup (seeing parent === null).
type YogaCarrier = { yoga: import("yoga-layout").Node };
const yogaAdded: Array<{
yc: YogaCarrier;
origParent: import("yoga-layout").Node | null;
origIndex: number;
}> = [];
// yIdx tracks only yoga-carrying nodes; DOM-only nodes (text-leaf, comment,
// fragment anchors) do not contribute a yoga slot and must not advance it.
let yIdx = 0;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!;
// Add to iso.children for paint() traversal, but do NOT change node.parent.
iso.children.push(node);
const yCarrier = node as unknown as YogaCarrier;
// Skip nodes that carry no yoga node (text-leaf, comment, fragment anchors).
if (!yCarrier.yoga || typeof yCarrier.yoga === "symbol") continue;
// If the node already has a yoga parent, temporarily remove it so we can
// re-insert it under iso for layout calculation.
const yParent = (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null })
.getParent
? (yCarrier.yoga as unknown as { getParent(): import("yoga-layout").Node | null }).getParent()
: null;
const origIndex = yParent ? findYogaIndex(yParent, yCarrier.yoga) : 0;
if (yParent) {
yParent.removeChild(yCarrier.yoga);
}
iso.yoga.insertChild(yCarrier.yoga, yIdx);
yogaAdded.push({ yc: yCarrier, origParent: yParent, origIndex });
yIdx++;
}
try {
iso.yoga.calculateLayout(width, undefined, Yoga.DIRECTION_LTR);
return paint(iso);
} finally {
// Restore yoga parents in reverse order so earlier indices remain stable.
for (const { yc, origParent, origIndex } of yogaAdded.slice().reverse()) {
iso.yoga.removeChild(yc.yoga);
if (origParent) {
origParent.insertChild(yc.yoga, origIndex);
}
}
// Remove children from iso without touching their .parent pointers — they
// still belong to the original Static node in the live DOM tree.
iso.children.length = 0;
detachYoga(iso);
}
}
function findYogaIndex(
parent: import("yoga-layout").Node,
child: import("yoga-layout").Node,
): number {
for (let i = 0; i < parent.getChildCount(); i++) {
if (parent.getChild(i) === child) return i;
}
return 0;
}
@@ -0,0 +1,21 @@
import type { TuiNode, TuiStatic } from "../host/nodes.ts";
import { paintIsolated } from "./paint.ts";
export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] {
if (root.type === "static") out.push(root);
if (root.type !== "text-leaf" && root.type !== "comment") {
const containerChildren = (root as { children: TuiNode[] }).children;
for (const child of containerChildren) findStatics(child, out);
}
return out;
}
export function flushStatic(root: TuiNode, stream: NodeJS.WriteStream): void {
for (const stat of findStatics(root)) {
const fresh = stat.children.slice(stat.writtenCount);
if (fresh.length === 0) continue;
const frame = paintIsolated(fresh, stream.columns ?? 80);
if (frame.length > 0) stream.write(frame + "\n");
stat.writtenCount = stat.children.length;
}
}
@@ -0,0 +1,53 @@
import chalk from "chalk";
import { expect, test } from "vite-plus/test";
import { applyChalk } from "./text-style.ts";
test("named color applies chalk method", () => {
const prev = chalk.level;
chalk.level = 1;
try {
expect(applyChalk("x", { color: "red" })).toBe(chalk.red("x"));
} finally {
chalk.level = prev;
}
});
test("hex color applies chalk.hex", () => {
const prev = chalk.level;
chalk.level = 1;
try {
expect(applyChalk("x", { color: "#ff0000" })).toBe(chalk.hex("#ff0000")("x"));
} finally {
chalk.level = prev;
}
});
test("rgb tuple applies chalk.rgb", () => {
const prev = chalk.level;
chalk.level = 1;
try {
expect(applyChalk("x", { color: [255, 0, 0] })).toBe(chalk.rgb(255, 0, 0)("x"));
} finally {
chalk.level = prev;
}
});
test("unknown color name falls back to no color", () => {
const prev = chalk.level;
chalk.level = 1;
try {
expect(applyChalk("x", { color: "not-a-real-color" })).toBe("x");
} finally {
chalk.level = prev;
}
});
test("multiple modifiers chain", () => {
const prev = chalk.level;
chalk.level = 1;
try {
expect(applyChalk("x", { bold: true, underline: true })).toBe(chalk.bold.underline("x"));
} finally {
chalk.level = prev;
}
});
+45
View File
@@ -0,0 +1,45 @@
import chalk, { type ChalkInstance } from "chalk";
import type { TextProps } from "../host/nodes.ts";
export function applyColor(
c: ChalkInstance,
color: string | [number, number, number],
bg: boolean,
): ChalkInstance {
if (Array.isArray(color)) {
return bg ? c.bgRgb(color[0], color[1], color[2]) : c.rgb(color[0], color[1], color[2]);
}
if (typeof color !== "string") return c;
if (color.startsWith("#")) return bg ? c.bgHex(color) : c.hex(color);
if (color.startsWith("rgb(")) {
const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
if (m) {
const [r, g, b] = [+m[1]!, +m[2]!, +m[3]!];
return bg ? c.bgRgb(r, g, b) : c.rgb(r, g, b);
}
}
if (color.startsWith("ansi(")) {
const n = +color.slice(5, -1);
return bg ? c.bgAnsi256(n) : c.ansi256(n);
}
const key = bg ? bgKey(color) : color;
const fn = (c as never as Record<string, ChalkInstance>)[key];
return fn ?? c;
}
function bgKey(name: string): string {
return "bg" + name.charAt(0).toUpperCase() + name.slice(1);
}
export function applyChalk(text: string, props: TextProps): string {
let style: ChalkInstance = chalk;
if (props.color) style = applyColor(style, props.color as never, false);
if (props.backgroundColor) style = applyColor(style, props.backgroundColor as never, true);
if (props.dimColor) style = style.dim;
if (props.bold) style = style.bold;
if (props.italic) style = style.italic;
if (props.underline) style = style.underline;
if (props.strikethrough) style = style.strikethrough;
if (props.inverse) style = style.inverse;
return style(text);
}
+406
View File
@@ -0,0 +1,406 @@
import Yoga from "yoga-layout";
import { type Component, type ComponentPublicInstance, type App as VueApp, shallowRef } from "vue";
import { createRenderer } from "@vue/runtime-core";
import { EventEmitter } from "node:events";
import { createRoot, type TuiRoot, type TuiNode } from "./host/nodes.ts";
import { attachYoga, detachYoga } from "./host/yoga.ts";
import { buildNodeOps } from "./host/node-ops.ts";
import { createCommitScheduler } from "./scheduler.ts";
import { paint } from "./paint/paint.ts";
import { flushStatic } from "./paint/static-channel.ts";
import { createFrameWriter } from "./io/frame-writer.ts";
import {
AppContextKey,
FocusContextKey,
StdinContextKey,
type AppContext,
type FocusContext,
type StdinContext,
} from "./context.ts";
export interface MountOptions {
stdout?: NodeJS.WriteStream;
stdin?: NodeJS.ReadStream;
stderr?: NodeJS.WriteStream;
debug?: boolean;
exitOnCtrlC?: boolean;
}
export interface TuiApp extends Omit<VueApp<TuiNode>, "mount"> {
mount(options?: MountOptions): ComponentPublicInstance;
waitUntilExit(): Promise<void>;
}
type RootProps = Record<string, unknown>;
export function createApp(root: Component, rootProps?: RootProps | null): TuiApp {
// exit promise — created at createApp time so waitUntilExit() works even
// before mount (it just hangs until mount + exit).
let exitResolve!: () => void;
let exitReject!: (e: Error) => void;
const exitPromise = new Promise<void>((res, rej) => {
exitResolve = res;
exitReject = rej;
});
exitPromise.catch(() => {});
let mountedRoot: TuiRoot | null = null;
let mountedWriter: ReturnType<typeof createFrameWriter> | null = null;
let mountedStdinController: StdinController | null = null;
let mountedAppContext: AppContext | null = null;
let mountedSigintHandler: (() => void) | null = null;
let mountedResizeHandler: (() => void) | null = null;
let mountedExitListener: (() => void) | null = null;
let mountedFocusListener: (() => void) | null = null;
let mountedDebug = false;
// The renderer's onCommit closure is wired at createApp time but only does
// real work after mount swaps in scheduler.schedule. One renderer per app
// even though it's not used until mount.
let scheduledCommit: () => void = () => {};
let teardownStarted = false;
function teardown() {
if (teardownStarted) return;
teardownStarted = true;
scheduledCommit = () => {};
try {
originalUnmount();
} catch {
// Vue's unmount may throw on double-unmount; swallow for idempotency.
}
if (mountedWriter && !mountedDebug) mountedWriter.done();
if (mountedRoot) detachYoga(mountedRoot);
if (mountedResizeHandler && mountedAppContext) {
mountedAppContext.stdout.off("resize", mountedResizeHandler);
}
if (mountedSigintHandler) {
process.off("SIGINT", mountedSigintHandler);
}
if (mountedExitListener) {
process.off("exit", mountedExitListener);
}
if (mountedFocusListener) {
mountedFocusListener();
}
if (mountedStdinController) {
mountedStdinController.dispose();
}
}
const renderer = createRenderer<TuiNode, TuiNode>(
buildNodeOps({ onCommit: () => scheduledCommit() }),
);
const baseApp = renderer.createApp(root, rootProps ?? undefined);
const originalMount = baseApp.mount.bind(baseApp);
const originalUnmount = baseApp.unmount.bind(baseApp);
const app = baseApp as unknown as TuiApp;
app.mount = function mount(options: MountOptions = {}): ComponentPublicInstance {
const stdout = options.stdout ?? process.stdout;
const stdin = options.stdin ?? process.stdin;
const stderr = options.stderr ?? process.stderr;
const debug = options.debug ?? false;
const exitOnCtrlC = options.exitOnCtrlC ?? true;
mountedDebug = debug;
const appContext: AppContext = {
exit(err?: Error) {
if (err) exitReject(err);
// Defer teardown to a microtask: exit() is frequently called from
// inside the Vue update cycle (useInput handler, setup(), errorHandler)
// and unmounting synchronously would tear Vue down mid-flush.
queueMicrotask(() => {
teardown();
exitResolve();
});
},
stdout,
stderr,
stdin,
debug,
isRawModeSupported: !!(stdin as { isTTY?: boolean }).isTTY,
setRawMode(mode: boolean) {
if (
typeof (stdin as { setRawMode?: (mode: boolean) => unknown }).setRawMode === "function"
) {
(stdin as { setRawMode: (mode: boolean) => unknown }).setRawMode(mode);
}
},
};
mountedAppContext = appContext;
const focusContext: FocusContext = createFocusController();
const stdinController = createStdinController(stdin, appContext);
mountedStdinController = stdinController;
const tuiRoot = createRoot(appContext);
attachYoga(tuiRoot);
tuiRoot.yoga.setWidth(stdout.columns ?? 80);
mountedRoot = tuiRoot;
const writer = createFrameWriter(stdout, { debug });
mountedWriter = writer;
function commit() {
flushStatic(tuiRoot, stdout);
const w = stdout.columns ?? 80;
tuiRoot.yoga.setWidth(w);
tuiRoot.yoga.calculateLayout(w, undefined, Yoga.DIRECTION_LTR);
const frame = paint(tuiRoot);
writer.write(frame);
}
const scheduler = createCommitScheduler(commit);
scheduledCommit = scheduler.schedule;
// Internal provides — set before the actual mount so components can inject
// them. User .use/.provide calls made earlier on the chain stay intact;
// our keys are Symbols so there's no collision risk.
baseApp.provide(AppContextKey, appContext);
baseApp.provide(FocusContextKey, focusContext);
baseApp.provide(StdinContextKey, stdinController);
let proxy: ComponentPublicInstance;
try {
proxy = originalMount(tuiRoot) as unknown as ComponentPublicInstance;
} catch (mountError) {
stdinController.dispose();
detachYoga(tuiRoot);
throw mountError;
}
// errorHandler installed AFTER mount so sync mount errors still throw normally.
// Async errors (Vue's flushJobs scheduler) get routed through appContext.exit
// instead of surfacing as unhandled rejections.
baseApp.config.errorHandler = (err) => {
appContext.exit(err instanceof Error ? err : new Error(String(err)));
};
// Built-in Tab / Shift+Tab / Escape focus navigation (matches Ink).
// Placed AFTER mount so a sync mount failure doesn't leak the listener.
const focusInputListener = (chunk: Buffer | string) => {
const data = chunk.toString();
if (data === "\t") focusContext.focusNext();
else if (data === "\x1b[Z") focusContext.focusPrevious();
else if (data === "\x1b") focusContext.blur();
};
stdin.on("data", focusInputListener);
mountedFocusListener = () => stdin.off("data", focusInputListener);
const onResize = () => scheduler.schedule();
stdout.on("resize", onResize);
mountedResizeHandler = onResize;
if (exitOnCtrlC) {
const handler = () => appContext.exit();
process.once("SIGINT", handler);
mountedSigintHandler = handler;
}
// Auto-cleanup on process exit (process.exit, event-loop drain, uncaught
// exception — anything that fires Node's 'exit' event). teardown() is
// sync and idempotent, safe to call from this hook. If the user already
// called unmount() / useExit(), this is a no-op.
const exitListener = () => teardown();
process.on("exit", exitListener);
mountedExitListener = exitListener;
return proxy;
};
app.unmount = function unmount(): void {
teardown();
exitResolve();
};
app.waitUntilExit = function waitUntilExit(): Promise<void> {
return exitPromise;
};
return app;
}
// --- Focus controller ----------------------------------------------------
interface Focusable {
readonly id: string;
isActive: boolean;
}
function createFocusController(): FocusContext {
const focusables: Focusable[] = [];
const subs = new Map<string, Set<(focused: boolean) => void>>();
let activeId: string | null = null;
const activeIdRef = shallowRef<string | null>(null);
let enabled = true;
function notify(id: string, focused: boolean) {
subs.get(id)?.forEach((fn) => fn(focused));
}
function setActive(next: string | null) {
if (activeId === next) return;
const prev = activeId;
activeId = next;
ctx.activeId = activeId;
activeIdRef.value = activeId;
if (prev) notify(prev, false);
if (next) notify(next, true);
}
function findNextActive(startIdx: number, direction: 1 | -1): string | null {
const len = focusables.length;
for (let i = 0; i < len; i++) {
const idx = (startIdx + direction * (i + 1) + len * len) % len;
if (focusables[idx]!.isActive) return focusables[idx]!.id;
}
return null;
}
const ctx: FocusContext = {
activeId: null,
activeIdRef,
enabled: true,
enableFocus() {
enabled = true;
ctx.enabled = true;
},
disableFocus() {
enabled = false;
ctx.enabled = false;
},
focusNext() {
if (!enabled || focusables.length === 0) return;
const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : -1;
const next = findNextActive(idx, 1);
if (next) setActive(next);
},
focusPrevious() {
if (!enabled || focusables.length === 0) return;
const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : focusables.length;
const prev = findNextActive(idx, -1);
if (prev) setActive(prev);
},
focus(id) {
const entry = focusables.find((f) => f.id === id);
if (entry) setActive(id);
},
blur() {
setActive(null);
},
add(id, options) {
if (!focusables.some((f) => f.id === id)) {
focusables.push({ id, isActive: true });
}
if (options.autoFocus && activeId == null) {
setActive(id);
}
},
remove(id) {
const idx = focusables.findIndex((f) => f.id === id);
if (idx >= 0) focusables.splice(idx, 1);
if (activeId === id) setActive(null);
},
activate(id) {
const entry = focusables.find((f) => f.id === id);
if (entry) entry.isActive = true;
},
deactivate(id) {
const entry = focusables.find((f) => f.id === id);
if (entry) {
entry.isActive = false;
if (activeId === id) setActive(null);
}
},
subscribe(id, fn) {
let set = subs.get(id);
if (!set) {
set = new Set();
subs.set(id, set);
}
set.add(fn);
return () => set!.delete(fn);
},
};
return ctx;
}
// --- Stdin controller ----------------------------------------------------
interface StdinController extends StdinContext {
dispose: () => void;
}
interface RawModeState {
refs: number;
prevRaw: boolean | null;
}
const rawModeRegistry = new WeakMap<NodeJS.ReadStream, RawModeState>();
function getRawModeState(stdin: NodeJS.ReadStream): RawModeState {
let state = rawModeRegistry.get(stdin);
if (!state) {
state = { refs: 0, prevRaw: null };
rawModeRegistry.set(stdin, state);
}
return state;
}
function createStdinController(stdin: NodeJS.ReadStream, appCtx: AppContext): StdinController {
const emitter = new EventEmitter();
const listener = (chunk: Buffer | string) => {
emitter.emit("data", chunk.toString());
};
stdin.on("data", listener);
let localRefs = 0;
return {
stdin,
setRawMode: appCtx.setRawMode,
isRawModeSupported: appCtx.isRawModeSupported,
internal_eventEmitter: emitter,
acquireRawMode() {
if (!appCtx.isRawModeSupported) return;
const state = getRawModeState(stdin);
if (state.refs === 0) {
state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false;
appCtx.setRawMode(true);
}
state.refs++;
localRefs++;
},
releaseRawMode() {
if (!appCtx.isRawModeSupported) return;
if (localRefs === 0) return;
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - 1);
localRefs = Math.max(0, localRefs - 1);
if (state.refs === 0 && state.prevRaw !== null) {
// Defer the actual disable: when components swap (v-if key change),
// Vue unmounts the old before mounting the new, so refs briefly hits 0.
// Disabling synchronously would drop raw mode between the two mounts.
queueMicrotask(() => {
if (state.refs > 0 || state.prevRaw === null) return;
appCtx.setRawMode(state.prevRaw);
state.prevRaw = null;
});
}
},
dispose() {
stdin.off("data", listener);
if (localRefs > 0 && appCtx.isRawModeSupported) {
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - localRefs);
localRefs = 0;
if (state.refs === 0 && state.prevRaw !== null) {
appCtx.setRawMode(state.prevRaw);
state.prevRaw = null;
}
}
},
};
}
+35
View File
@@ -0,0 +1,35 @@
import { queuePostFlushCb } from "@vue/runtime-core";
export interface CommitScheduler {
schedule: () => void;
flush: () => Promise<void>;
}
export function createCommitScheduler(commit: () => void): CommitScheduler {
let scheduled = false;
let resolveFlush: (() => void) | null = null;
function schedule() {
if (scheduled) return;
scheduled = true;
queuePostFlushCb(() => {
scheduled = false;
try {
commit();
} finally {
const r = resolveFlush;
resolveFlush = null;
r?.();
}
});
}
function flush(): Promise<void> {
if (!scheduled) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveFlush = resolve;
});
}
return { schedule, flush };
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["es2023"],
"moduleDetection": "force",
"module": "nodenext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"declaration": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "vue"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite-plus";
import vueJsx from "@vitejs/plugin-vue-jsx";
export default defineConfig({
plugins: [vueJsx()],
pack: {
entry: ["src/index.ts", "src/internal.ts"],
dts: { tsgo: true },
exports: true,
},
lint: {
options: { typeAware: true, typeCheck: true },
},
fmt: {},
});