feat(components): add @vue-tui/components + Spinner (first component) (#229)

* feat(components): scaffold @vue-tui/components with spinner preset data

New private package (0.0.0) for high-level components composed from runtime
primitives. Ships the dots/line preset data + a pure resolveSpinner() with
edge-guards (empty frames / unknown type → dots; interval threads both modes),
fully unit-tested incl. a width-safety guard (string-width === 1 per frame).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(components): add the Spinner component

Spinner is a <Text> + useAnimation pure composition: `type` selects an inline
preset (dots/line), `frames`/`interval` is the escape hatch, and it always
animates (no interactivity gate — there is no public signal; matches Ink).
Renders a visible glyph non-interactively. Typed props via ExtractPublicPropTypes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(components): Spinner color + label

`color` tints the glyph only (label stays default, matching ora/@inkjs/ui);
`label` renders after the glyph with a separating space (interpolated so Vue
whitespace-condense keeps it). Two <Text> spans share an outer <Text> context so
they render inline on one line rather than stacking vertically.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(components): add @vue-tui/components to the CI task graph

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(components): record Spinner decisions

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(components): refresh package status and clarify ink-spinner parity note

The design-principles status blockquote said the package was 'planned'
with no code yet; this branch ships Spinner, so mark it active. Also
reword the spinner Behavior note to name the third-party ink-spinner
explicitly and soften it to an unverified, un-run-checked observation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): list @vue-tui/components + <Spinner>

Add the new package to the hero line + Packages table, and <Spinner> to the
Components table. Marked "New; early".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): give @vue-tui/components its own section

