prototype: explore Odin InkUI TUI workflow
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const color = variantColor(variant, theme);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={color} bold inverse>{` ${children} `}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BadgeProps, BadgeVariant } from './Badge.js';
|
||||
export { Badge } from './Badge.js';
|
||||
@@ -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<GaugeProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={color}>{bar}</Text>
|
||||
{showValue && <Text color={color}>{displayValue}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={color}>{'◜'}{topBar}{'◝'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'} <Text color={color}>{displayValue.padStart(ringWidth - 1)}</Text>{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={color}>{'◟'}{botBar}{'◞'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// arc variant
|
||||
const arcWidth = Math.max(10, width);
|
||||
const filled = Math.round((pct / 100) * arcWidth);
|
||||
const arcBar = '█'.repeat(filled) + '░'.repeat(arcWidth - filled);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={theme.colors.border}>{' ╭' + '─'.repeat(arcWidth) + '╮'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╱'}{' '.repeat(arcWidth + 2)}{'╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'╱ '}<Text color={color}>{arcBar}</Text>{' ╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'│'}{' '.repeat(Math.floor(arcWidth / 2))}<Text color={color}>{displayValue}</Text>{' '.repeat(Math.ceil(arcWidth / 2))}{'│'}</Text>
|
||||
<Text color={theme.colors.border}>{'╲'}{' '.repeat(arcWidth + 2)}{'╱'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╰' + '─'.repeat(arcWidth) + '╯'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{' '}{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Gauge } from './Gauge.js';
|
||||
export type { GaugeProps } from './Gauge.js';
|
||||
@@ -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<HeaderProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{top}</Text>
|
||||
{subtitle && (
|
||||
<Text color={theme.colors.primary}>
|
||||
{'│'} <Text color={theme.colors.muted}>{subtitle.padEnd(inner - 2)}</Text> {'│'}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.colors.primary}>{bot}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HeaderProps, HeaderStyle } from './Header.js';
|
||||
export { Header } from './Header.js';
|
||||
@@ -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<KeyHintProps> = ({
|
||||
keys,
|
||||
theme = darkTheme,
|
||||
}) => (
|
||||
<Box gap={2}>
|
||||
{keys.map(({ key, label }) => (
|
||||
<Box key={key} gap={1}>
|
||||
<Text bold dimColor>[{key}]</Text>
|
||||
<Text color={theme.colors.muted}>{label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { KeyHintProps, KeyHintItem } from './KeyHint.js';
|
||||
export { KeyHint } from './KeyHint.js';
|
||||
@@ -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 (
|
||||
<Box
|
||||
flexDirection={direction === 'horizontal' ? 'row' : 'column'}
|
||||
gap={gap}
|
||||
>
|
||||
{childArray.map((child, i) => {
|
||||
const flex = sizes ? sizes[i] ?? 1 : 1;
|
||||
return (
|
||||
<Box key={i} flexGrow={flex} flexShrink={1} flexBasis={0}>
|
||||
{child}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Map our BorderStyle names → Ink/cli-boxes names
|
||||
// ('rounded' → 'round', 'ascii' → 'classic', others match directly)
|
||||
const INK_BORDER_MAP: Record<BorderStyle, string> = {
|
||||
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 (
|
||||
<Box flexDirection="column" width={totalWidth}>
|
||||
{/* Custom top border with optional title */}
|
||||
<Text color={bc}>{topLine}</Text>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Box
|
||||
borderStyle={INK_BORDER_MAP[borderStyle] as 'single'}
|
||||
borderTop={false}
|
||||
borderColor={bc}
|
||||
paddingX={padding}
|
||||
flexDirection="column"
|
||||
width={totalWidth}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Panel, SplitPane } from './Panel.js';
|
||||
export type { PanelProps, SplitPaneProps } from './Panel.js';
|
||||
@@ -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<ProgressBarProps> = ({
|
||||
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 (
|
||||
<Box>
|
||||
{label ? <Text>{label} </Text> : null}
|
||||
<Text color={theme.colors.primary}>{filledStr}</Text>
|
||||
<Text color={theme.colors.muted}>{emptyStr}</Text>
|
||||
{showPercent ? (
|
||||
<Text color={theme.colors.muted}>{percentSuffix}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ProgressBarProps } from './ProgressBar.js';
|
||||
export { ProgressBar } from './ProgressBar.js';
|
||||
@@ -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<ScrollAreaProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{visibleItems}
|
||||
</Box>
|
||||
{scrollbar && totalItems > height && (
|
||||
<Box flexDirection="column" width={1}>
|
||||
{Array.from({ length: height }, (_, i) => {
|
||||
const isThumb = i >= thumbPos && i < thumbPos + thumbSize;
|
||||
return (
|
||||
<Text key={i} color={isThumb ? theme.colors.primary : theme.colors.muted}>
|
||||
{isThumb ? scrollbarChar : trackChar}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ScrollArea } from './ScrollArea.js';
|
||||
export type { ScrollAreaProps } from './ScrollArea.js';
|
||||
@@ -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<SparklineProps> = ({
|
||||
data,
|
||||
width = 20,
|
||||
height = 1,
|
||||
label,
|
||||
showRange = false,
|
||||
showLatest = true,
|
||||
color,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
if (!data || data.length === 0) {
|
||||
return <Text color={theme.colors.muted}>{label ?? ''} (no data)</Text>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={barColor}>{sparkStr}</Text>
|
||||
{showLatest && <Text color={barColor}>{Math.round(data[data.length - 1])}</Text>}
|
||||
{showRange && (
|
||||
<Text color={theme.colors.muted} dimColor>
|
||||
(min: {Math.round(minVal)} max: {Math.round(maxVal)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sparkline } from './Sparkline.js';
|
||||
export type { SparklineProps } from './Sparkline.js';
|
||||
@@ -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<StepperProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box key={step.key} flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text bold={isCurrent} color={isCurrent ? theme.colors.text : theme.colors.muted}>
|
||||
{step.title}
|
||||
{step.optional && <Text color={theme.colors.muted}> (optional)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
{step.description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.colors.muted}>│ {step.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{i < steps.length - 1 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={connector}>│</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{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 (
|
||||
<React.Fragment key={step.key}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text color={isCurrent ? theme.colors.text : theme.colors.muted} bold={isCurrent}>
|
||||
{' '}{step.title}{step.optional ? ' (opt)' : ''}
|
||||
</Text>
|
||||
{i < steps.length - 1 && (
|
||||
<Text color={connectorColor}>{' ── '}</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
{steps.map((step, i) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{step.description && (
|
||||
<Text color={theme.colors.muted}>
|
||||
{' '}{step.description}
|
||||
{i < steps.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Stepper } from './Stepper.js';
|
||||
export type { StepperProps, Step } from './Stepper.js';
|
||||
Generated
+1054
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -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
|
||||
@@ -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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle={ascii ? 'classic' : 'round'}
|
||||
borderColor={theme.colors.border}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
flexGrow={flexGrow}
|
||||
flexShrink={width ? 0 : 1}
|
||||
>
|
||||
<Text bold color={theme.colors.secondary}>{title}</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClickRow({ id, register, children, selected = false, theme, ascii }) {
|
||||
return (
|
||||
<Box ref={register(id)}>
|
||||
<Text
|
||||
inverse={selected}
|
||||
color={selected ? theme.colors.textInverse : theme.colors.text}
|
||||
bold={selected}
|
||||
>
|
||||
{selected ? (ascii ? '> ' : '› ') : ' '}{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StageNav({ phase, register, theme, ascii, vertical = false }) {
|
||||
return (
|
||||
<Box flexDirection={vertical ? 'column' : 'row'} gap={vertical ? 0 : 1}>
|
||||
{phases.map((item) => (
|
||||
<Box key={item.id} ref={register(`phase:${item.id}`)}>
|
||||
<Text
|
||||
bold={phase === item.id}
|
||||
color={phase === item.id ? theme.colors.primary : theme.colors.muted}
|
||||
inverse={phase === item.id}
|
||||
>
|
||||
{ascii ? `[${item.key}] ${item.title}` : ` ${item.title} `}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRows({ profile, setProfile, register, theme, ascii, compact = false }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={compact ? 0 : 1}>
|
||||
{profiles.map((item) => (
|
||||
<Box key={item.id} ref={register(`profile:${item.id}`)}>
|
||||
<Text
|
||||
bold={profile === item.id}
|
||||
color={profile === item.id ? theme.colors.primary : theme.colors.text}
|
||||
inverse={profile === item.id}
|
||||
>
|
||||
{profile === item.id ? (ascii ? '> ' : '◆ ') : ' '}
|
||||
{item.label} {item.time}{compact ? '' : ` ${item.impact}`}
|
||||
</Text>
|
||||
{!compact && <Text color={theme.colors.muted}> {item.note}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={theme.colors.muted}>{ascii ? 'p changes profile' : 'p changes profile; arrows scroll domains'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="success" theme={theme}>6 READY</Badge>
|
||||
<Badge variant="warning" theme={theme}>1 UNAVAILABLE</Badge>
|
||||
</Box>
|
||||
<Text>Selected: {selected.label} · {selected.time} · {selected.impact} impact</Text>
|
||||
<Text color={theme.colors.muted}>{selected.note}. No score if coverage stays incomplete.</Text>
|
||||
<Text>GPU unavailable: no supported compute driver detected.</Text>
|
||||
<Text>Workload writes: none in this profile.</Text>
|
||||
<Box ref={register('start')} marginTop={1}>
|
||||
<Text bold color={theme.colors.success} inverse> Enter to begin {selected.label} plan </Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'run') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="info" theme={theme}>RUNNING</Badge>
|
||||
<Text>CPU mixed load · trial 3 of 5 · ~6 min left</Text>
|
||||
</Box>
|
||||
{ascii
|
||||
? <Text>[########------------] {progress}%</Text>
|
||||
: <ProgressBar value={progress} width={28} theme={theme} />}
|
||||
<Text color={theme.colors.warning} bold>
|
||||
{cancelQueued ? 'Cancel queued for the next safe trial boundary.' : 'Timed sample active · display updates pause during measurement.'}
|
||||
</Text>
|
||||
<Text>GPU: skipped · no supported compute driver</Text>
|
||||
<Text>Worker isolated · UI input cannot change workload conditions.</Text>
|
||||
{cancelPrompt ? (
|
||||
<Box borderStyle={ascii ? 'classic' : 'round'} borderColor={theme.colors.warning} paddingX={1} marginTop={1}>
|
||||
<Text bold>Stop after this trial? [y] Confirm [n] Continue</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box ref={register('cancel')}><Text bold color={theme.colors.error} inverse> c Request safe stop </Text></Box>
|
||||
<Box ref={register('recover')}><Text color={theme.colors.secondary}> u Simulate interruption recovery </Text></Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'results') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="warning" theme={theme}>HEADLINE WITHHELD</Badge>
|
||||
<Text>{runStatus === 'cancelled' || runStatus === 'recovered' ? 'Partial coverage · no median' : '6/7 valid · GPU unavailable'}</Text>
|
||||
</Box>
|
||||
<Text bold>{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'}</Text>
|
||||
{runStatus === 'cancelled' || runStatus === 'recovered'
|
||||
? <Text>Partial evidence remains; unrun domains have no measurements.</Text>
|
||||
: <Box flexDirection="row" flexWrap="wrap" gap={1}>
|
||||
{domains.filter((item) => item.score !== '—').map((item) => (
|
||||
<Text key={item.name} color={theme.colors.primary}>{item.name} {item.score}</Text>
|
||||
))}
|
||||
</Box>}
|
||||
<Text>Prior complete run: median 93.2 · reference only</Text>
|
||||
<Text color={theme.colors.warning}>Warning · thermal throttling observed after trial 3</Text>
|
||||
<Text color={theme.colors.muted}>Evidence: temperature rise + clock drop · corroborated</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'history') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>LOCAL RUN HISTORY</Text>
|
||||
<Text>OD-0928 · {profiles.find((item) => item.id === profile)?.label} · {runStatus === 'cancelled' ? 'Cancelled · partial' : runStatus === 'recovered' ? 'Recovered · partial' : 'Complete attempt'} · headline withheld</Text>
|
||||
<Text>OD-0921 · Standard · Complete · 7/7 · median 93.2</Text>
|
||||
<Text color={theme.colors.warning}>Partial run is not comparable; prior complete run remains available.</Text>
|
||||
{recovered && <Text color={theme.colors.warning}>Recovered after interruption · partial evidence retained · cleanup confirmed</Text>}
|
||||
<Text color={theme.colors.muted}>Notes stay separate from original measurements.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.colors.warning}>Finding · WARNING · corroborated</Text>
|
||||
<Text>CPU clocks fell as temperature reached 91°C during sustained load.</Text>
|
||||
<Text>Advice: inspect cooling before repeating extended profile.</Text>
|
||||
<Text color={theme.colors.success}>Expected benefit: steadier sustained throughput.</Text>
|
||||
<Text>Tradeoff: downtime for inspection. Odin changes no settings.</Text>
|
||||
<Text color={theme.colors.muted}>Evidence and uncertainty remain visible beside advice.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
? <Box flexDirection="row" gap={1}><Text bold color={theme.colors.primary}>ODIN</Text><Text>by Bongbetic · local benchmark + system health</Text></Box>
|
||||
: <Header title="ODIN · Bongbetic" version="prototype" subtitle="LOCAL BENCHMARK + SYSTEM HEALTH" style="line" theme={theme} />;
|
||||
|
||||
const renderProfiles = (compact = false) => (
|
||||
<ProfileRows profile={profile} register={register} theme={theme} ascii={ascii} compact={compact} />
|
||||
);
|
||||
|
||||
const renderDomains = (height = 5) => (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>COVERAGE · 6/7</Text>
|
||||
{ascii
|
||||
? <ScrollArea height={height} theme={theme} scrollbarChar="#" trackChar="." focus>
|
||||
{domains.map((item) => <Text key={item.name}>{item.name}: {item.state}{item.reason ? ` — ${item.reason}` : ''}</Text>)}
|
||||
</ScrollArea>
|
||||
: <ScrollArea height={height} theme={theme} focus>
|
||||
{domains.map((item) => (
|
||||
<Box key={item.name} flexDirection="row" gap={1}>
|
||||
<Text color={item.state === 'Ready' ? theme.colors.success : theme.colors.warning}>{item.state === 'Ready' ? '●' : '○'}</Text>
|
||||
<Text>{item.name}</Text>
|
||||
<Text color={theme.colors.muted}>{item.state === 'Ready' ? 'ready' : 'unavailable'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>}
|
||||
<Text color={theme.colors.muted}>Arrows scroll · status words work without color</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<StageContent
|
||||
phase={phase}
|
||||
profile={profile}
|
||||
theme={theme}
|
||||
ascii={ascii}
|
||||
progress={progress}
|
||||
cancelPrompt={cancelPrompt}
|
||||
cancelQueued={cancelQueued}
|
||||
recovered={recovered}
|
||||
runStatus={runStatus}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderVariantSwitch = () => (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box ref={register('variant:prev')}><Text color={theme.colors.muted}>[</Text></Box>
|
||||
<Text color={theme.colors.secondary} bold>Layout {String.fromCharCode(65 + variant)} · {variantNames[variant]}</Text>
|
||||
<Box ref={register('variant:next')}><Text color={theme.colors.muted}>]</Text></Box>
|
||||
<Box ref={register('theme')}><Text color={theme.colors.muted}>[t] theme</Text></Box>
|
||||
<Box ref={register('ascii')}><Text color={theme.colors.muted}>[a] ASCII</Text></Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderFooter = () => (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{showHelp ? (
|
||||
<Text color={theme.colors.muted}>[[]/]] layout · [g/l/s/h/o] views · [p] profile · [Enter] advance · [c] cancel · [u] recover · [t] theme · [a] ASCII · [q] quit</Text>
|
||||
) : (
|
||||
<KeyHint theme={theme} keys={[
|
||||
{ key: 'Tab/←→', label: 'View' },
|
||||
{ key: 'p', label: 'Profile' },
|
||||
{ key: 'Enter', label: phase === 'run' ? 'Next trial' : 'Open' },
|
||||
{ key: 'c', label: 'Cancel run' },
|
||||
{ key: '?', label: 'Help' },
|
||||
{ key: 'q', label: 'Quit' },
|
||||
]} />
|
||||
)}
|
||||
<Text color={theme.colors.muted}>Mouse: {process.stdin.isTTY ? 'SGR click mode · ' : 'unavailable · '}{mouseLast} · {columns}×{rows} · {ascii ? 'ASCII + monochrome' : process.env.NO_COLOR !== undefined ? 'Unicode · NO_COLOR' : `${themeMode} color`}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderA = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box marginBottom={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Box flexDirection={roomy ? 'row' : 'column'} gap={1} flexGrow={1}>
|
||||
<Card title="RUN SETUP" ascii={ascii} theme={theme} width={roomy ? Math.min(31, Math.floor(columns * 0.38)) : columns}>
|
||||
{renderProfiles(true)}
|
||||
{renderDomains(roomy ? 5 : 3)}
|
||||
</Card>
|
||||
<Card title={phases.find((item) => item.id === phase)?.title.toUpperCase()} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderB = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{!ascii && <Stepper
|
||||
steps={[{ key: 'plan', title: 'Plan' }, { key: 'run', title: 'Measure' }, { key: 'review', title: 'Review' }]}
|
||||
currentStep={phase === 'plan' ? 'plan' : phase === 'run' ? 'run' : 'review'}
|
||||
completedSteps={phase === 'run' ? ['plan'] : phase === 'results' || phase === 'history' || phase === 'advice' ? ['plan', 'run'] : []}
|
||||
theme={theme}
|
||||
/>}
|
||||
{ascii && <Text>STEP 1 PLAN --> STEP 2 MEASURE --> STEP 3 REVIEW</Text>}
|
||||
<Box marginTop={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Card title={phase === 'plan' ? 'CHOOSE A PROFILE' : phase === 'run' ? 'MEASUREMENT IN PROGRESS' : phase === 'results' ? 'REVIEW RESULT' : phase === 'history' ? 'SAVED RUNS' : 'NEXT ACTION'} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{phase === 'plan' && renderProfiles(false)}
|
||||
{renderContent()}
|
||||
{phase === 'plan' && renderDomains(4)}
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderC = () => {
|
||||
const wideConsole = columns >= 112 && rows >= 22;
|
||||
if (!wideConsole) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} />
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text>LOCAL · NO UPLOAD</Text>
|
||||
<Text color={theme.colors.success}>6 ready / 7</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
</Box>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
{phase === 'results' && <Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>VALID DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 · MEM 91 · GPU N/A · DISK 94 · WEB 90 · BASH 98 · LANG 93</Text>
|
||||
: <Sparkline data={scoreValues} width={Math.min(30, columns - 8)} label="Scores" theme={theme} />}
|
||||
</Box>}
|
||||
</Card>
|
||||
<Text color={theme.colors.muted}>EVENT · GPU unavailable · thermal warning corroborated · worker separate from redraw</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} flexGrow={1}>
|
||||
<Card title="ODIN" ascii={ascii} theme={theme} width={27}>
|
||||
<Text color={theme.colors.muted}>LOCAL · NO UPLOAD</Text>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} vertical />
|
||||
<Text color={theme.colors.secondary}>PROFILE · {profile.toUpperCase()}</Text>
|
||||
<Text>6 ready · 1 unavailable</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
<Text color={theme.colors.muted}>[[] / ]] layouts</Text>
|
||||
</Card>
|
||||
<Box flexDirection="column" flexGrow={1} gap={1}>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>{renderContent()}</Box>
|
||||
{phase === 'results' && <Box flexDirection="column" width={30}>
|
||||
<Text bold>DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 | MEM 91 | GPU N/A | DISK 94 | WEB 90 | BASH 98 | LANG 93</Text>
|
||||
: <><Gauge value={93} width={16} label="Valid domains" unit="%" theme={theme} /><Sparkline data={scoreValues} width={18} label="Scores" theme={theme} /></>}
|
||||
</Box>}
|
||||
</Box>
|
||||
</Card>
|
||||
<Card title="EVENT / EVIDENCE LOG" ascii={ascii} theme={theme}>
|
||||
<Text>14:03 capability scan · GPU unavailable · reason recorded</Text>
|
||||
<Text>14:04 thermal finding · clocks down as temperature rose</Text>
|
||||
<Text color={theme.colors.muted}>Worker activity remains separate from terminal redraws.</Text>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={columns} paddingX={1}>
|
||||
{renderHeader()}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginY={1}>
|
||||
{renderVariantSwitch()}
|
||||
<Text color={theme.colors.muted}>{profile.toUpperCase()} · {profiles.find((item) => item.id === profile)?.time}</Text>
|
||||
</Box>
|
||||
{variant === 0 ? renderA() : variant === 1 ? renderB() : renderC()}
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, { alternateScreen: true, kittyKeyboard: { mode: 'auto' } });
|
||||
Reference in New Issue
Block a user