@dex-ai/vue-tui 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@dex-ai/vue-tui",
3
+ "version": "0.1.10",
4
+ "description": "Vue-powered terminal UI renderer. CSS-like styling, composable components, reactive updates, SFC support.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ },
11
+ "./plugin": {
12
+ "types": "./src/plugin.ts",
13
+ "default": "./src/plugin.ts"
14
+ },
15
+ "./register": {
16
+ "default": "./src/register.ts"
17
+ },
18
+ "./composables": {
19
+ "types": "./src/composables/index.ts",
20
+ "default": "./src/composables/index.ts"
21
+ },
22
+ "./components": {
23
+ "types": "./src/components/index.ts",
24
+ "default": "./src/components/index.ts"
25
+ }
26
+ },
27
+ "files": [
28
+ "src"
29
+ ],
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit",
32
+ "demo": "bun run examples/agent.ts",
33
+ "changeset": "changeset",
34
+ "version": "changeset version",
35
+ "release": "changeset publish"
36
+ },
37
+ "dependencies": {
38
+ "@vue/runtime-core": "^3.5.0",
39
+ "@vue/reactivity": "^3.5.0",
40
+ "@vue/compiler-sfc": "^3.5.0"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.6.3",
44
+ "@changesets/cli": "^2.29.0"
45
+ },
46
+ "sideEffects": false,
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "registry": "https://registry.npmjs.org/"
50
+ }
51
+ }
package/src/app.ts ADDED
@@ -0,0 +1,385 @@
1
+ import type { Component, App } from "@vue/runtime-core";
2
+ import { createElement } from "./nodes";
3
+ import type { TElement } from "./nodes";
4
+ import type { Theme } from "./style";
5
+ import { defaultTheme } from "./theme";
6
+ import { createTerminalRenderer } from "./renderer";
7
+ import { renderToLines } from "./render";
8
+ import type { KeyEvent, KeyHandler, RawInputHandle } from "./input";
9
+ import { createRawInput } from "./input";
10
+
11
+ export interface TerminalApp {
12
+ mount(): void;
13
+ unmount(): void;
14
+ forceRedraw(): void;
15
+ /** Get the last rendered frame as an array of ANSI-styled lines. */
16
+ getFrame(): string[];
17
+ }
18
+
19
+ /** A key binding matcher — matches against a KeyEvent. */
20
+ export interface KeyBinding {
21
+ key?: string; // key name: "enter", "c", "up", etc.
22
+ shift?: boolean;
23
+ alt?: boolean;
24
+ ctrl?: boolean;
25
+ raw?: string; // exact raw sequence match (terminal-specific)
26
+ }
27
+
28
+ /** Maps key bindings to action names. */
29
+ export type KeyMap = Record<string, KeyBinding | KeyBinding[]>;
30
+
31
+ export type ActionHandler = (action: string, key: KeyEvent) => void;
32
+
33
+ export interface TerminalAppOptions {
34
+ theme?: Theme | undefined;
35
+ stdout?: NodeJS.WriteStream | undefined;
36
+ stdin?: NodeJS.ReadStream | undefined;
37
+ /** Key handler — called for each key press (after keyMap resolution). */
38
+ onKey?: KeyHandler | undefined;
39
+ /** Key map — maps action names to key bindings. */
40
+ keyMap?: KeyMap | undefined;
41
+ /** Action handler — called when a keyMap match is found. */
42
+ onAction?: ActionHandler | undefined;
43
+ }
44
+
45
+ /** Detect if the terminal supports kitty keyboard protocol. */
46
+ function detectKittyKeyboard(): boolean {
47
+ const term = process.env["TERM_PROGRAM"] ?? "";
48
+ const termName = process.env["TERM"] ?? "";
49
+ // Kitty, WezTerm, and foot natively support it
50
+ if (term === "kitty" || term === "WezTerm" || term === "foot") return true;
51
+ // xterm-kitty TERM value
52
+ if (termName === "xterm-kitty") return true;
53
+ // Ghostty supports it
54
+ if (term === "ghostty") return true;
55
+ return false;
56
+ }
57
+
58
+ /** Check if a KeyEvent matches a KeyBinding. */
59
+ function matchesBinding(key: KeyEvent, binding: KeyBinding): boolean {
60
+ // Raw sequence match — takes priority, bypasses name/modifier checks
61
+ if (binding.raw) {
62
+ return key.raw === binding.raw;
63
+ }
64
+
65
+ if (!binding.key || key.name !== binding.key) {
66
+ return false;
67
+ }
68
+ if (binding.shift && !key.shift) return false;
69
+ if (binding.alt && !key.alt) return false;
70
+ if (binding.ctrl && !key.ctrl) return false;
71
+ if (!binding.shift && key.shift) return false;
72
+ if (!binding.alt && key.alt) return false;
73
+ if (!binding.ctrl && key.ctrl) return false;
74
+ return true;
75
+ }
76
+
77
+ /** Resolve a KeyEvent against a KeyMap, returning the action name or null. */
78
+ function resolveKeyMap(key: KeyEvent, keyMap: KeyMap): string | null {
79
+ for (const [action, bindings] of Object.entries(keyMap)) {
80
+ const bindingList = Array.isArray(bindings) ? bindings : [bindings];
81
+ for (const binding of bindingList) {
82
+ if (matchesBinding(key, binding)) {
83
+ return action;
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // ANSI cursor helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Move cursor up N lines. */
95
+ function cursorUp(n: number): string {
96
+ return n > 0 ? `\x1b[${n}A` : "";
97
+ }
98
+
99
+ /** Clear from cursor to end of line. */
100
+ const CLEAR_LINE = "\x1b[2K\r";
101
+
102
+ /** Synchronized output begin/end (prevents flicker). */
103
+ const SYNC_START = "\x1b[?2026h";
104
+ const SYNC_END = "\x1b[?2026l";
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Diff writer
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Move cursor down N lines. */
111
+ function cursorDown(n: number): string {
112
+ return n > 0 ? `\x1b[${n}B` : "";
113
+ }
114
+
115
+ interface FrameState {
116
+ /** Lines from the previous render. */
117
+ lines: string[];
118
+ /** Where the viewport top is in the buffer (line index). */
119
+ viewportTop: number;
120
+ /** The row the hardware cursor is on (buffer-absolute). */
121
+ cursorRow: number;
122
+ }
123
+
124
+ /**
125
+ * Write the new frame to stdout using differential rendering.
126
+ *
127
+ * Strategy inspired by Pi TUI:
128
+ * - Track where the viewport top is (viewportTop).
129
+ * - Find firstChanged and lastChanged lines.
130
+ * - If firstChanged is above viewportTop, do a full clear+redraw.
131
+ * - Otherwise, move cursor to firstChanged, rewrite only changed lines.
132
+ * - Append new lines at the bottom naturally.
133
+ * - Never cursor-up past the viewport top.
134
+ */
135
+ function writeFrame(
136
+ state: FrameState,
137
+ next: string[],
138
+ out: NodeJS.WriteStream,
139
+ ): void {
140
+ const height = out.rows ?? 24;
141
+ const prev = state.lines;
142
+
143
+ out.write(SYNC_START);
144
+
145
+ // Helper: compute screen-row delta from current cursor to target buffer row
146
+ const computeMove = (targetRow: number): number => {
147
+ const currentScreen = state.cursorRow - state.viewportTop;
148
+ const targetScreen = targetRow - state.viewportTop;
149
+ return targetScreen - currentScreen;
150
+ };
151
+
152
+ // --- First render or force-redraw ---
153
+ if (prev.length === 0) {
154
+ // If cursorRow > 0, this is a force-redraw (not truly the first render).
155
+ // Move cursor back to the top of the viewport before rewriting.
156
+ if (state.cursorRow > 0) {
157
+ const moveUp = state.cursorRow - state.viewportTop;
158
+ if (moveUp > 0) out.write(cursorUp(moveUp));
159
+ }
160
+ for (let i = 0; i < next.length; i++) {
161
+ if (i > 0) out.write("\n");
162
+ out.write(CLEAR_LINE + next[i]!);
163
+ }
164
+ // Clear any leftover lines below (in case frame shrank since last render)
165
+ out.write("\x1b[J");
166
+ state.cursorRow = Math.max(0, next.length - 1);
167
+ state.viewportTop = Math.max(0, next.length - height);
168
+ state.lines = next;
169
+ out.write(SYNC_END);
170
+ return;
171
+ }
172
+
173
+ // --- Find changed region ---
174
+ let firstChanged = -1;
175
+ let lastChanged = -1;
176
+ const maxLines = Math.max(prev.length, next.length);
177
+
178
+ for (let i = 0; i < maxLines; i++) {
179
+ const oldLine = i < prev.length ? prev[i] : undefined;
180
+ const newLine = i < next.length ? next[i] : undefined;
181
+ if (oldLine !== newLine) {
182
+ if (firstChanged === -1) firstChanged = i;
183
+ lastChanged = i;
184
+ }
185
+ }
186
+
187
+ // Nothing changed
188
+ if (firstChanged === -1) {
189
+ out.write(SYNC_END);
190
+ return;
191
+ }
192
+
193
+ // If change is above the viewport, do a full clear+redraw.
194
+ // This handles the scroll-desync case: user scrolled up, changes are in
195
+ // scrollback that we can't reliably reach.
196
+ if (firstChanged < state.viewportTop) {
197
+ // Clear screen and scrollback, redraw everything
198
+ out.write("\x1b[2J\x1b[H\x1b[3J");
199
+ for (let i = 0; i < next.length; i++) {
200
+ if (i > 0) out.write("\n");
201
+ out.write(next[i]!);
202
+ }
203
+ state.cursorRow = Math.max(0, next.length - 1);
204
+ state.viewportTop = Math.max(0, next.length - height);
205
+ state.lines = next;
206
+ out.write(SYNC_END);
207
+ return;
208
+ }
209
+
210
+ // --- Differential render within viewport ---
211
+
212
+ // Detect append-only: new lines added at the end, nothing else changed
213
+ const appendOnly = firstChanged >= prev.length && next.length > prev.length;
214
+
215
+ if (appendOnly) {
216
+ // Move cursor to end of prev content and append new lines
217
+ const move = computeMove(prev.length - 1);
218
+ if (move > 0) out.write(cursorDown(move));
219
+ else if (move < 0) out.write(cursorUp(-move));
220
+
221
+ for (let i = prev.length; i < next.length; i++) {
222
+ out.write("\n" + CLEAR_LINE + next[i]!);
223
+ }
224
+ state.cursorRow = next.length - 1;
225
+ } else {
226
+ // Move cursor to firstChanged
227
+ const move = computeMove(firstChanged);
228
+ if (move > 0) out.write(cursorDown(move));
229
+ else if (move < 0) out.write(cursorUp(-move));
230
+
231
+ // Render from firstChanged to lastChanged (or end of next, whichever is greater)
232
+ const renderEnd = Math.max(lastChanged, next.length - 1);
233
+ for (let i = firstChanged; i <= renderEnd; i++) {
234
+ if (i > firstChanged) out.write("\n");
235
+ out.write(CLEAR_LINE + (i < next.length ? next[i]! : ""));
236
+ }
237
+
238
+ state.cursorRow = renderEnd;
239
+
240
+ // If prev was longer and there are lines beyond renderEnd to clear,
241
+ // move down and erase them. (In practice renderEnd >= prev.length - 1
242
+ // because the diff loop detects removed lines, but guard defensively.)
243
+ if (prev.length - 1 > renderEnd) {
244
+ const extra = prev.length - 1 - renderEnd;
245
+ for (let i = 0; i < extra; i++) {
246
+ out.write("\n" + CLEAR_LINE);
247
+ }
248
+ // Move back up to end of new content
249
+ out.write(cursorUp(extra));
250
+ state.cursorRow = renderEnd;
251
+ }
252
+ }
253
+
254
+ // Update viewport top
255
+ state.viewportTop = Math.max(state.viewportTop, state.cursorRow - height + 1);
256
+ state.lines = next;
257
+ out.write(SYNC_END);
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // createTerminalApp
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export function createTerminalApp(
265
+ rootComponent: Component,
266
+ options?: TerminalAppOptions,
267
+ ): TerminalApp {
268
+ const theme = options?.theme ?? defaultTheme;
269
+ const stdout: NodeJS.WriteStream = options?.stdout ?? process.stdout;
270
+ const stdin: NodeJS.ReadStream = options?.stdin ?? process.stdin;
271
+
272
+ let vueApp: App<TElement> | null = null;
273
+ let rootEl: TElement | null = null;
274
+ const frameState: FrameState = { lines: [], viewportTop: 0, cursorRow: 0 };
275
+ let scheduled = false;
276
+ let inputHandle: RawInputHandle | null = null;
277
+ const useKitty = detectKittyKeyboard();
278
+
279
+ function scheduleRender(): void {
280
+ if (scheduled) return;
281
+ scheduled = true;
282
+ // Use microtask to batch multiple synchronous mutations
283
+ queueMicrotask(doRender);
284
+ }
285
+
286
+ /** Force a full redraw by invalidating the previous frame cache. */
287
+ function forceRedraw(): void {
288
+ frameState.lines = [];
289
+ scheduleRender();
290
+ }
291
+
292
+ function doRender(): void {
293
+ scheduled = false;
294
+ if (rootEl === null) return;
295
+
296
+ const width = stdout.columns ?? 80;
297
+ const next = renderToLines(rootEl, width, theme);
298
+
299
+ writeFrame(frameState, next, stdout);
300
+ }
301
+
302
+ return {
303
+ getFrame(): string[] {
304
+ return [...frameState.lines];
305
+ },
306
+
307
+ mount(): void {
308
+ rootEl = createElement("root");
309
+
310
+ const { createApp } = createTerminalRenderer(scheduleRender);
311
+ vueApp = createApp(rootComponent);
312
+
313
+ // Hide hardware cursor — the app renders its own software cursor
314
+ stdout.write("\x1b[?25l");
315
+
316
+ // Enable bracketed paste mode so we can detect pastes
317
+ stdout.write("\x1b[?2004h");
318
+
319
+ // Enable kitty keyboard protocol if terminal supports it
320
+ if (useKitty) {
321
+ stdout.write("\x1b[>1u");
322
+ }
323
+
324
+ // Force full redraw on terminal resize
325
+ stdout.on("resize", forceRedraw);
326
+
327
+ // Force full redraw when returning from background (e.g. Ctrl+Z → fg)
328
+ process.on("SIGCONT", forceRedraw);
329
+
330
+ // Set up raw stdin input if onKey handler provided
331
+ if (options?.onKey) {
332
+ inputHandle = createRawInput((key) => {
333
+ // Resolve key through keyMap if provided
334
+ if (options.keyMap) {
335
+ const action = resolveKeyMap(key, options.keyMap);
336
+ if (action && options.onAction) {
337
+ options.onAction(action, key);
338
+ return;
339
+ }
340
+ }
341
+ options.onKey!(key);
342
+ }, stdin);
343
+ }
344
+
345
+ // Mount onto the root element — Vue will populate it
346
+ vueApp.mount(rootEl);
347
+
348
+ // Trigger initial render
349
+ scheduleRender();
350
+ },
351
+
352
+ forceRedraw,
353
+
354
+ unmount(): void {
355
+ // Tear down input
356
+ if (inputHandle !== null) {
357
+ inputHandle.destroy();
358
+ inputHandle = null;
359
+ }
360
+
361
+ // Remove event listeners
362
+ stdout.off("resize", forceRedraw);
363
+ process.off("SIGCONT", forceRedraw);
364
+
365
+ if (vueApp !== null) {
366
+ vueApp.unmount();
367
+ vueApp = null;
368
+ }
369
+ rootEl = null;
370
+ frameState.lines = [];
371
+ frameState.viewportTop = 0;
372
+ frameState.cursorRow = 0;
373
+ // Move cursor below rendered content so shell prompt doesn't overwrite
374
+ stdout.write("\r\n");
375
+ // Disable bracketed paste mode
376
+ stdout.write("\x1b[?2004l");
377
+ // Disable kitty keyboard protocol if it was enabled
378
+ if (useKitty) {
379
+ stdout.write("\x1b[<u");
380
+ }
381
+ // Ensure cursor is visible
382
+ stdout.write("\x1b[?25h");
383
+ },
384
+ };
385
+ }
@@ -0,0 +1,35 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @dex-ai/vue-tui — TuiCheckbox component
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { defineComponent, h } from "@vue/runtime-core";
6
+
7
+ /**
8
+ * Renders a checkbox as `[x]` or `[ ]` with a label.
9
+ *
10
+ * Props:
11
+ * - checked: boolean
12
+ * - label: string
13
+ * - focused: boolean (optional) — highlights when focused
14
+ */
15
+ export const TuiCheckbox = defineComponent({
16
+ name: "TuiCheckbox",
17
+ props: {
18
+ checked: { type: Boolean, required: true },
19
+ label: { type: String, required: true },
20
+ focused: { type: Boolean, default: false },
21
+ },
22
+ setup(props) {
23
+ return () => {
24
+ const box = props.checked ? "[x]" : "[ ]";
25
+ const color = props.focused ? "accent" : undefined;
26
+
27
+ return h("line", {}, [
28
+ h("text", { color, bold: props.focused }, box),
29
+ h("text", { color, style: { paddingLeft: 1 } }, props.label),
30
+ ]);
31
+ };
32
+ },
33
+ });
34
+
35
+ export default TuiCheckbox;
@@ -0,0 +1,123 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @dex-ai/vue-tui — TuiField component
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { defineComponent, h, type PropType } from "@vue/runtime-core";
6
+ import type { FormField } from "../types/form";
7
+ import { TuiCheckbox } from "./TuiCheckbox";
8
+
9
+ /**
10
+ * Renders a single form field line with focus indicator, label, separator,
11
+ * and value display. Handles text, password, select, checkbox, and number types.
12
+ *
13
+ * Props:
14
+ * - field: FormField
15
+ * - focused: boolean
16
+ * - editing: boolean
17
+ * - editBuffer: string
18
+ * - value: string | boolean | undefined
19
+ * - error: string | undefined
20
+ */
21
+ export const TuiField = defineComponent({
22
+ name: "TuiField",
23
+ props: {
24
+ field: { type: Object as PropType<FormField>, required: true },
25
+ focused: { type: Boolean, default: false },
26
+ editing: { type: Boolean, default: false },
27
+ editBuffer: { type: String, default: "" },
28
+ value: { type: [String, Boolean], default: undefined },
29
+ error: { type: String, default: undefined },
30
+ },
31
+ setup(props) {
32
+ return () => {
33
+ const { field, focused, editing, editBuffer, value, error } = props;
34
+
35
+ // Checkbox — delegate to TuiCheckbox
36
+ if (field.type === "checkbox") {
37
+ return h(TuiCheckbox, {
38
+ checked: value === true,
39
+ label: field.label,
40
+ focused,
41
+ });
42
+ }
43
+
44
+ // Standard field layout: [indicator] [label] │ [value]
45
+ const children = [];
46
+
47
+ // Focus indicator
48
+ children.push(
49
+ h(
50
+ "text",
51
+ { color: focused ? "accent" : "muted" },
52
+ focused ? "❯ " : " ",
53
+ ),
54
+ );
55
+
56
+ // Label
57
+ children.push(
58
+ h(
59
+ "text",
60
+ {
61
+ color: focused ? "text" : "muted",
62
+ bold: focused,
63
+ },
64
+ field.label,
65
+ ),
66
+ );
67
+
68
+ // Required indicator
69
+ if (field.required && !field.readonly && !value) {
70
+ children.push(
71
+ h("text", { color: "warn", style: { paddingLeft: 1 } }, "*"),
72
+ );
73
+ }
74
+
75
+ // Separator
76
+ children.push(h("text", { dim: true }, " │ "));
77
+
78
+ // Value display
79
+ if (editing) {
80
+ // Show edit buffer with cursor
81
+ children.push(
82
+ h("text", { color: "accent" }, editBuffer + "▏"),
83
+ );
84
+ } else if (field.readonly && value) {
85
+ children.push(h("text", { dim: true }, String(value)));
86
+ } else if (field.readonly) {
87
+ children.push(h("text", { dim: true }, "—"));
88
+ } else if (field.type === "password" && value) {
89
+ children.push(h("text", {}, maskPassword(String(value))));
90
+ } else if (value !== undefined && value !== "") {
91
+ children.push(h("text", {}, String(value)));
92
+ } else if (field.placeholder) {
93
+ children.push(h("text", { dim: true }, field.placeholder));
94
+ }
95
+
96
+ // Select type indicator
97
+ if (field.type === "select" && !editing) {
98
+ children.push(h("text", { dim: true, style: { paddingLeft: 1 } }, "▾"));
99
+ }
100
+
101
+ const nodes = [h("line", {}, children)];
102
+
103
+ // Error display
104
+ if (error) {
105
+ nodes.push(
106
+ h("line", {}, [
107
+ h("text", { color: "error", style: { paddingLeft: 4 } }, `⚠ ${error}`),
108
+ ]),
109
+ );
110
+ }
111
+
112
+ return h("stack", {}, nodes);
113
+ };
114
+ },
115
+ });
116
+
117
+ /** Mask a password value, showing only the last 4 chars. */
118
+ function maskPassword(val: string): string {
119
+ if (val.length <= 4) return "••••";
120
+ return "••••••••" + val.slice(-4);
121
+ }
122
+
123
+ export default TuiField;
@@ -0,0 +1,86 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @dex-ai/vue-tui — TuiSelect component
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { defineComponent, h, type PropType } from "@vue/runtime-core";
6
+
7
+ /**
8
+ * Renders a vertical select list with one highlighted option.
9
+ *
10
+ * Props:
11
+ * - options: string[]
12
+ * - selectedIndex: number
13
+ * - filter: string (optional) — shows filter text above options
14
+ * - maxVisible: number (optional, default 8) — scroll window height
15
+ * - scrollOffset: number (optional, default 0) — current scroll position
16
+ */
17
+ export const TuiSelect = defineComponent({
18
+ name: "TuiSelect",
19
+ props: {
20
+ options: { type: Array as PropType<string[]>, required: true },
21
+ selectedIndex: { type: Number, required: true },
22
+ filter: { type: String, default: "" },
23
+ maxVisible: { type: Number, default: 8 },
24
+ scrollOffset: { type: Number, default: 0 },
25
+ },
26
+ setup(props) {
27
+ return () => {
28
+ const children = [];
29
+
30
+ // Filter indicator
31
+ if (props.filter) {
32
+ children.push(
33
+ h("line", {}, [
34
+ h("text", { dim: true }, "filter: "),
35
+ h("text", { color: "accent" }, props.filter),
36
+ ]),
37
+ );
38
+ }
39
+
40
+ // Compute visible window
41
+ const total = props.options.length;
42
+ const visCount = Math.min(props.maxVisible, total);
43
+ const start = props.scrollOffset;
44
+ const end = Math.min(start + visCount, total);
45
+
46
+ // Scroll indicator (top)
47
+ if (start > 0) {
48
+ children.push(h("text", { dim: true }, " ↑ more"));
49
+ }
50
+
51
+ // Visible options
52
+ for (let i = start; i < end; i++) {
53
+ const selected = i === props.selectedIndex;
54
+ const option = props.options[i] ?? "";
55
+
56
+ children.push(
57
+ h("line", {}, [
58
+ h(
59
+ "text",
60
+ selected
61
+ ? { color: "accent", bold: true }
62
+ : {},
63
+ selected ? "▸ " : " ",
64
+ ),
65
+ h(
66
+ "text",
67
+ selected
68
+ ? { color: "accent", bold: true }
69
+ : {},
70
+ option,
71
+ ),
72
+ ]),
73
+ );
74
+ }
75
+
76
+ // Scroll indicator (bottom)
77
+ if (end < total) {
78
+ children.push(h("text", { dim: true }, " ↓ more"));
79
+ }
80
+
81
+ return h("stack", {}, children);
82
+ };
83
+ },
84
+ });
85
+
86
+ export default TuiSelect;
@@ -0,0 +1,7 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @dex-ai/vue-tui/components — barrel export
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export { TuiField } from "./TuiField";
6
+ export { TuiSelect } from "./TuiSelect";
7
+ export { TuiCheckbox } from "./TuiCheckbox";