Move <Spinner> out of the runtime Components table into a separate
"High-level Components" section so the package's API surface stays decoupled
from the runtime primitives. Add a ToC entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): drop the "New; early" status tag for @vue-tui/components

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-28 16:42:01 +08:00
committed by GitHub
parent 7a8ccf3655
commit 8aea881ffa
14 changed files with 387 additions and 8 deletions
+4 -2
View File
@@ -5,8 +5,10 @@
> issue, not here. It records how components in `@vue-tui/components` should be _shaped_ and
> _styled_, and the bar for adding one in the first place.
>
> **Status:** planned — no `@vue-tui/components` code exists yet. This is design intent, not a
> description of shipped code.
> **Status:** active — the package now ships its first component, `Spinner` (see per-component
> records below). The principles here are design intent for the package as a whole.
>
> **Per-component records:** [spinner](./components/spinner.md).
**The governing idea:** components in `@vue-tui/components` are **pure compositions of
`@vue-tui/runtime` primitives**. The runtime owns the terminal-I/O and layout/commit boundary;
+43
View File
@@ -0,0 +1,43 @@
# Spinner — decision record
> Decisions specific to `@vue-tui/components`' `Spinner`. Shared conventions live in
> [components-design-principles.md](../components-design-principles.md). Tracking: #218.
A pure composition of `<Text>` + `useAnimation` — no new runtime export needed.
## Style selection & the escape hatch
- `type` selects an **inlined preset**; only **`dots`** (default) and **`line`** ship.
- **Inclusion bar for a preset:** universal default + functional fallback only; every frame
must be **width-safe (exactly 1 column, verified with `string-width`)**; non-novelty. `dots`
is the universal default; `line` is the only pure-ASCII fallback (braille needs a braille font).
Everything else — including the full `cli-spinners` set — is reachable via the escape hatch.
- **Escape hatch:** `frames: string[]` + `interval?: number` override `type`. A `cli-spinners`
entry (`{ interval, frames }`) can be spread in verbatim. Empty `frames` and an unknown `type`
both fall back to `dots`; `interval` overrides in either mode.
- **No `cli-spinners` dependency.** This is a _set-membership_ decision under the inclusion bar,
recorded here — **not** an Ink divergence (`ink-spinner`/`cli-spinners` are third-party npm, not
Ink-core v7.0.4, which has no Spinner; there is nothing to diverge from).
## Behavior
- **Always animates.** It does NOT gate on app interactivity — there is no public signal for
`interactive` (it lives only on the internal `AppContext`), and reaching for it would break the
pure-composition rule. This mirrors the third-party `ink-spinner`, which likewise just animates
(unverified parity — not run-checked); the runtime governs
non-interactive output one layer down. (If a static-when-non-interactive affordance is ever
wanted, it needs the runtime to expose interactivity publicly first.)
- Switching `type` changes the preset interval; `useAnimation` resets `frame` to 0 on a live
interval change — acceptable for a spinner.
## API shape
- `color` tints the **glyph only**; the `label` stays default-colored (matches ora / @inkjs/ui).
- `label` is a **`string` prop** (type-friendly + the common Vue idiom for simple text). If rich
label content is ever needed, add a same-purpose **default slot** later — non-breaking.
## Non-goals
- **succeed / fail / pending** terminal states (`✔`/`✖`) are NOT Spinner's job — they belong to a
future `TaskList` / `StatusMessage` (render = f(state) in a separate component).
- **Screen-reader** handling: deferred (niche; no surveyed spinner implements it).
+16 -6
View File
@@ -6,7 +6,7 @@ The Vue framework for terminal UIs.
Build with components, develop with HMR, test with confidence.
<p align="center">
<a href="https://npmx.dev/@vue-tui/runtime"><code>@vue-tui/runtime</code></a> · <a href="https://npmx.dev/@vue-tui/vite"><code>@vue-tui/vite</code></a> · <a href="https://npmx.dev/@vue-tui/testing"><code>@vue-tui/testing</code></a>
<a href="https://npmx.dev/@vue-tui/runtime"><code>@vue-tui/runtime</code></a> · <a href="https://npmx.dev/@vue-tui/components"><code>@vue-tui/components</code></a> · <a href="https://npmx.dev/@vue-tui/vite"><code>@vue-tui/vite</code></a> · <a href="https://npmx.dev/@vue-tui/testing"><code>@vue-tui/testing</code></a>
</p>
- **Vue SFC & JSX** — write terminal interfaces with `<template>`, TSX, or both
@@ -82,6 +82,7 @@ For non-interactive output — snapshots, CI logs, piped commands — `renderToS
- [Packages](#packages)
- [Examples](#examples)
- [Components](#components)
- [High-level Components](#high-level-components)
- [Composables (Hooks)](#composables-hooks)
- [Testing](#testing)
- [Development](#development)
@@ -91,11 +92,12 @@ For non-interactive output — snapshots, CI logs, piped commands — `renderToS
## Packages
| Package | Description |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`@vue-tui/runtime`](https://www.npmjs.com/package/@vue-tui/runtime) | The core framework — Vue 3 renderer for the terminal with components (`Box`, `Text`, `Static`, etc.), composables (`useInput`, `useFocus`, `useApp`, etc.), and yoga-based flexbox layout. _API stabilizing._ |
| [`@vue-tui/vite`](https://www.npmjs.com/package/@vue-tui/vite) | Vite plugin — add `vueTui()` to `vite.config.ts` for an in-process terminal dev server with HMR (`npm run dev`) plus a production build (`vite build`). _Experimental; may change._ |
| [`@vue-tui/testing`](https://www.npmjs.com/package/@vue-tui/testing) | Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
| Package | Description |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`@vue-tui/runtime`](https://www.npmjs.com/package/@vue-tui/runtime) | The core framework — Vue 3 renderer for the terminal with components (`Box`, `Text`, `Static`, etc.), composables (`useInput`, `useFocus`, `useApp`, etc.), and yoga-based flexbox layout. _API stabilizing._ |
| [`@vue-tui/vite`](https://www.npmjs.com/package/@vue-tui/vite) | Vite plugin — add `vueTui()` to `vite.config.ts` for an in-process terminal dev server with HMR (`npm run dev`) plus a production build (`vite build`). _Experimental; may change._ |
| [`@vue-tui/testing`](https://www.npmjs.com/package/@vue-tui/testing) | Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
| [`@vue-tui/components`](https://www.npmjs.com/package/@vue-tui/components) | High-level components built on the runtime primitives — currently `<Spinner>` (animated loading), with more to come. |
## Examples
@@ -117,6 +119,14 @@ For non-interactive output — snapshots, CI logs, piped commands — `renderToS
| [`<Static>`](./packages/runtime) | Renders a list of items once, above the redrawn region |
| [`<Transform>`](./packages/runtime) | Applies a string transform function to each rendered line |
## High-level Components
The [`@vue-tui/components`](./packages/components) package adds higher-level components composed from the runtime primitives — published separately from the core.
| Component | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------ |
| [`<Spinner>`](./packages/components) | Animated loading spinner — built-in `dots`/`line` presets or custom frames, optional label |
## Composables (Hooks)
| Composable | Description |
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@vue-tui/components",
"version": "0.0.0",
"private": true,
"description": "High-level Vue components for vue-tui, composed from @vue-tui/runtime primitives.",
"license": "MIT",
"files": [
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
},
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"test": "vp test",
"check:type": "vue-tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@vitejs/plugin-vue-jsx": "catalog:",
"@vue-tui/runtime": "workspace:*",
"@vue-tui/testing": "workspace:*",
"chalk": "catalog:",
"string-width": "catalog:",
"strip-ansi": "catalog:",
"typescript": "^6.0.3",
"unplugin-vue": "catalog:",
"vite-plus": "catalog:",
"vue": "^3.4.0",
"vue-tsc": "catalog:"
},
"peerDependencies": {
"@vue-tui/runtime": "workspace:*",
"vue": "^3.4.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { default as Spinner } from "./spinner.vue";
export type { SpinnerProps } from "./spinner-props.ts";
+13
View File
@@ -0,0 +1,13 @@
import { type ExtractPublicPropTypes, type PropType } from "vue";
import type { PresetName } from "./spinners.ts";
export const spinnerProps = {
type: { type: String as PropType<PresetName>, default: "dots" as PresetName },
frames: { type: Array as PropType<string[]> },
interval: Number,
color: String,
label: String,
};
/** Props accepted by `<Spinner>`. */
export type SpinnerProps = ExtractPublicPropTypes<typeof spinnerProps>;
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import Spinner from "./spinner.vue";
import { PRESETS } from "./spinners.ts";
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const dots = PRESETS.dots.frames;
describe("Spinner", () => {
test("renders a dots glyph by default", async () => {
const r = await render(Spinner, { interactive: false });
await delay(50);
const out = r.lastFrame() ?? "";
expect(out.length).toBeGreaterThan(0);
expect(dots.some((g) => out.includes(g))).toBe(true);
r.unmount();
});
test("animates through more than two distinct glyphs over time", async () => {
const r = await render(Spinner);
await delay(250);
const distinct = new Set(r.frames.map((f) => f.trim()).filter(Boolean));
expect(distinct.size).toBeGreaterThan(2);
r.unmount();
});
test("renders a visible (non-blank) glyph when non-interactive", async () => {
const r = await render(Spinner, { interactive: false });
await delay(50);
expect((r.lastFrame() ?? "").trim().length).toBeGreaterThan(0);
r.unmount();
});
test("type='line' renders a line glyph", async () => {
const r = await render(Spinner, { interactive: false, props: { type: "line" } });
await delay(50);
const out = r.lastFrame() ?? "";
expect(PRESETS.line.frames.some((g) => out.includes(g))).toBe(true);
r.unmount();
});
test("custom frames override the preset", async () => {
const r = await render(Spinner, { interactive: false, props: { frames: ["@"] } });
await delay(50);
expect((r.lastFrame() ?? "").includes("@")).toBe(true);
r.unmount();
});
test("color tints the glyph but not the label", async () => {
const chalk = (await import("chalk")).default;
const r = await render(Spinner, {
interactive: false,
props: { frames: ["⠋"], color: "green", label: "Loading" },
});
await delay(20);
const out = r.lastFrame() ?? "";
expect(out).toContain(chalk.green("⠋"));
expect(out).toContain(" Loading");
expect(out).not.toContain(chalk.green("Loading"));
r.unmount();
});
test("label renders after the glyph with a separating space", async () => {
const r = await render(Spinner, {
interactive: false,
props: { frames: ["⠋"], label: "Done" },
});
await delay(20);
expect(r.lastFrame() ?? "").toContain("⠋ Done");
r.unmount();
});
test("no label renders the glyph only", async () => {
const r = await render(Spinner, { interactive: false, props: { frames: ["⠋"] } });
await delay(20);
expect((r.lastFrame() ?? "").trim()).toBe("⠋");
r.unmount();
});
});
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import { useAnimation, Text } from "@vue-tui/runtime";
import { spinnerProps } from "./spinner-props.ts";
import { resolveSpinner } from "./spinners.ts";
defineOptions({ name: "Spinner" });
const props = defineProps(spinnerProps);
const set = computed(() => resolveSpinner(props));
const { frame } = useAnimation({ interval: () => set.value.interval });
const glyph = computed(() => set.value.frames[frame.value % set.value.frames.length]);
</script>
<template>
<!-- The outer <Text> establishes a shared text context (runtime TextContextKey) so
both inner spans render INLINE as <tui-virtual-text> (one line, `⠋ Loading`).
Two bare sibling top-level <Text> would each be a block <tui-text> node and stack
vertically under the root's column direction. The outer Text carries no color, so
only the glyph span is tinted; the label span stays default. The separating space
is an interpolation so Vue's whitespace:'condense' keeps it. -->
<Text
><Text :color="color">{{ glyph }}</Text
><Text v-if="label">{{ " " + label }}</Text></Text
>
</template>
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "vite-plus/test";
import stringWidth from "string-width";
import { PRESETS, resolveSpinner } from "./spinners.ts";
describe("PRESETS", () => {
test("ships exactly dots and line", () => {
expect(Object.keys(PRESETS).sort()).toEqual(["dots", "line"]);
});
test("every preset frame is exactly one column wide", () => {
for (const [name, set] of Object.entries(PRESETS)) {
for (const frame of set.frames) {
expect(stringWidth(frame), `${name} frame ${JSON.stringify(frame)}`).toBe(1);
}
}
});
});
describe("resolveSpinner", () => {
test("defaults to dots at 80ms", () => {
expect(resolveSpinner({})).toEqual({ frames: PRESETS.dots.frames, interval: 80 });
});
test("selects a named preset with its own interval", () => {
expect(resolveSpinner({ type: "line" })).toEqual({
frames: PRESETS.line.frames,
interval: 130,
});
});
test("unknown type falls back to dots", () => {
expect(resolveSpinner({ type: "bogus" })).toEqual({
frames: PRESETS.dots.frames,
interval: 80,
});
});
test("custom frames override type", () => {
expect(resolveSpinner({ type: "line", frames: ["a", "b"] })).toEqual({
frames: ["a", "b"],
interval: 80,
});
});
test("empty frames fall back to dots", () => {
expect(resolveSpinner({ frames: [] })).toEqual({ frames: PRESETS.dots.frames, interval: 80 });
});
test("interval applies in preset mode", () => {
expect(resolveSpinner({ type: "dots", interval: 200 })).toEqual({
frames: PRESETS.dots.frames,
interval: 200,
});
});
test("interval applies in frames mode", () => {
expect(resolveSpinner({ frames: ["a"], interval: 50 })).toEqual({
frames: ["a"],
interval: 50,
});
});
});
+24
View File
@@ -0,0 +1,24 @@
// Curated spinner presets. Inclusion bar (see .agents/docs/components/spinner.md):
// universal default + functional fallback only, every frame width-safe (1 column).
// Everything else is reachable via the `frames`/`interval` escape hatch.
export const PRESETS = {
dots: { interval: 80, frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] },
line: { interval: 130, frames: ["-", "\\", "|", "/"] },
} satisfies Record<string, { interval: number; frames: string[] }>;
export type PresetName = keyof typeof PRESETS;
/** Resolve effective `{ frames, interval }` from spinner props. Custom `frames` win;
* an empty `frames` array and an unknown `type` both fall back to `dots`; `interval`
* overrides in either mode. Pure — no rendering, no timers. */
export function resolveSpinner(opts: {
type?: string;
frames?: readonly string[];
interval?: number;
}): { frames: string[]; interval: number } {
if (opts.frames?.length) {
return { frames: [...opts.frames], interval: opts.interval ?? 80 };
}
const p = PRESETS[opts.type as PresetName] ?? PRESETS.dots;
return { frames: p.frames, interval: opts.interval ?? p.interval };
}
+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"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite-plus";
import vueJsx from "@vitejs/plugin-vue-jsx";
import Vue from "unplugin-vue/rolldown";
import VueVite from "unplugin-vue/vite";
export default defineConfig({
plugins: [vueJsx(), VueVite()],
pack: {
plugins: [Vue({ isProduction: true })],
dts: { vue: true },
exports: true,
},
test: {
env: { FORCE_COLOR: "3", CI: "false" },
},
});
+39
View File
@@ -152,6 +152,45 @@ importers:
specifier: 8.1.0
version: 8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)
packages/components:
devDependencies:
'@types/node':
specifier: ^24.12.4
version: 24.12.4
'@vitejs/plugin-vue-jsx':
specifier: 'catalog:'
version: 5.1.5(vite@8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3))(vue@3.5.34(typescript@6.0.3))
'@vue-tui/runtime':
specifier: workspace:*
version: link:../runtime
'@vue-tui/testing':
specifier: workspace:*
version: link:../testing
chalk:
specifier: 'catalog:'
version: 5.6.2
string-width:
specifier: 'catalog:'
version: 8.2.1
strip-ansi:
specifier: 'catalog:'
version: 7.2.0
typescript:
specifier: ^6.0.3
version: 6.0.3
unplugin-vue:
specifier: 'catalog:'
version: 7.2.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(vue@3.5.34(typescript@6.0.3))
vite-plus:
specifier: 'catalog:'
version: 0.1.22(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(typescript@6.0.3)(vite@8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3))
vue:
specifier: ^3.4.0
version: 3.5.34(typescript@6.0.3)
vue-tsc:
specifier: 'catalog:'
version: 3.3.4(typescript@6.0.3)
packages/runtime:
dependencies:
'@alcalzone/ansi-tokenize':
+9
View File
@@ -74,6 +74,14 @@ export default defineConfig({
command: "vp run @vue-tui/runtime-tests#test:examples",
dependsOn: ["ci:build"],
},
// @vue-tui/components (high-level components composed from runtime
// primitives) carries its own unit suite; run it on its own parallel
// branch like the other packages. Depends on ci:build because its tests
// resolve @vue-tui/runtime + @vue-tui/testing from their built dist.
"ci:test:components": {
command: "vp run @vue-tui/components#test",
dependsOn: ["ci:build"],
},
ci: {
command: "echo ci ok",
dependsOn: [
@@ -86,6 +94,7 @@ export default defineConfig({
"ci:test:pty",
"ci:test:vite-plugin",
"ci:test:examples",
"ci:test:components",
],
},
},