feat(runtime): re-export Ink-aligned named prop/data types (BoxProps, …) (#70)

vue-tui withheld Ink's named prop types (BoxProps, TextProps, StaticProps,
TransformProps, NewlineProps) and the WindowSize/CursorPosition data shapes
under a blanket "avoid React-shaped type names" rule. That rule over-reached:
a <Box> has props in Vue exactly as in React, so those names carry no
React-vs-Vue content — there's no reason to rename them. Re-export them under
Ink's names so a consumer can name a component's props the same way as in Ink.

- Derive each XProps from the component's runtime `props` object via Vue's
  `ExtractPublicPropTypes`, so the public type can never drift from the real
  props. Pin `required: true as const` on Static.items / Transform.transform:
  a standalone `const` widens `true`→`boolean`, which would otherwise drop them
  from the required keys — in both the exported type AND the component's own
  `setup(props)` typing.
- Add `WindowSize { columns, rows }` and `CursorPosition { x, y }`, anchored to
  their real usage in useTerminalSize / useCursor. (The composables still return
  reactive refs of these — the data shape matches Ink; the ref wrapper is the
  framework difference.)
- Keep the genuinely-divergent names as-is: DOMElement→TuiNode (real
  DOM-emulation vs host-node difference), RenderOptions/Instance→MountOptions/
  TuiApp (downstream of createApp()), App/Stdin/Stdout/StderrProps = N/A. Rewrite
  the ink-divergences doc to record what's now aligned vs still divergent.
- Add a tsc-checked type-level test (public-types.test-d.ts) asserting the
  exported shapes; it is excluded from vitest's runtime run by naming.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 19:18:19 +08:00
