From c3185688f95cffcd4ce2db5d13d8d11b7295b65a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 02:35:01 +0530 Subject: [PATCH] prototype: explore Odin InkUI TUI workflow --- prototype/odin-tui/.gitignore | 1 + prototype/odin-tui/README.md | 9 + prototype/odin-tui/components/ui/_core.ts | 110 ++ .../odin-tui/components/ui/badge/Badge.tsx | 38 + .../odin-tui/components/ui/badge/index.ts | 2 + .../odin-tui/components/ui/gauge/Gauge.tsx | 92 ++ .../odin-tui/components/ui/gauge/index.ts | 2 + .../odin-tui/components/ui/header/Header.tsx | 78 ++ .../odin-tui/components/ui/header/index.ts | 2 + .../components/ui/key-hint/KeyHint.tsx | 30 + .../odin-tui/components/ui/key-hint/index.ts | 2 + .../odin-tui/components/ui/panel/Panel.tsx | 147 +++ .../odin-tui/components/ui/panel/index.ts | 2 + .../ui/progress-bar/ProgressBar.tsx | 58 + .../components/ui/progress-bar/index.ts | 2 + .../components/ui/scroll-area/ScrollArea.tsx | 91 ++ .../components/ui/scroll-area/index.ts | 2 + .../components/ui/sparkline/Sparkline.tsx | 74 ++ .../odin-tui/components/ui/sparkline/index.ts | 2 + .../components/ui/stepper/Stepper.tsx | 111 ++ .../odin-tui/components/ui/stepper/index.ts | 2 + prototype/odin-tui/package-lock.json | 1054 +++++++++++++++++ prototype/odin-tui/package.json | 16 + prototype/odin-tui/run.sh | 7 + prototype/odin-tui/src/main.tsx | 547 +++++++++ 25 files changed, 2481 insertions(+) create mode 100644 prototype/odin-tui/.gitignore create mode 100644 prototype/odin-tui/README.md create mode 100644 prototype/odin-tui/components/ui/_core.ts create mode 100644 prototype/odin-tui/components/ui/badge/Badge.tsx create mode 100644 prototype/odin-tui/components/ui/badge/index.ts create mode 100644 prototype/odin-tui/components/ui/gauge/Gauge.tsx create mode 100644 prototype/odin-tui/components/ui/gauge/index.ts create mode 100644 prototype/odin-tui/components/ui/header/Header.tsx create mode 100644 prototype/odin-tui/components/ui/header/index.ts create mode 100644 prototype/odin-tui/components/ui/key-hint/KeyHint.tsx create mode 100644 prototype/odin-tui/components/ui/key-hint/index.ts create mode 100644 prototype/odin-tui/components/ui/panel/Panel.tsx create mode 100644 prototype/odin-tui/components/ui/panel/index.ts create mode 100644 prototype/odin-tui/components/ui/progress-bar/ProgressBar.tsx create mode 100644 prototype/odin-tui/components/ui/progress-bar/index.ts create mode 100644 prototype/odin-tui/components/ui/scroll-area/ScrollArea.tsx create mode 100644 prototype/odin-tui/components/ui/scroll-area/index.ts create mode 100644 prototype/odin-tui/components/ui/sparkline/Sparkline.tsx create mode 100644 prototype/odin-tui/components/ui/sparkline/index.ts create mode 100644 prototype/odin-tui/components/ui/stepper/Stepper.tsx create mode 100644 prototype/odin-tui/components/ui/stepper/index.ts create mode 100644 prototype/odin-tui/package-lock.json create mode 100644 prototype/odin-tui/package.json create mode 100755 prototype/odin-tui/run.sh create mode 100644 prototype/odin-tui/src/main.tsx diff --git a/prototype/odin-tui/.gitignore b/prototype/odin-tui/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/prototype/odin-tui/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/prototype/odin-tui/README.md b/prototype/odin-tui/README.md new file mode 100644 index 0000000..93515fc --- /dev/null +++ b/prototype/odin-tui/README.md @@ -0,0 +1,9 @@ +# Odin TUI workflow prototype + +Disposable InkUI prototype for choosing between three structurally different benchmark workflows. All measurements and findings are sample data; no benchmark runs or system changes occur. + +Run with `./run.sh` from this directory. On first run, the script installs the pinned local dependencies. + +Controls: `[` / `]` change layout; `p` changes profile; `Enter` moves through the run; `c` requests cancellation; `g` plan, `l` live run, `s` summary, `h` history, `o` advice; `t` theme; `a` ASCII fallback; `q` quit. + +Mouse support is experimental. Click visible action rows in terminals that send SGR mouse reports. Resize terminal while running to inspect reflow. If your shell sets `NO_COLOR`, use `env -u NO_COLOR ./run.sh` to inspect the color theme. diff --git a/prototype/odin-tui/components/ui/_core.ts b/prototype/odin-tui/components/ui/_core.ts new file mode 100644 index 0000000..7cc41fc --- /dev/null +++ b/prototype/odin-tui/components/ui/_core.ts @@ -0,0 +1,110 @@ +// Auto-generated by sync-registry.ts +// Inlines @inkui-cli/core theme and tokens + +export const spacing = { + none: 0, + xs: 1, + sm: 1, + md: 2, + lg: 3, + xl: 4, +} as const; + +export const borderStyles = { + single: { + topLeft: '┌', top: '─', topRight: '┐', + left: '│', right: '│', + bottomLeft: '└', bottom: '─', bottomRight: '┘', + topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼', + }, + double: { + topLeft: '╔', top: '═', topRight: '╗', + left: '║', right: '║', + bottomLeft: '╚', bottom: '═', bottomRight: '╝', + topT: '╦', bottomT: '╩', leftT: '╠', rightT: '╣', cross: '╬', + }, + rounded: { + topLeft: '╭', top: '─', topRight: '╮', + left: '│', right: '│', + bottomLeft: '╰', bottom: '─', bottomRight: '╯', + topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼', + }, + bold: { + topLeft: '┏', top: '━', topRight: '┓', + left: '┃', right: '┃', + bottomLeft: '┗', bottom: '━', bottomRight: '┛', + topT: '┳', bottomT: '┻', leftT: '┣', rightT: '┫', cross: '╋', + }, + ascii: { + topLeft: '+', top: '-', topRight: '+', + left: '|', right: '|', + bottomLeft: '+', bottom: '-', bottomRight: '+', + topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+', + }, +} as const; + +export type BorderStyle = keyof typeof borderStyles; + +export const spinnerFrames = { + dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + line: ['−', '\\', '|', '/'], + arc: ['◜', '◠', '◝', '◞', '◡', '◟'], + bounce: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], +} as const; + +export type SpinnerType = keyof typeof spinnerFrames; + + +export interface InkUITheme { + colors: { + primary: string; + secondary: string; + success: string; + warning: string; + error: string; + info: string; + muted: string; + text: string; + textInverse: string; + border: string; + focus: string; + selection: string; + }; + border: 'single' | 'double' | 'rounded' | 'bold' | 'ascii'; +} + +export const darkTheme: InkUITheme = { + colors: { + primary: 'cyan', + secondary: 'magenta', + success: 'green', + warning: 'yellow', + error: 'red', + info: 'blue', + muted: 'gray', + text: 'white', + textInverse: 'black', + border: 'gray', + focus: 'cyan', + selection: 'blue', + }, + border: 'single', +}; + +export const lightTheme: InkUITheme = { + colors: { + primary: 'blue', + secondary: 'magenta', + success: 'green', + warning: 'yellow', + error: 'red', + info: 'cyan', + muted: 'gray', + text: 'black', + textInverse: 'white', + border: 'gray', + focus: 'blue', + selection: 'cyan', + }, + border: 'rounded', +}; diff --git a/prototype/odin-tui/components/ui/badge/Badge.tsx b/prototype/odin-tui/components/ui/badge/Badge.tsx new file mode 100644 index 0000000..2e4a806 --- /dev/null +++ b/prototype/odin-tui/components/ui/badge/Badge.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Text, Box } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info'; + +export interface BadgeProps { + /** Badge label text */ + children: string; + /** Visual style — maps to theme colors */ + variant?: BadgeVariant; + /** Theme override — defaults to darkTheme */ + theme?: InkUITheme; +} + +function variantColor(variant: BadgeVariant, theme: InkUITheme): string { + switch (variant) { + case 'success': return theme.colors.success; + case 'warning': return theme.colors.warning; + case 'error': return theme.colors.error; + case 'info': return theme.colors.info; + default: return theme.colors.muted; + } +} + +export const Badge: React.FC = ({ + children, + variant = 'default', + theme = darkTheme, +}) => { + const color = variantColor(variant, theme); + return ( + + {` ${children} `} + + ); +}; diff --git a/prototype/odin-tui/components/ui/badge/index.ts b/prototype/odin-tui/components/ui/badge/index.ts new file mode 100644 index 0000000..1e43a9d --- /dev/null +++ b/prototype/odin-tui/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export type { BadgeProps, BadgeVariant } from './Badge.js'; +export { Badge } from './Badge.js'; diff --git a/prototype/odin-tui/components/ui/gauge/Gauge.tsx b/prototype/odin-tui/components/ui/gauge/Gauge.tsx new file mode 100644 index 0000000..b8ec744 --- /dev/null +++ b/prototype/odin-tui/components/ui/gauge/Gauge.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export interface GaugeProps { + value: number; + min?: number; + max?: number; + label?: string; + unit?: string; + variant?: 'arc' | 'bar' | 'ring'; + colorStops?: [number, string][]; + width?: number; + showValue?: boolean; + theme?: InkUITheme; +} + +export const Gauge: React.FC = ({ + value, + min = 0, + max = 100, + label, + unit = '%', + variant = 'bar', + colorStops, + width = 20, + showValue = true, + theme = darkTheme, +}) => { + const pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); + + const defaultStops: [number, string][] = [ + [0, theme.colors.success], + [60, theme.colors.warning], + [80, theme.colors.error], + ]; + const stops = colorStops ?? defaultStops; + let color = stops[0][1]; + for (const [threshold, c] of stops) { + if (pct >= threshold) color = c; + } + + const displayValue = `${Math.round(value)}${unit}`; + + if (variant === 'bar') { + const filled = Math.round((pct / 100) * width); + const bar = '█'.repeat(filled) + '░'.repeat(width - filled); + return ( + + {label && {label}} + {bar} + {showValue && {displayValue}} + + ); + } + + if (variant === 'ring') { + const ringWidth = Math.floor(width / 2); + const topFilled = Math.round((pct / 100) * ringWidth); + const topBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled); + const botBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled); + + return ( + + {'◜'}{topBar}{'◝'} + {'█'}{' '.repeat(ringWidth)}{' █'} + {'█'} {displayValue.padStart(ringWidth - 1)}{' █'} + {'█'}{' '.repeat(ringWidth)}{' █'} + {'◟'}{botBar}{'◞'} + {label && {label}} + + ); + } + + // arc variant + const arcWidth = Math.max(10, width); + const filled = Math.round((pct / 100) * arcWidth); + const arcBar = '█'.repeat(filled) + '░'.repeat(arcWidth - filled); + + return ( + + {' ╭' + '─'.repeat(arcWidth) + '╮'} + {' ╱'}{' '.repeat(arcWidth + 2)}{'╲'} + {'╱ '}{arcBar}{' ╲'} + {'│'}{' '.repeat(Math.floor(arcWidth / 2))}{displayValue}{' '.repeat(Math.ceil(arcWidth / 2))}{'│'} + {'╲'}{' '.repeat(arcWidth + 2)}{'╱'} + {' ╰' + '─'.repeat(arcWidth) + '╯'} + {label && {' '}{label}} + + ); +}; diff --git a/prototype/odin-tui/components/ui/gauge/index.ts b/prototype/odin-tui/components/ui/gauge/index.ts new file mode 100644 index 0000000..49f01fc --- /dev/null +++ b/prototype/odin-tui/components/ui/gauge/index.ts @@ -0,0 +1,2 @@ +export { Gauge } from './Gauge.js'; +export type { GaugeProps } from './Gauge.js'; diff --git a/prototype/odin-tui/components/ui/header/Header.tsx b/prototype/odin-tui/components/ui/header/Header.tsx new file mode 100644 index 0000000..24cb6ec --- /dev/null +++ b/prototype/odin-tui/components/ui/header/Header.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Text, Box, useStdout } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export type HeaderStyle = 'box' | 'line' | 'filled'; + +export interface HeaderProps { + title: string; + version?: string; + subtitle?: string; + style?: HeaderStyle; + align?: 'left' | 'center'; + theme?: InkUITheme; +} + +export const Header: React.FC = ({ + title, + version, + subtitle, + style = 'box', + align = 'left', + theme = darkTheme, +}) => { + const { stdout } = useStdout(); + const w = stdout?.columns ?? 80; + const fullTitle = version ? `${title} v${version}` : title; + + if (style === 'box') { + // ┌─── MyApp v1.0 ──────────────────┐ + const inner = w - 2; // exclude ┌ and ┐ + const label = ` ${fullTitle} `; + const left = 3; // ─── before label + const right = Math.max(0, inner - left - label.length); + const top = '┌' + '─'.repeat(left) + label + '─'.repeat(right) + '┐'; + const bot = '└' + '─'.repeat(inner) + '┘'; + + return ( + + {top} + {subtitle && ( + + {'│'} {subtitle.padEnd(inner - 2)} {'│'} + + )} + {bot} + + ); + } + + if (style === 'line') { + // ══ MyApp v1.0 ════════════════════ + const label = ` ${fullTitle} `; + const pre = '══'; + const remaining = Math.max(0, w - pre.length - label.length); + const line = pre + label + '═'.repeat(remaining); + + return ( + + {line} + {subtitle && {subtitle}} + + ); + } + + // filled: ███ MyApp v1.0 ████████████ + const label = ` ${fullTitle} `; + const pre = '██ '; + const remaining = Math.max(0, w - pre.length - label.length); + const line = pre + label + '█'.repeat(remaining); + + return ( + + {line} + {subtitle && {subtitle}} + + ); +}; diff --git a/prototype/odin-tui/components/ui/header/index.ts b/prototype/odin-tui/components/ui/header/index.ts new file mode 100644 index 0000000..2870c84 --- /dev/null +++ b/prototype/odin-tui/components/ui/header/index.ts @@ -0,0 +1,2 @@ +export type { HeaderProps, HeaderStyle } from './Header.js'; +export { Header } from './Header.js'; diff --git a/prototype/odin-tui/components/ui/key-hint/KeyHint.tsx b/prototype/odin-tui/components/ui/key-hint/KeyHint.tsx new file mode 100644 index 0000000..a9babe3 --- /dev/null +++ b/prototype/odin-tui/components/ui/key-hint/KeyHint.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Text, Box } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export interface KeyHintItem { + /** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */ + key: string; + /** Description label, e.g. "Select", "Navigate", "Toggle" */ + label: string; +} + +export interface KeyHintProps { + keys: KeyHintItem[]; + theme?: InkUITheme; +} + +export const KeyHint: React.FC = ({ + keys, + theme = darkTheme, +}) => ( + + {keys.map(({ key, label }) => ( + + [{key}] + {label} + + ))} + +); diff --git a/prototype/odin-tui/components/ui/key-hint/index.ts b/prototype/odin-tui/components/ui/key-hint/index.ts new file mode 100644 index 0000000..1315162 --- /dev/null +++ b/prototype/odin-tui/components/ui/key-hint/index.ts @@ -0,0 +1,2 @@ +export type { KeyHintProps, KeyHintItem } from './KeyHint.js'; +export { KeyHint } from './KeyHint.js'; diff --git a/prototype/odin-tui/components/ui/panel/Panel.tsx b/prototype/odin-tui/components/ui/panel/Panel.tsx new file mode 100644 index 0000000..dd5bcd0 --- /dev/null +++ b/prototype/odin-tui/components/ui/panel/Panel.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, useStdout } from 'ink'; +import stringWidth from 'string-width'; +import { borderStyles, darkTheme } from '../_core.js'; +import type { BorderStyle, InkUITheme } from '../_core.js'; + +export interface SplitPaneProps { + /** Split direction */ + direction?: 'horizontal' | 'vertical'; + /** Sizes as flex ratios, e.g. [1, 2] for 33%/67% */ + sizes?: number[]; + /** Gap between panes (columns/rows) */ + gap?: number; + theme?: InkUITheme; + children: React.ReactNode; +} + +export function SplitPane({ + direction = 'horizontal', + sizes, + gap = 1, + children, +}: SplitPaneProps) { + const childArray = React.Children.toArray(children); + + return ( + + {childArray.map((child, i) => { + const flex = sizes ? sizes[i] ?? 1 : 1; + return ( + + {child} + + ); + })} + + ); +} + +// Map our BorderStyle names → Ink/cli-boxes names +// ('rounded' → 'round', 'ascii' → 'classic', others match directly) +const INK_BORDER_MAP: Record = { + single: 'single', + double: 'double', + rounded: 'round', + bold: 'bold', + ascii: 'classic', +}; + +export interface PanelProps { + title?: string; + children?: React.ReactNode; + borderStyle?: BorderStyle; + /** Fixed column width (includes borders). 'auto' = fill terminal width. */ + width?: number | 'auto'; + /** Inner horizontal padding on each side, default 1 */ + padding?: number; + borderColor?: string; + titleColor?: string; + theme?: InkUITheme; +} + +function repeat(char: string, n: number): string { + return n <= 0 ? '' : char.repeat(n); +} + +export function Panel({ + title, + children, + borderStyle = 'rounded', + width = 'auto', + padding = 1, + borderColor, + titleColor: _titleColor, + theme = darkTheme, +}: PanelProps) { + const { stdout } = useStdout(); + + // Track terminal width for auto-sizing + const [termCols, setTermCols] = useState(stdout?.columns ?? 80); + useEffect(() => { + if (!stdout) return; + const handler = () => setTermCols(stdout.columns ?? 80); + stdout.on('resize', handler); + return () => { stdout.off('resize', handler); }; + }, [stdout]); + + const chars = borderStyles[borderStyle]; + const bc = borderColor ?? theme.colors.border; + + // Total panel width (includes the 2 border chars on each side) + const totalWidth: number = width === 'auto' ? termCols : (width as number); + // Inner width = space between left and right border chars + const innerWidth = Math.max(4, totalWidth - 2); + + // ── Build the custom top border line with optional embedded title ── + let topLine: string; + if (title) { + // Max visible title length: innerWidth - "─ " - " ─" = innerWidth - 4 + const maxTitleLen = Math.max(0, innerWidth - 4); + const raw = stringWidth(title) > maxTitleLen + ? title.slice(0, Math.max(0, maxTitleLen - 1)) + '…' + : title; + // titleCell = " Title " + const titleCell = ' ' + raw + ' '; + const leading = 1; // one '─' before the title + const remaining = innerWidth - leading - stringWidth(titleCell); + topLine = + chars.topLeft + + repeat(chars.top, leading) + + titleCell + + repeat(chars.top, Math.max(0, remaining)) + + chars.topRight; + } else { + topLine = + chars.topLeft + + repeat(chars.top, innerWidth) + + chars.topRight; + } + + return ( + + {/* Custom top border with optional title */} + {topLine} + + {/* + Native Ink Box with borderStyle for left, right, bottom. + borderTop={false} removes the top line — our custom line above handles it. + The corner chars from Ink's bottom border (╰ and ╯ for rounded) naturally + connect to our top line since we used the same borderStyle chars. + */} + + {children} + + + ); +} diff --git a/prototype/odin-tui/components/ui/panel/index.ts b/prototype/odin-tui/components/ui/panel/index.ts new file mode 100644 index 0000000..b0a5cf0 --- /dev/null +++ b/prototype/odin-tui/components/ui/panel/index.ts @@ -0,0 +1,2 @@ +export { Panel, SplitPane } from './Panel.js'; +export type { PanelProps, SplitPaneProps } from './Panel.js'; diff --git a/prototype/odin-tui/components/ui/progress-bar/ProgressBar.tsx b/prototype/odin-tui/components/ui/progress-bar/ProgressBar.tsx new file mode 100644 index 0000000..b2b578c --- /dev/null +++ b/prototype/odin-tui/components/ui/progress-bar/ProgressBar.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export interface ProgressBarProps { + /** Progress value 0–100 */ + value: number; + /** Optional label shown to the left of the bar */ + label?: string; + /** Show percentage at the right end */ + showPercent?: boolean; + /** Fixed bar width in columns — defaults to auto (fills terminal width) */ + width?: number; + /** Theme override — defaults to darkTheme */ + theme?: InkUITheme; +} + +const FILL = '█'; +const EMPTY = '░'; + +export const ProgressBar: React.FC = ({ + value, + label, + showPercent = true, + width, + theme = darkTheme, +}) => { + const { stdout } = useStdout(); + const termWidth = stdout?.columns ?? 80; + + // Clamp value to [0, 100] + const pct = Math.min(100, Math.max(0, value)); + + // Build the bar width: + // total - label - space - percent suffix (e.g. " 100%") - 2 padding spaces + const percentSuffix = showPercent ? ` ${String(Math.round(pct)).padStart(3)}%` : ''; + const labelPrefix = label ? `${label} ` : ''; + const overhead = labelPrefix.length + percentSuffix.length; + const barWidth = width ?? Math.max(8, termWidth - overhead); + + const filled = Math.round((pct / 100) * barWidth); + const empty = barWidth - filled; + + const filledStr = FILL.repeat(filled); + const emptyStr = EMPTY.repeat(empty); + + return ( + + {label ? {label} : null} + {filledStr} + {emptyStr} + {showPercent ? ( + {percentSuffix} + ) : null} + + ); +}; diff --git a/prototype/odin-tui/components/ui/progress-bar/index.ts b/prototype/odin-tui/components/ui/progress-bar/index.ts new file mode 100644 index 0000000..c939d48 --- /dev/null +++ b/prototype/odin-tui/components/ui/progress-bar/index.ts @@ -0,0 +1,2 @@ +export type { ProgressBarProps } from './ProgressBar.js'; +export { ProgressBar } from './ProgressBar.js'; diff --git a/prototype/odin-tui/components/ui/scroll-area/ScrollArea.tsx b/prototype/odin-tui/components/ui/scroll-area/ScrollArea.tsx new file mode 100644 index 0000000..3567892 --- /dev/null +++ b/prototype/odin-tui/components/ui/scroll-area/ScrollArea.tsx @@ -0,0 +1,91 @@ +import React, { useState, useCallback } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export interface ScrollAreaProps { + /** Visible height in rows */ + height: number; + /** Scrollbar visibility */ + scrollbar?: boolean; + /** Scrollbar thumb character */ + scrollbarChar?: string; + /** Track character */ + trackChar?: string; + /** Called when scroll position changes */ + onScroll?: (offset: number, total: number) => void; + /** Whether this component accepts keyboard input */ + focus?: boolean; + /** Color theme */ + theme?: InkUITheme; + children: React.ReactNode; +} + +export const ScrollArea: React.FC = ({ + height, + scrollbar = true, + scrollbarChar = '█', + trackChar = '░', + onScroll, + focus = true, + theme = darkTheme, + children, +}) => { + const [scrollOffset, setScrollOffset] = useState(0); + + const items = React.Children.toArray(children); + const totalItems = items.length; + const maxOffset = Math.max(0, totalItems - height); + + const scroll = useCallback( + (delta: number) => { + setScrollOffset((prev) => { + const next = Math.max(0, Math.min(maxOffset, prev + delta)); + onScroll?.(next, totalItems); + return next; + }); + }, + [maxOffset, totalItems, onScroll] + ); + + useInput( + (input, key) => { + if (key.upArrow || input === 'k') scroll(-1); + else if (key.downArrow || input === 'j') scroll(1); + else if (key.pageUp || input === 'u') scroll(-Math.floor(height / 2)); + else if (key.pageDown || input === 'd') scroll(Math.floor(height / 2)); + else if (input === 'g') scroll(-totalItems); + else if (input === 'G') scroll(totalItems); + }, + { isActive: focus } + ); + + const visibleItems = items.slice(scrollOffset, scrollOffset + height); + + // Scrollbar calculation + const thumbSize = Math.max(1, Math.round((height / Math.max(totalItems, 1)) * height)); + const thumbPos = + maxOffset > 0 + ? Math.round((scrollOffset / maxOffset) * (height - thumbSize)) + : 0; + + return ( + + + {visibleItems} + + {scrollbar && totalItems > height && ( + + {Array.from({ length: height }, (_, i) => { + const isThumb = i >= thumbPos && i < thumbPos + thumbSize; + return ( + + {isThumb ? scrollbarChar : trackChar} + + ); + })} + + )} + + ); +}; diff --git a/prototype/odin-tui/components/ui/scroll-area/index.ts b/prototype/odin-tui/components/ui/scroll-area/index.ts new file mode 100644 index 0000000..78ab786 --- /dev/null +++ b/prototype/odin-tui/components/ui/scroll-area/index.ts @@ -0,0 +1,2 @@ +export { ScrollArea } from './ScrollArea.js'; +export type { ScrollAreaProps } from './ScrollArea.js'; diff --git a/prototype/odin-tui/components/ui/sparkline/Sparkline.tsx b/prototype/odin-tui/components/ui/sparkline/Sparkline.tsx new file mode 100644 index 0000000..76178cc --- /dev/null +++ b/prototype/odin-tui/components/ui/sparkline/Sparkline.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +export interface SparklineProps { + data: number[]; + width?: number; + height?: number; + label?: string; + showRange?: boolean; + showLatest?: boolean; + color?: string; + theme?: InkUITheme; +} + +export const Sparkline: React.FC = ({ + data, + width = 20, + height = 1, + label, + showRange = false, + showLatest = true, + color, + theme = darkTheme, +}) => { + if (!data || data.length === 0) { + return {label ?? ''} (no data); + } + + const barColor = color ?? theme.colors.primary; + const minVal = Math.min(...data); + const maxVal = Math.max(...data); + const range = maxVal - minVal || 1; + + // Downsample or use as-is + let points: number[]; + if (data.length > width) { + const step = data.length / width; + points = Array.from({ length: width }, (_, i) => { + const start = Math.floor(i * step); + const end = Math.floor((i + 1) * step); + const slice = data.slice(start, end); + return slice.reduce((a, b) => a + b, 0) / slice.length; + }); + } else if (data.length < width) { + points = [...data, ...Array(width - data.length).fill(data[data.length - 1])]; + } else { + points = data; + } + + const chars = points.map((v) => { + const normalized = (v - minVal) / range; + const idx = Math.min(7, Math.round(normalized * 7)); + return BLOCKS[idx]; + }); + + const sparkStr = chars.join(''); + + return ( + + {label && {label}} + {sparkStr} + {showLatest && {Math.round(data[data.length - 1])}} + {showRange && ( + + (min: {Math.round(minVal)} max: {Math.round(maxVal)}) + + )} + + ); +}; diff --git a/prototype/odin-tui/components/ui/sparkline/index.ts b/prototype/odin-tui/components/ui/sparkline/index.ts new file mode 100644 index 0000000..038963c --- /dev/null +++ b/prototype/odin-tui/components/ui/sparkline/index.ts @@ -0,0 +1,2 @@ +export { Sparkline } from './Sparkline.js'; +export type { SparklineProps } from './Sparkline.js'; diff --git a/prototype/odin-tui/components/ui/stepper/Stepper.tsx b/prototype/odin-tui/components/ui/stepper/Stepper.tsx new file mode 100644 index 0000000..ca47f98 --- /dev/null +++ b/prototype/odin-tui/components/ui/stepper/Stepper.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { darkTheme } from '../_core.js'; +import type { InkUITheme } from '../_core.js'; + +export interface Step { + key: string; + title: string; + description?: string; + optional?: boolean; +} + +export interface StepperProps { + steps: Step[]; + currentStep: string; + orientation?: 'horizontal' | 'vertical'; + showNumbers?: boolean; + completedSteps?: string[]; + errorSteps?: string[]; + theme?: InkUITheme; +} + +export const Stepper: React.FC = ({ + steps, + currentStep, + orientation = 'horizontal', + showNumbers = false, + completedSteps = [], + errorSteps = [], + theme = darkTheme, +}) => { + const getIndicator = (step: Step, index: number) => { + if (errorSteps.includes(step.key)) return { char: '✕', color: theme.colors.error }; + if (completedSteps.includes(step.key)) return { char: '✓', color: theme.colors.success }; + if (step.key === currentStep) return { char: '●', color: theme.colors.primary }; + return { char: '○', color: theme.colors.muted }; + }; + + if (orientation === 'vertical') { + return ( + + {steps.map((step, i) => { + const { char, color } = getIndicator(step, i); + const isCurrent = step.key === currentStep; + const connector = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border; + + return ( + + + {showNumbers && {i + 1}.} + {char} + + {step.title} + {step.optional && (optional)} + + + {step.description && ( + + │ {step.description} + + )} + {i < steps.length - 1 && ( + + │ + + )} + + ); + })} + + ); + } + + // Horizontal + return ( + + + {steps.map((step, i) => { + const { char, color } = getIndicator(step, i); + const isCurrent = step.key === currentStep; + const connectorColor = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border; + + return ( + + {showNumbers && {i + 1}.} + {char} + + {' '}{step.title}{step.optional ? ' (opt)' : ''} + + {i < steps.length - 1 && ( + {' ── '} + )} + + ); + })} + + + {steps.map((step, i) => ( + + {step.description && ( + + {' '}{step.description} + {i < steps.length - 1 ? ' ' : ''} + + )} + + ))} + + + ); +}; diff --git a/prototype/odin-tui/components/ui/stepper/index.ts b/prototype/odin-tui/components/ui/stepper/index.ts new file mode 100644 index 0000000..9f7d7fd --- /dev/null +++ b/prototype/odin-tui/components/ui/stepper/index.ts @@ -0,0 +1,2 @@ +export { Stepper } from './Stepper.js'; +export type { StepperProps, Step } from './Stepper.js'; diff --git a/prototype/odin-tui/package-lock.json b/prototype/odin-tui/package-lock.json new file mode 100644 index 0000000..8f28d4a --- /dev/null +++ b/prototype/odin-tui/package-lock.json @@ -0,0 +1,1054 @@ +{ + "name": "odin-tui-workflow-prototype", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "odin-tui-workflow-prototype", + "version": "0.0.0", + "dependencies": { + "ink": "7.1.1", + "react": "19.3.0" + }, + "devDependencies": { + "tsx": "4.23.15" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.1.tgz", + "integrity": "sha512-cUD0PT0YX+rImRhfn7BfMRduP/tX/Zqt/N0dHlAyn2RlcJssCb5KsCJkhln61eh9vuJDkH8wKn3L8zJzzWXPFg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.1.tgz", + "integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz", + "integrity": "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + } + } +} diff --git a/prototype/odin-tui/package.json b/prototype/odin-tui/package.json new file mode 100644 index 0000000..0a495a3 --- /dev/null +++ b/prototype/odin-tui/package.json @@ -0,0 +1,16 @@ +{ + "name": "odin-tui-workflow-prototype", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/main.tsx" + }, + "dependencies": { + "ink": "7.1.1", + "react": "19.3.0" + }, + "devDependencies": { + "tsx": "4.23.15" + } +} diff --git a/prototype/odin-tui/run.sh b/prototype/odin-tui/run.sh new file mode 100755 index 0000000..f88d3d1 --- /dev/null +++ b/prototype/odin-tui/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +if [[ ! -d node_modules ]]; then + npm install --no-audit --no-fund +fi +exec npm start diff --git a/prototype/odin-tui/src/main.tsx b/prototype/odin-tui/src/main.tsx new file mode 100644 index 0000000..e744257 --- /dev/null +++ b/prototype/odin-tui/src/main.tsx @@ -0,0 +1,547 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Text, + measureElement, + render, + useApp, + useInput, + useWindowSize, +} from 'ink'; +import { Badge } from '../components/ui/badge/index.js'; +import { Header } from '../components/ui/header/index.js'; +import { KeyHint } from '../components/ui/key-hint/index.js'; +import { ProgressBar } from '../components/ui/progress-bar/index.js'; +import { ScrollArea } from '../components/ui/scroll-area/index.js'; +import { Sparkline } from '../components/ui/sparkline/index.js'; +import { Stepper } from '../components/ui/stepper/index.js'; +import { Gauge } from '../components/ui/gauge/index.js'; +import { darkTheme, lightTheme } from '../components/ui/_core.js'; + +const phases = [ + { id: 'plan', key: 'g', title: 'Plan' }, + { id: 'run', key: 'l', title: 'Live run' }, + { id: 'results', key: 's', title: 'Summary' }, + { id: 'history', key: 'h', title: 'History' }, + { id: 'advice', key: 'o', title: 'Advice' }, +]; + +const profiles = [ + { id: 'quick', label: 'Quick', time: '~4 min', impact: 'Low', note: 'Read-only triage' }, + { id: 'standard', label: 'Standard', time: '~14 min', impact: 'Moderate', note: 'All capable domains' }, + { id: 'extended', label: 'Extended', time: '~38 min', impact: 'High', note: 'Sustained load; opt in' }, +]; + +const domains = [ + { name: 'CPU', state: 'Ready', score: '96.4' }, + { name: 'Memory', state: 'Ready', score: '91.2' }, + { name: 'GPU', state: 'Unavailable', score: '—', reason: 'No supported compute driver' }, + { name: 'Storage', state: 'Ready', score: '94.1' }, + { name: 'Browser', state: 'Ready', score: '90.5' }, + { name: 'Bash', state: 'Ready', score: '97.6' }, + { name: 'Languages', state: 'Ready', score: '93.0' }, +]; + +const variantNames = ['Mission Control', 'Guided Run', 'Run Ledger']; +const scoreValues = [96.4, 91.2, 94.1, 90.5, 97.6, 93.0]; + +function Card({ title, children, ascii, theme, width, flexGrow = 0 }) { + return ( + + {title} + {children} + + ); +} + +function ClickRow({ id, register, children, selected = false, theme, ascii }) { + return ( + + + {selected ? (ascii ? '> ' : '› ') : ' '}{children} + + + ); +} + +function StageNav({ phase, register, theme, ascii, vertical = false }) { + return ( + + {phases.map((item) => ( + + + {ascii ? `[${item.key}] ${item.title}` : ` ${item.title} `} + + + ))} + + ); +} + +function ProfileRows({ profile, setProfile, register, theme, ascii, compact = false }) { + return ( + + {profiles.map((item) => ( + + + {profile === item.id ? (ascii ? '> ' : '◆ ') : ' '} + {item.label} {item.time}{compact ? '' : ` ${item.impact}`} + + {!compact && {item.note}} + + ))} + {ascii ? 'p changes profile' : 'p changes profile; arrows scroll domains'} + + ); +} + +function StageContent({ + phase, + profile, + theme, + ascii, + progress, + cancelPrompt, + cancelQueued, + recovered, + runStatus, + register, +}) { + if (phase === 'plan') { + const selected = profiles.find((item) => item.id === profile) ?? profiles[1]; + return ( + + + 6 READY + 1 UNAVAILABLE + + Selected: {selected.label} · {selected.time} · {selected.impact} impact + {selected.note}. No score if coverage stays incomplete. + GPU unavailable: no supported compute driver detected. + Workload writes: none in this profile. + + Enter to begin {selected.label} plan + + + ); + } + + if (phase === 'run') { + return ( + + + RUNNING + CPU mixed load · trial 3 of 5 · ~6 min left + + {ascii + ? [########------------] {progress}% + : } + + {cancelQueued ? 'Cancel queued for the next safe trial boundary.' : 'Timed sample active · display updates pause during measurement.'} + + GPU: skipped · no supported compute driver + Worker isolated · UI input cannot change workload conditions. + {cancelPrompt ? ( + + Stop after this trial? [y] Confirm [n] Continue + + ) : ( + + c Request safe stop + u Simulate interruption recovery + + )} + + ); + } + + if (phase === 'results') { + return ( + + + HEADLINE WITHHELD + {runStatus === 'cancelled' || runStatus === 'recovered' ? 'Partial coverage · no median' : '6/7 valid · GPU unavailable'} + + {runStatus === 'cancelled' ? 'Cancelled safely · partial record preserved' : runStatus === 'recovered' ? 'Recovered after interruption · partial record preserved' : runStatus === 'complete' ? 'Run complete · capability coverage remains 6/7' : 'Sample run · partial record preserved'} + {runStatus === 'cancelled' || runStatus === 'recovered' + ? Partial evidence remains; unrun domains have no measurements. + : + {domains.filter((item) => item.score !== '—').map((item) => ( + {item.name} {item.score} + ))} + } + Prior complete run: median 93.2 · reference only + Warning · thermal throttling observed after trial 3 + Evidence: temperature rise + clock drop · corroborated + + ); + } + + if (phase === 'history') { + return ( + + LOCAL RUN HISTORY + OD-0928 · {profiles.find((item) => item.id === profile)?.label} · {runStatus === 'cancelled' ? 'Cancelled · partial' : runStatus === 'recovered' ? 'Recovered · partial' : 'Complete attempt'} · headline withheld + OD-0921 · Standard · Complete · 7/7 · median 93.2 + Partial run is not comparable; prior complete run remains available. + {recovered && Recovered after interruption · partial evidence retained · cleanup confirmed} + Notes stay separate from original measurements. + + ); + } + + return ( + + Finding · WARNING · corroborated + CPU clocks fell as temperature reached 91°C during sustained load. + Advice: inspect cooling before repeating extended profile. + Expected benefit: steadier sustained throughput. + Tradeoff: downtime for inspection. Odin changes no settings. + Evidence and uncertainty remain visible beside advice. + + ); +} + +function App() { + const { exit } = useApp(); + const { columns = 80, rows = 24 } = useWindowSize(); + const [variant, setVariant] = useState(0); + const [phase, setPhase] = useState('plan'); + const [profile, setProfile] = useState('standard'); + const [themeMode, setThemeMode] = useState('dark'); + const [ascii, setAscii] = useState(false); + const [cancelPrompt, setCancelPrompt] = useState(false); + const [cancelQueued, setCancelQueued] = useState(false); + const [progress, setProgress] = useState(64); + const [recovered, setRecovered] = useState(false); + const [runStatus, setRunStatus] = useState('sample'); + const [mouseLast, setMouseLast] = useState('No click yet'); + const [showHelp, setShowHelp] = useState(false); + const hitTargets = useRef(new Map()); + const handlers = useRef({}); + const themeBase = themeMode === 'dark' ? darkTheme : lightTheme; + const colors = ascii || process.env.NO_COLOR !== undefined + ? Object.fromEntries(Object.keys(themeBase.colors).map((key) => [key, undefined])) + : { + ...themeBase.colors, + primary: themeMode === 'dark' ? 'magenta' : 'blue', + secondary: themeMode === 'dark' ? 'cyan' : 'magenta', + }; + const theme = { ...themeBase, colors }; + const roomy = columns >= 80 && rows >= 19; + const register = (id) => (node) => { + if (node) hitTargets.current.set(id, node); + else hitTargets.current.delete(id); + }; + + const nextVariant = (delta) => setVariant((prev) => (prev + delta + variantNames.length) % variantNames.length); + const nextProfile = () => { + const index = profiles.findIndex((item) => item.id === profile); + setProfile(profiles[(index + 1) % profiles.length].id); + }; + const startRun = () => { + setPhase('run'); + setProgress(64); + setRunStatus('running'); + setCancelPrompt(false); + setCancelQueued(false); + }; + const cancelRun = () => { + setCancelPrompt(false); + setCancelQueued(false); + setRunStatus('cancelled'); + setPhase('results'); + }; + const advanceRun = () => { + if (progress >= 96) { + setRunStatus('complete'); + setPhase('results'); + } else setProgress((value) => Math.min(96, value + 16)); + }; + const recoverRun = () => { + setRecovered(true); + setRunStatus('recovered'); + setPhase('history'); + setCancelPrompt(false); + }; + + handlers.current = { + 'variant:prev': () => nextVariant(-1), + 'variant:next': () => nextVariant(1), + 'theme': () => setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'), + 'ascii': () => setAscii((value) => !value), + 'phase:plan': () => setPhase('plan'), + 'phase:run': () => setPhase('run'), + 'phase:results': () => setPhase('results'), + 'phase:history': () => setPhase('history'), + 'phase:advice': () => setPhase('advice'), + 'profile:quick': () => setProfile('quick'), + 'profile:standard': () => setProfile('standard'), + 'profile:extended': () => setProfile('extended'), + 'start': startRun, + 'cancel': () => { setCancelQueued(true); setCancelPrompt(true); }, + 'recover': recoverRun, + }; + + const clickAt = (column, row) => { + for (const [id, node] of hitTargets.current.entries()) { + const rect = measureElement(node); + if (column >= rect.x && column < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) { + handlers.current[id]?.(); + setMouseLast(id.replace(':', ' ')); + return; + } + } + }; + + useEffect(() => { + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + const enable = '\u001b[?1000h\u001b[?1006h'; + const disable = '\u001b[?1006l\u001b[?1000l'; + process.stdout.write(enable); + let carry = ''; + const onData = (chunk) => { + carry += chunk.toString('utf8'); + const pattern = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g; + let match; + while ((match = pattern.exec(carry)) !== null) { + const button = Number(match[1]) & 3; + if (button === 0 && match[4] === 'M') { + clickAt(Number(match[2]) - 1, Number(match[3]) - 1); + } + } + carry = carry.slice(-80); + }; + const onExit = () => process.stdout.write(disable); + process.stdin.on('data', onData); + process.once('exit', onExit); + return () => { + process.stdin.off('data', onData); + process.off('exit', onExit); + process.stdout.write(disable); + }; + }, []); + + useInput((input, key) => { + if (cancelPrompt) { + if (input.toLowerCase() === 'y' || key.return) cancelRun(); + else if (input.toLowerCase() === 'n' || key.escape) { setCancelPrompt(false); setCancelQueued(false); } + return; + } + if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; } + if (input === '[') { nextVariant(-1); return; } + if (input === ']') { nextVariant(1); return; } + if (input === 'p') { nextProfile(); return; } + if (input === 't') { setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'); return; } + if (input === 'a') { setAscii((value) => !value); return; } + if (input === '?') { setShowHelp((value) => !value); return; } + if (input === 'g' || key.tab && key.shift) { setPhase('plan'); return; } + if (input === 'l') { setPhase('run'); return; } + if (input === 's') { setPhase('results'); return; } + if (input === 'h') { setPhase('history'); return; } + if (input === 'o') { setPhase('advice'); return; } + if (key.leftArrow) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + phases.length - 1) % phases.length].id); return; } + if (key.rightArrow || key.tab) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + 1) % phases.length].id); return; } + if (phase === 'run' && input.toLowerCase() === 'c') { setCancelQueued(true); setCancelPrompt(true); return; } + if (phase === 'run' && input.toLowerCase() === 'u') { recoverRun(); return; } + if (phase === 'plan' && key.return) { startRun(); return; } + if (phase === 'run' && key.return) { advanceRun(); return; } + if (phase === 'results' && key.return) { setPhase('history'); return; } + if (phase === 'history' && key.return) { setPhase('advice'); return; } + if (phase === 'advice' && key.return) { setPhase('plan'); return; } + }); + + const renderHeader = () => ascii + ? ODINby Bongbetic · local benchmark + system health + :
; + + const renderProfiles = (compact = false) => ( + + ); + + const renderDomains = (height = 5) => ( + + COVERAGE · 6/7 + {ascii + ? + {domains.map((item) => {item.name}: {item.state}{item.reason ? ` — ${item.reason}` : ''})} + + : + {domains.map((item) => ( + + {item.state === 'Ready' ? '●' : '○'} + {item.name} + {item.state === 'Ready' ? 'ready' : 'unavailable'} + + ))} + } + Arrows scroll · status words work without color + + ); + + const renderContent = () => ( + + ); + + const renderVariantSwitch = () => ( + + [ + Layout {String.fromCharCode(65 + variant)} · {variantNames[variant]} + ] + [t] theme + [a] ASCII + + ); + + const renderFooter = () => ( + + {showHelp ? ( + [[]/]] layout · [g/l/s/h/o] views · [p] profile · [Enter] advance · [c] cancel · [u] recover · [t] theme · [a] ASCII · [q] quit + ) : ( + + )} + Mouse: {process.stdin.isTTY ? 'SGR click mode · ' : 'unavailable · '}{mouseLast} · {columns}×{rows} · {ascii ? 'ASCII + monochrome' : process.env.NO_COLOR !== undefined ? 'Unicode · NO_COLOR' : `${themeMode} color`} + + ); + + const renderA = () => ( + + + + + {renderProfiles(true)} + {renderDomains(roomy ? 5 : 3)} + + item.id === phase)?.title.toUpperCase()} ascii={ascii} theme={theme} flexGrow={1}> + {renderContent()} + + + + ); + + const renderB = () => ( + + {!ascii && } + {ascii && STEP 1 PLAN --> STEP 2 MEASURE --> STEP 3 REVIEW} + + + {phase === 'plan' && renderProfiles(false)} + {renderContent()} + {phase === 'plan' && renderDomains(4)} + + + ); + + const renderC = () => { + const wideConsole = columns >= 112 && rows >= 22; + if (!wideConsole) { + return ( + + + + LOCAL · NO UPLOAD + 6 ready / 7 + 1 finding + + + {renderContent()} + {phase === 'results' && + VALID DOMAIN MEDIANS + {ascii + ? CPU 96 · MEM 91 · GPU N/A · DISK 94 · WEB 90 · BASH 98 · LANG 93 + : } + } + + EVENT · GPU unavailable · thermal warning corroborated · worker separate from redraw + + ); + } + return ( + + + LOCAL · NO UPLOAD + + PROFILE · {profile.toUpperCase()} + 6 ready · 1 unavailable + 1 finding + [[] / ]] layouts + + + + + {renderContent()} + {phase === 'results' && + DOMAIN MEDIANS + {ascii + ? CPU 96 | MEM 91 | GPU N/A | DISK 94 | WEB 90 | BASH 98 | LANG 93 + : <>} + } + + + + 14:03 capability scan · GPU unavailable · reason recorded + 14:04 thermal finding · clocks down as temperature rose + Worker activity remains separate from terminal redraws. + + + + ); + }; + + return ( + + {renderHeader()} + + {renderVariantSwitch()} + {profile.toUpperCase()} · {profiles.find((item) => item.id === profile)?.time} + + {variant === 0 ? renderA() : variant === 1 ? renderB() : renderC()} + {renderFooter()} + + ); +} + +render(, { alternateScreen: true, kittyKeyboard: { mode: 'auto' } });