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