committed by GitHub
parent 688da13872
commit 6edc51b925
10 changed files with 267 additions and 130 deletions
+31 -7
View File
@@ -40,14 +40,38 @@ deliberate. Divergences fall into a few kinds:
- **Why:** intentionally minimal, single-purpose composables. `waitUntilRenderFlush` is
deliberately **not** exposed.
### No named type / prop re-exports
### Named type / prop re-exports
- **Ink:** re-exports `BoxProps`, `TextProps`, `StaticProps`, `TransformProps`,
`NewlineProps`, `WindowSize`, `CursorPosition`, `DOMElement`, `RenderOptions`, `Instance`,
`App/Stdin/Stdout/StderrProps`.
- **vue-tui:** does not re-export those names; exposes its own type surface
(`TuiApp`, `MountOptions`, …).
- **Why:** avoid leaking React-shaped type names; present a Vue-native type surface.
- **Ink:** re-exports its component prop types plus a few data/handle types:
`BoxProps`, `TextProps`, `StaticProps`, `TransformProps`, `NewlineProps`,
`WindowSize`, `CursorPosition`, `DOMElement`, `RenderOptions`, `Instance`,
`AppProps`, `StdinProps`, `StdoutProps`, `StderrProps`.
- **vue-tui:** re-exports the framework-neutral ones under the **same names** —
`BoxProps`, `TextProps`, `StaticProps`, `TransformProps`, `NewlineProps`,
`WindowSize` (`{ columns, rows }`) and `CursorPosition` (`{ x, y }`). These are
**not** divergences: a `<Box>` has props in Vue exactly as in React, so the names
carry over. They are derived from the runtime `props` objects via Vue's
`ExtractPublicPropTypes`, so they never drift from the components' real props.
Only the remaining few genuinely differ, each for a concrete reason — never merely
to "avoid React-shaped names":
- `DOMElement` → **`TuiNode`**. The one genuinely DOM-shaped type: Ink's
`DOMElement` models a DOM-emulation node (`nodeName` / `attributes` /
`childNodes`). vue-tui's host tree is a different representation
(`TuiContainer | TuiTextLeaf | TuiComment`), exported as `TuiNode` from
`@vue-tui/runtime/internal`.
- `RenderOptions` / `Instance` → **`MountOptions`** / **`TuiApp`**. Downstream of
the `createApp()` entry above — vue-tui mounts a Vue app, so the options bag and
the returned handle are Vue-shaped, not `render()`-shaped.
- `AppProps` / `StdinProps` / `StdoutProps` / `StderrProps` → **N/A**. These are the
props of Ink's internal React _context-provider components_ (`<AppContext>`,
`<StdinContext>`, …). vue-tui has no such components — that state is reached via
`createApp` plus the `useStdin` / `useStdout` / `useStderr` composables — so there
is nothing to name.
- **Why:** the earlier blanket "expose a Vue-native type surface, don't leak
React-shaped names" over-reached — it withheld names like `BoxProps` that have no
React vs Vue content at all. The rule is narrower: mirror Ink's names wherever the
underlying type is framework-neutral; reshape only where Vue genuinely has a
different thing (a host node, a mounted app) or no thing at all.
## Additive features (vue-tui is a strict superset)
@@ -0,0 +1,38 @@
// Type-level guarantees for the public *named* type surface.
//
// vue-tui tracks Ink, and Ink re-exports its component prop types and a couple of
// framework-neutral data shapes under stable names. These names (BoxProps, TextProps,
// …, WindowSize, CursorPosition) have nothing to do with React vs Vue — a <Box> has
// props in Vue exactly as in React — so vue-tui re-exports them too, letting consumers
// name a component's props the same way they would in Ink. See
// `.agents/docs/ink-divergences.md` ("Named type / prop re-exports").
//
// These assertions are erased at runtime; the real gate is `tsc --noEmit` (the package's
// `check:type` script). This file is named `*.test-d.ts` on purpose so vitest does NOT
// pick it up as a runtime test (its include is `*.test.ts`), while tsc still checks it.
import { expectTypeOf } from "vite-plus/test";
import type {
BoxProps,
TextProps,
StaticProps,
TransformProps,
NewlineProps,
WindowSize,
CursorPosition,
} from "@vue-tui/runtime";
// Prop types carry their component's real, declared props.
expectTypeOf<BoxProps["flexDirection"]>().toEqualTypeOf<
"row" | "row-reverse" | "column" | "column-reverse" | undefined
>();
expectTypeOf<BoxProps["gap"]>().toEqualTypeOf<number | undefined>();
expectTypeOf<TextProps["bold"]>().toEqualTypeOf<boolean | undefined>();
expectTypeOf<StaticProps["items"]>().toEqualTypeOf<unknown[]>();
expectTypeOf<TransformProps["transform"]>().toEqualTypeOf<
(line: string, lineIndex: number) => string
>();
expectTypeOf<NewlineProps["count"]>().toEqualTypeOf<number | undefined>();
// Framework-neutral data shapes, mirrored from Ink exactly.
expectTypeOf<WindowSize>().toEqualTypeOf<{ readonly columns: number; readonly rows: number }>();
expectTypeOf<CursorPosition>().toEqualTypeOf<{ x: number; y: number }>();
+92 -87
View File
@@ -1,4 +1,4 @@
import { defineComponent, h, inject, type PropType } from "vue";
import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue";
import type cliBoxes from "cli-boxes";
import { AppContextKey } from "../context.ts";
import type { WithChildren } from "./with-children.ts";
@@ -69,94 +69,96 @@ export interface AriaState {
selected?: boolean;
}
const boxProps = {
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],
maxWidth: [Number, String],
maxHeight: [Number, String],
aspectRatio: Number,
alignContent: String as PropType<AlignContent>,
position: String as PropType<"absolute" | "relative" | "static">,
top: [Number, String],
right: [Number, String],
bottom: [Number, String],
left: [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,
// Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13).
// Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here.
borderStyle: [String, Object] as PropType<BorderStyle | BoxStyle>,
borderColor: [String, Array],
// `default: undefined` is intentional and load-bearing: Vue's boolean-casting
// rule coerces absent Boolean props to `false` only when there is no explicit
// default. Adding `default: undefined` suppresses that coercion so absent
// per-edge dim props arrive in the paint pass as `undefined`, not `false`.
// This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the
// general value only when the per-edge prop was truly omitted — mirroring
// Ink render-border.ts:54 which uses real-undefined via React's prop model
// (G16). The `Boolean` type is kept so Vue still accepts bare-attribute
// `<Box borderDimColor>` in templates (coerces `""` → `true`) and passes
// TypeScript type-checking for consumers.
borderDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderTopDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderBottomDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderLeftDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderRightDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
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],
borderBackgroundColor: [String, Array],
borderTopBackgroundColor: [String, Array],
borderBottomBackgroundColor: [String, Array],
borderLeftBackgroundColor: [String, Array],
borderRightBackgroundColor: [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">,
ariaLabel: String,
ariaHidden: Boolean,
ariaRole: String as PropType<AriaRole>,
ariaState: Object as PropType<AriaState>,
};
const BoxImpl = 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],
maxWidth: [Number, String],
maxHeight: [Number, String],
aspectRatio: Number,
alignContent: String as PropType<AlignContent>,
position: String as PropType<"absolute" | "relative" | "static">,
top: [Number, String],
right: [Number, String],
bottom: [Number, String],
left: [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,
// Accept either a preset name string or a full custom BoxStyle object (Ink parity, G13).
// Ink types borderStyle as `keyof Boxes | BoxStyle`; we mirror that here.
borderStyle: [String, Object] as PropType<BorderStyle | BoxStyle>,
borderColor: [String, Array],
// `default: undefined` is intentional and load-bearing: Vue's boolean-casting
// rule coerces absent Boolean props to `false` only when there is no explicit
// default. Adding `default: undefined` suppresses that coercion so absent
// per-edge dim props arrive in the paint pass as `undefined`, not `false`.
// This lets `edgeDim = (perEdge ?? generalDim)` correctly fall back to the
// general value only when the per-edge prop was truly omitted — mirroring
// Ink render-border.ts:54 which uses real-undefined via React's prop model
// (G16). The `Boolean` type is kept so Vue still accepts bare-attribute
// `<Box borderDimColor>` in templates (coerces `""` → `true`) and passes
// TypeScript type-checking for consumers.
borderDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderTopDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderBottomDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderLeftDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
borderRightDimColor: { type: Boolean as PropType<boolean | undefined>, default: undefined },
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],
borderBackgroundColor: [String, Array],
borderTopBackgroundColor: [String, Array],
borderBottomBackgroundColor: [String, Array],
borderLeftBackgroundColor: [String, Array],
borderRightBackgroundColor: [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">,
ariaLabel: String,
ariaHidden: Boolean,
ariaRole: String as PropType<AriaRole>,
ariaState: Object as PropType<AriaState>,
},
props: boxProps,
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
@@ -177,3 +179,6 @@ const BoxImpl = defineComponent({
});
export const Box = BoxImpl as WithChildren<typeof BoxImpl>;
/** Props accepted by `<Box>` — the vue-tui analogue of Ink's `BoxProps`. */
export type BoxProps = ExtractPublicPropTypes<typeof boxProps>;
+7 -2
View File
@@ -1,8 +1,10 @@
import { defineComponent, getCurrentInstance, h } from "vue";
import { defineComponent, getCurrentInstance, h, type ExtractPublicPropTypes } from "vue";
const newlineProps = { count: { type: Number, default: 1 } };
export const Newline = defineComponent({
name: "Newline",
props: { count: { type: Number, default: 1 } },
props: newlineProps,
setup(props) {
return () => {
const content = "\n".repeat(props.count);
@@ -30,3 +32,6 @@ function isInsideText(): boolean {
}
return false;
}
/** Props accepted by `<Newline>` — the vue-tui analogue of Ink's `NewlineProps`. */
export type NewlineProps = ExtractPublicPropTypes<typeof newlineProps>;
+21 -5
View File
@@ -1,12 +1,25 @@
import { defineComponent, h, shallowRef, watch, type PropType } from "vue";
import {
defineComponent,
h,
shallowRef,
watch,
type ExtractPublicPropTypes,
type PropType,
} from "vue";
import type { WithChildren } from "./with-children.ts";
const staticProps = {
// `required: true as const` (not bare `true`): a standalone `const` widens
// `true` → `boolean`, which would drop `items` from ExtractPublicPropTypes'
// required keys (and from the component's own `props.items` typing). The
// literal keeps `items` required, matching Ink's `StaticProps`.
items: { type: Array as PropType<unknown[]>, required: true as const },
style: { type: Object as PropType<Record<string, unknown>>, default: undefined },
};
const StaticImpl = defineComponent({
name: "Static",
props: {
items: { type: Array as PropType<unknown[]>, required: true },
style: { type: Object as PropType<Record<string, unknown>>, default: undefined },
},
props: staticProps,
setup(props, { slots }) {
const defaultStyle: Record<string, unknown> = {
position: "absolute",
@@ -64,3 +77,6 @@ const StaticImpl = defineComponent({
});
export const Static = StaticImpl as WithChildren<typeof StaticImpl>;
/** Props accepted by `<Static>` — the vue-tui analogue of Ink's `StaticProps`. */
export type StaticProps = ExtractPublicPropTypes<typeof staticProps>;
+26 -14
View File
@@ -1,4 +1,11 @@
import { defineComponent, getCurrentInstance, h, inject, type PropType } from "vue";
import {
defineComponent,
getCurrentInstance,
h,
inject,
type ExtractPublicPropTypes,
type PropType,
} from "vue";
import { AppContextKey } from "../context.ts";
import type { WithChildren } from "./with-children.ts";
@@ -11,21 +18,23 @@ type WrapMode =
| "truncate-middle"
| "truncate-start";
const textProps = {
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" },
ariaLabel: String,
ariaHidden: Boolean,
};
const TextImpl = 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" },
ariaLabel: String,
ariaHidden: Boolean,
},
props: textProps,
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
@@ -57,6 +66,9 @@ const TextImpl = defineComponent({
export const Text = TextImpl as WithChildren<typeof TextImpl>;
/** Props accepted by `<Text>` — the vue-tui analogue of Ink's `TextProps`. */
export type TextProps = ExtractPublicPropTypes<typeof textProps>;
function isInsideText(): boolean {
let parent = getCurrentInstance()?.parent;
while (parent) {
+13 -5
View File
@@ -1,15 +1,20 @@
import { defineComponent, h, inject, type PropType } from "vue";
import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue";
import { AppContextKey } from "../context.ts";
import type { WithChildren } from "./with-children.ts";
type TransformFn = (line: string, lineIndex: number) => string;
const transformProps = {
// `required: true as const` keeps `transform` a required key once the props
// object lives in a standalone `const` (which would otherwise widen `true` →
// `boolean`). Matches Ink's `TransformProps`.
transform: { type: Function as PropType<TransformFn>, required: true as const },
accessibilityLabel: String,
};
const TransformImpl = defineComponent({
name: "Transform",
props: {
transform: { type: Function as PropType<TransformFn>, required: true },
accessibilityLabel: String,
},
props: transformProps,
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
@@ -28,3 +33,6 @@ const TransformImpl = defineComponent({
});
export const Transform = TransformImpl as WithChildren<typeof TransformImpl>;
/** Props accepted by `<Transform>` — the vue-tui analogue of Ink's `TransformProps`. */
export type TransformProps = ExtractPublicPropTypes<typeof transformProps>;
+10 -2
View File
@@ -1,6 +1,14 @@
import { inject, shallowRef, watch, onScopeDispose } from "vue";
import { AppContextKey } from "../context.ts";
/**
* A cursor position in output-origin coordinates. Mirrors Ink's `CursorPosition`.
*/
export interface CursorPosition {
x: number;
y: number;
}
/**
* Returns `setCursorPosition` so a component can control the terminal cursor.
*
@@ -12,9 +20,9 @@ export function useCursor() {
const ctx = inject(AppContextKey);
if (!ctx) throw new Error("useCursor() must be called inside a vue-tui render tree");
const positionRef = shallowRef<{ x: number; y: number } | undefined>(undefined);
const positionRef = shallowRef<CursorPosition | undefined>(undefined);
function setCursorPosition(position: { x: number; y: number } | undefined) {
function setCursorPosition(position: CursorPosition | undefined) {
positionRef.value = position;
}
@@ -2,13 +2,28 @@ import { inject, onScopeDispose, shallowRef, type ShallowRef } from "vue";
import terminalSize from "terminal-size";
import { AppContextKey } from "../context.ts";
/**
* A terminal's character-cell dimensions. Mirrors Ink's `WindowSize` exactly
* (`columns`/`rows`, not `width`/`height`).
*
* Note the Vue-vs-React shape: Ink's `useWindowSize()` returns a `WindowSize`
* snapshot, whereas vue-tui's `useWindowSize()` / `useTerminalSize()` return
* reactive **refs** of these dimensions
* (`{ columns: ShallowRef<number>; rows: ShallowRef<number> }`). The data shape
* is the same; the reactivity wrapper is the framework difference.
*/
export interface WindowSize {
readonly columns: number;
readonly rows: number;
}
/**
* Resolve terminal dimensions with a fallback chain:
* 1. stdout.columns / stdout.rows (available in TTY mode)
* 2. terminal-size package (works even when stdout is redirected)
* 3. Hardcoded defaults (80x24)
*/
export function resolveSize(stdout: NodeJS.WriteStream): { columns: number; rows: number } {
export function resolveSize(stdout: NodeJS.WriteStream): WindowSize {
const cols = stdout.columns;
const rowsVal = stdout.rows;
if (cols && rowsVal) return { columns: cols, rows: rowsVal };
+13 -7
View File
@@ -1,12 +1,18 @@
export { createApp, type TuiApp, type MountOptions } from "./render.ts";
export { renderToString, type RenderToStringOptions } from "./render-to-string.ts";
export { Box, type AriaRole, type AriaState, type BoxStyle } from "./components/Box.ts";
export { Text } from "./components/Text.ts";
export { Newline } from "./components/Newline.ts";
export {
Box,
type AriaRole,
type AriaState,
type BoxStyle,
type BoxProps,
} from "./components/Box.ts";
export { Text, type TextProps } from "./components/Text.ts";
export { Newline, type NewlineProps } from "./components/Newline.ts";
export { Spacer } from "./components/Spacer.ts";
export { Static } from "./components/Static.ts";
export { Transform } from "./components/Transform.ts";
export { Static, type StaticProps } from "./components/Static.ts";
export { Transform, type TransformProps } from "./components/Transform.ts";
export { useExit } from "./composables/useExit.ts";
export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts";
@@ -16,8 +22,8 @@ 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, useWindowSize } from "./composables/useTerminalSize.ts";
export { useCursor } from "./composables/useCursor.ts";
export { useTerminalSize, useWindowSize, type WindowSize } from "./composables/useTerminalSize.ts";
export { useCursor, type CursorPosition } from "./composables/useCursor.ts";
export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts";
export {
useAnimation,