feat(runtime)!: narrow useStdin to Ink's public surface; name composable returns UseXReturn (#79)
useStdin() returned the full internal StdinContext (8 members incl. the raw-mode
ref-counting primitives acquireRawMode/releaseRawMode, setBracketedPasteMode, and
internal_eventEmitter/internal_exitOnCtrlC). Ink's useStdin() returns only its PublicProps
— { stdin, setRawMode, isRawModeSupported } — keeping the rest on the internal context,
reached via the internal useStdinContext()/inject. Verified against Ink 7.0.4
(src/hooks/use-stdin.ts:10, src/components/StdinContext.ts).
- Narrow useStdin(): UseStdinReturn (the 3 public fields). The full StdinContext stays
internal, reached by useInput/useFocus/usePaste via inject(StdinContextKey) — the runtime
object is unchanged, only the public type narrows (mirrors Ink's type-level narrowing).
- Name every stdio/app composable return type per VueUse's UseXReturn convention and export
them: UseStdinReturn, UseStdoutReturn, UseStderrReturn, UseAppReturn (shapes byte-identical
to Ink's StdinProps/StdoutProps/StderrProps/AppProps). vue-tui reserves XProps for component
props (BoxProps, via ExtractPublicPropTypes), so composable returns use UseXReturn — the
Vue-community-idiomatic name.
- Unify the two pre-existing return types onto the same convention:
AnimationResult → UseAnimationReturn, UseBoxMetricsResult → UseBoxMetricsReturn.
- Type-level test (public-types.test-d.ts) locks the shapes and asserts useStdin()'s public
return excludes the internal members.
- Docs: trim the type-reexport divergence area to genuine divergences (fold
RenderOptions/Instance → MountOptions/TuiApp into createApp; keep DOMElement → TuiNode);
drop the AppProps/StdinProps/StdoutProps/StderrProps "N/A" entry — those Ink names are the
hook return types, now mirrored as UseXReturn (recorded under Framework idioms). Supersedes #77.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,43 +26,22 @@ deliberate. Divergences fall into a few kinds:
|
||||
|
||||
### Entry point — `createApp()` instead of `render()`
|
||||
|
||||
- **Ink:** `render(<App/>)`.
|
||||
- **vue-tui:** `createApp(App).mount(options)`.
|
||||
- **Ink:** `render(<App/>, options?)` — `options` is `RenderOptions`; returns an `Instance`.
|
||||
- **vue-tui:** `createApp(App)` returns a `TuiApp`; `app.mount(options?)` takes `MountOptions`.
|
||||
- **Why:** mirrors Vue's own `createApp` mental model — a Vue developer expects an app
|
||||
object they mount, not a one-shot render call.
|
||||
object (`TuiApp`) they mount, not a one-shot render call. The mount-options bag and the
|
||||
app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped
|
||||
(`RenderOptions` / `Instance`).
|
||||
|
||||
### Named type / prop re-exports
|
||||
### Host-node type — `DOMElement` → `TuiNode`
|
||||
|
||||
- **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
|
||||
- **Ink:** exports `DOMElement`, a DOM-emulation node (`nodeName` / `attributes` /
|
||||
`childNodes`).
|
||||
- **vue-tui:** the 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.
|
||||
- **Why:** vue-tui's renderer keeps a native host-node tree rather than a DOM emulation,
|
||||
so the exported node type names that tree, not a DOM node.
|
||||
|
||||
## Additive features (vue-tui is a strict superset)
|
||||
|
||||
@@ -110,6 +89,11 @@ deliberate. Divergences fall into a few kinds:
|
||||
Surface conventions, listed so they aren't mistaken for gaps:
|
||||
|
||||
- Vue **composables** (`useFocus`, `useInput`, …) instead of React **hooks**.
|
||||
- Composable **return types** follow VueUse's `UseXReturn` convention (`UseStdinReturn`,
|
||||
`UseAppReturn`, …) — Ink names the equivalent hook-return types `XProps` (`StdinProps`,
|
||||
`AppProps`, …), but in vue-tui `XProps` is reserved for component props (`BoxProps`,
|
||||
derived via `ExtractPublicPropTypes`). The return shapes still mirror Ink field-for-field
|
||||
(e.g. `useStdin()` exposes only Ink's public `{ stdin, setRawMode, isRawModeSupported }`).
|
||||
- `<script setup>` SFCs / `defineComponent` instead of function components.
|
||||
- kebab-case filenames; `.ts` over `.tsx` where there's no JSX.
|
||||
- `shallowRef` by default for reactive state.
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
// 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").
|
||||
// name a component's props the same way they would in Ink. This is parity, not a
|
||||
// divergence, so it is deliberately absent from `.agents/docs/ink-divergences.md` (which
|
||||
// records only divergences); this test is the guard that the names stay aligned.
|
||||
//
|
||||
// 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 { useApp, useStdin, useStdout, useStderr } from "@vue-tui/runtime";
|
||||
import type {
|
||||
BoxProps,
|
||||
TextProps,
|
||||
@@ -19,6 +21,10 @@ import type {
|
||||
NewlineProps,
|
||||
WindowSize,
|
||||
CursorPosition,
|
||||
UseAppReturn,
|
||||
UseStdinReturn,
|
||||
UseStdoutReturn,
|
||||
UseStderrReturn,
|
||||
} from "@vue-tui/runtime";
|
||||
|
||||
// Prop types carry their component's real, declared props.
|
||||
@@ -36,3 +42,36 @@ 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 }>();
|
||||
|
||||
// Composable return types: named per VueUse's `UseXReturn` convention, and shape-locked to
|
||||
// Ink's public hook returns. useStdin() in particular must expose ONLY Ink's `PublicProps`
|
||||
// (stdin/setRawMode/isRawModeSupported) — never the internal raw-mode/paste controller
|
||||
// (acquireRawMode/releaseRawMode/setBracketedPasteMode/internal_*), which the framework's
|
||||
// own composables reach via inject(StdinContextKey).
|
||||
expectTypeOf<UseStdinReturn>().toEqualTypeOf<{
|
||||
readonly stdin: NodeJS.ReadStream;
|
||||
readonly setRawMode: (mode: boolean) => void;
|
||||
readonly isRawModeSupported: boolean;
|
||||
}>();
|
||||
expectTypeOf<ReturnType<typeof useStdin>>().toEqualTypeOf<UseStdinReturn>();
|
||||
expectTypeOf<keyof ReturnType<typeof useStdin>>().toEqualTypeOf<
|
||||
"stdin" | "setRawMode" | "isRawModeSupported"
|
||||
>();
|
||||
|
||||
expectTypeOf<UseStdoutReturn>().toEqualTypeOf<{
|
||||
readonly stdout: NodeJS.WriteStream;
|
||||
readonly write: (data: string) => void;
|
||||
}>();
|
||||
expectTypeOf<ReturnType<typeof useStdout>>().toEqualTypeOf<UseStdoutReturn>();
|
||||
|
||||
expectTypeOf<UseStderrReturn>().toEqualTypeOf<{
|
||||
readonly stderr: NodeJS.WriteStream;
|
||||
readonly write: (data: string) => void;
|
||||
}>();
|
||||
expectTypeOf<ReturnType<typeof useStderr>>().toEqualTypeOf<UseStderrReturn>();
|
||||
|
||||
expectTypeOf<UseAppReturn>().toEqualTypeOf<{
|
||||
readonly exit: (errorOrResult?: unknown) => void;
|
||||
readonly waitUntilRenderFlush: () => Promise<void>;
|
||||
}>();
|
||||
expectTypeOf<ReturnType<typeof useApp>>().toEqualTypeOf<UseAppReturn>();
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AnimationOptions {
|
||||
isActive?: MaybeRefOrGetter<boolean>;
|
||||
}
|
||||
|
||||
export interface AnimationResult {
|
||||
export interface UseAnimationReturn {
|
||||
/**
|
||||
* Discrete counter that increments by 1 each interval.
|
||||
* Useful for indexed sequences like spinner frames.
|
||||
@@ -72,7 +72,7 @@ export interface AnimationResult {
|
||||
* </template>
|
||||
* ```
|
||||
*/
|
||||
export function useAnimation(options: AnimationOptions = {}): AnimationResult {
|
||||
export function useAnimation(options: AnimationOptions = {}): UseAnimationReturn {
|
||||
const frame = shallowRef(0);
|
||||
const time = shallowRef(0);
|
||||
const delta = shallowRef(0);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { inject } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
/** The public app-lifecycle surface returned by {@link useApp}. Mirrors Ink's `useApp()`. */
|
||||
export interface UseAppReturn {
|
||||
readonly exit: (errorOrResult?: unknown) => void;
|
||||
readonly waitUntilRenderFlush: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns app-level lifecycle controls for a component inside the render tree:
|
||||
*
|
||||
@@ -13,10 +19,7 @@ import { AppContextKey } from "../context.ts";
|
||||
* Mirrors Ink's `useApp()`. Streams are reached through the dedicated peer
|
||||
* composables (`useStdin`, `useStdout`, `useStderr`), exactly as in Ink.
|
||||
*/
|
||||
export function useApp(): {
|
||||
exit: (errorOrResult?: unknown) => void;
|
||||
waitUntilRenderFlush: () => Promise<void>;
|
||||
} {
|
||||
export function useApp(): UseAppReturn {
|
||||
const ctx = inject(AppContextKey);
|
||||
if (!ctx) throw new Error("useApp() must be called inside a vue-tui render tree");
|
||||
return { exit: ctx.exit, waitUntilRenderFlush: ctx.waitUntilRenderFlush };
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface BoxMetrics {
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
export interface UseBoxMetricsResult {
|
||||
export interface UseBoxMetricsReturn {
|
||||
/** Reactive element width. */
|
||||
readonly width: ShallowRef<number>;
|
||||
/** Reactive element height. */
|
||||
@@ -110,7 +110,7 @@ export function measureElement(node: unknown): { width: number; height: number }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useBoxMetrics(ref: Ref<unknown>): UseBoxMetricsResult {
|
||||
export function useBoxMetrics(ref: Ref<unknown>): UseBoxMetricsReturn {
|
||||
const width = shallowRef(0);
|
||||
const height = shallowRef(0);
|
||||
const left = shallowRef(0);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { inject } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
export function useStderr(): { stderr: NodeJS.WriteStream; write: (data: string) => void } {
|
||||
/** The public stderr surface returned by {@link useStderr}. Mirrors Ink's `useStderr()`. */
|
||||
export interface UseStderrReturn {
|
||||
readonly stderr: NodeJS.WriteStream;
|
||||
readonly write: (data: string) => void;
|
||||
}
|
||||
|
||||
export function useStderr(): UseStderrReturn {
|
||||
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.writeToStderr(data) };
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { inject } from "vue";
|
||||
import { StdinContextKey, type StdinContext } from "../context.ts";
|
||||
import { StdinContextKey } from "../context.ts";
|
||||
|
||||
export function useStdin(): StdinContext {
|
||||
/**
|
||||
* The public stdin surface returned by {@link useStdin}. Mirrors Ink's `useStdin()`,
|
||||
* which returns its `PublicProps` — not the full context. The raw-mode ref-counting
|
||||
* primitives and the paste/event-emitter plumbing on the internal `StdinContext` are
|
||||
* reached by the framework's own composables (`useInput` / `useFocus` / `usePaste`) via
|
||||
* `inject(StdinContextKey)`, and are deliberately not part of this public surface.
|
||||
*/
|
||||
export interface UseStdinReturn {
|
||||
readonly stdin: NodeJS.ReadStream;
|
||||
readonly setRawMode: (mode: boolean) => void;
|
||||
readonly isRawModeSupported: boolean;
|
||||
}
|
||||
|
||||
export function useStdin(): UseStdinReturn {
|
||||
const ctx = inject(StdinContextKey);
|
||||
if (!ctx) throw new Error("useStdin() must be called inside a vue-tui render tree");
|
||||
// Return the full controller narrowed to the public surface — TS structural typing
|
||||
// hides the internal members, exactly as Ink types `useStdin()` as `PublicProps`.
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { inject } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
export function useStdout(): { stdout: NodeJS.WriteStream; write: (data: string) => void } {
|
||||
/** The public stdout surface returned by {@link useStdout}. Mirrors Ink's `useStdout()`. */
|
||||
export interface UseStdoutReturn {
|
||||
readonly stdout: NodeJS.WriteStream;
|
||||
readonly write: (data: string) => void;
|
||||
}
|
||||
|
||||
export function useStdout(): UseStdoutReturn {
|
||||
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.writeToStdout(data) };
|
||||
|
||||
@@ -14,27 +14,27 @@ export { Spacer } from "./components/Spacer.ts";
|
||||
export { Static, type StaticProps } from "./components/Static.ts";
|
||||
export { Transform, type TransformProps } from "./components/Transform.ts";
|
||||
|
||||
export { useApp } from "./composables/useApp.ts";
|
||||
export { useApp, type UseAppReturn } from "./composables/useApp.ts";
|
||||
export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts";
|
||||
export { usePaste, type UsePasteOptions } from "./composables/usePaste.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 { useStdin, type UseStdinReturn } from "./composables/useStdin.ts";
|
||||
export { useStdout, type UseStdoutReturn } from "./composables/useStdout.ts";
|
||||
export { useStderr, type UseStderrReturn } from "./composables/useStderr.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,
|
||||
type AnimationOptions,
|
||||
type AnimationResult,
|
||||
type UseAnimationReturn,
|
||||
} from "./composables/useAnimation.ts";
|
||||
export {
|
||||
useBoxMetrics,
|
||||
measureElement,
|
||||
type BoxMetrics,
|
||||
type UseBoxMetricsResult,
|
||||
type UseBoxMetricsReturn,
|
||||
} from "./composables/useBoxMetrics.ts";
|
||||
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
|
||||
export type { DevState, DevErrorInfo } from "./hmr.ts";
|
||||
|
||||
Reference in New Issue
Block a user