Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408611d59f |
@@ -0,0 +1,33 @@
|
||||
# Speedometer 3.1 offline pack at the pinned upstream commit
|
||||
|
||||
## Decision-ready finding
|
||||
|
||||
The pinned [WebKit/Speedometer commit `1386415be8fef2f6b6bbdbe1828872471c5d802a`](https://github.com/WebKit/Speedometer/commit/1386415be8fef2f6b6bbdbe1828872471c5d802a) is a plausible source for an Odin **versioned browser workload** served from localhost. It contains built static applications and the benchmark runner. Its page identifies itself as Speedometer **3.1**, although `package.json` still says `3.0.0-alpha`; identify the pack by the full commit and a content digest, not that package version. The [about page](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/about.html) says workloads are built as static files and cannot depend on server infrastructure. The [runner](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/benchmark-runner.mjs) loads each suite in an iframe under `resources/`.
|
||||
|
||||
This is a **conditional yes** for an offline pack. Source inspection and static link checks support completeness, but they do not prove that a browser makes no external requests or that every workload succeeds without internet. Require a blocked-network browser smoke test before calling the pack offline-ready. This research did not execute a benchmark or install a browser.
|
||||
|
||||
## Pack contents and static checks
|
||||
|
||||
The [suite list](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/tests.mjs) declares 32 suites, 20 enabled by default. Local inspection of the pinned source archive found every declared suite entry path. The archive contained 1,118 files totaling 61,022,359 uncompressed bytes. For the root page plus the 20 enabled suite entry pages, a static HTML parser checked 194 `script`, asset `link`, and `img` references: none was external or missing. These numbers describe the checked archive, not a run result. The parser did not resolve dynamic JavaScript imports, CSS URLs, route requests, or user navigation.
|
||||
|
||||
The [main page](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/index.html) loads local CSS and `resources/main.mjs`; the [runner](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/benchmark-runner.mjs) constructs `resources/${suite.url}`. The Perf Dashboard workload deserves special attention: its [static page](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/perf.webkit.org/public/v3/index.html) replaces its API method with `mockAPIs()` and fetches 13 specified local JSON paths. All 13 files exist in the pinned archive. The ordinary [dashboard remote API](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/perf.webkit.org/public/v3/remote.js) supports XHR, so verify the mock remains active in the actual packaged page.
|
||||
|
||||
No `npm install` is needed to serve the already built workload assets. Upstream [development instructions](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/Development.md) use `http-server` for local development. Odin can serve the frozen file tree with its own loopback-only static server; do not rebuild application assets as part of a benchmark run. Use an HTTP origin rather than `file://`, since the suite uses modules, iframe paths, and fetches. The [upstream test harness](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/tests/run.mjs) is Selenium based and requires an installed browser and matching driver, per [Testing.md](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/Testing.md); it is not a prerequisite for serving the built pack.
|
||||
|
||||
## Redistribution boundary
|
||||
|
||||
The root [LICENSE](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/LICENSE) permits source and binary redistribution with or without changes if its copyright notice, conditions, and disclaimer are retained or reproduced as specified. This is not a blanket license for all included third-party work. The [TodoMVC subtree license](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/todomvc/license.md) states MIT unless otherwise specified and requires inclusion of its notice in copies or substantial portions. Built bundles also carry license files, including [React bundle notices](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/todomvc/architecture-examples/react/dist/app.bundle.js.LICENSE.txt), [Angular third-party notices](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/todomvc/architecture-examples/angular/dist/3rdpartylicenses.txt), and [React Stockcharts notices](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/react-stockcharts/build/static/js/2.8e539c84.chunk.js.LICENSE.txt).
|
||||
|
||||
Pack the complete upstream notice files alongside their assets, preserve inline notices, and record a notice inventory with the pack manifest. A complete third-party license audit remains unresolved: the archive includes many generated bundles and assets, and finding a root license plus named notice files does not establish licensing for every individual asset. Review the final redistributed file set and notices before shipping. This is a licensing assessment from primary source text, not legal advice.
|
||||
|
||||
## Browser mode and result identity
|
||||
|
||||
Upstream [test instructions](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/instructions.html) call for a latest stable browser, clean profile, focused page, closed competing tabs, and no interaction during the run. The [page](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/index.html) warns when its visible viewport is below 850 × 650. The [parameters](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/params.mjs) separately default the suite iframe to 800 × 600 and expose iteration count, suite selection, and timing method. Record these exact conditions in Odin's run record.
|
||||
|
||||
Use a headed, focused browser session for the comparable browser workload. Upstream provides no headless equivalence claim in the cited instructions or [Selenium runner](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/tests/run.mjs). Headless may be useful for a smoke test, but treat any headless measurement as a distinct software mode until equivalence is demonstrated. Do not silently mix browser versions, profiles, window/iframe sizes, suite selections, or headed/headless results in one calibrated measurement.
|
||||
|
||||
## Manifest and offline acceptance proposal
|
||||
|
||||
Create a deterministic, content-addressed pack manifest. Record: upstream repository URL and full commit; Speedometer 3.1 display version; every shipped relative path with byte length and SHA-256; a sorted inventory of license/notice paths; default suite names; and packaging schema version. Hash canonical serialized manifest bytes for the pack identifier. Verify each file hash before serving; reject missing, extra, or changed files. Keep the complete source snapshot or an auditable mapping from snapshot to shipped subset. This is an Odin design recommendation based on the pinned [runner paths](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/benchmark-runner.mjs), [suite list](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/tests.mjs), and [license](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/LICENSE); upstream does not prescribe this manifest.
|
||||
|
||||
Before a benchmark run, validate offline behavior without collecting a score: serve the frozen tree on loopback, open the landing page and each default suite page in a disposable browser profile, disable outside network at the browser or sandbox boundary, log attempted requests, and check that all required assets load with no external request or console error. Include dynamic imports, CSS fonts/images, redirects, worker requests, and the Perf Dashboard JSON paths. Do not click Start Test during this gate. If the gate fails, report the missing/remote URL and mark the browser workload unavailable; do not substitute an online fetch. This acceptance procedure is proposed, not claimed as completed.
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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',
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { BadgeProps, BadgeVariant } from './Badge.js';
|
||||
export { Badge } from './Badge.js';
|
||||
@@ -1,92 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Gauge } from './Gauge.js';
|
||||
export type { GaugeProps } from './Gauge.js';
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { HeaderProps, HeaderStyle } from './Header.js';
|
||||
export { Header } from './Header.js';
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { KeyHintProps, KeyHintItem } from './KeyHint.js';
|
||||
export { KeyHint } from './KeyHint.js';
|
||||
@@ -1,147 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Panel, SplitPane } from './Panel.js';
|
||||
export type { PanelProps, SplitPaneProps } from './Panel.js';
|
||||
@@ -1,58 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ProgressBarProps } from './ProgressBar.js';
|
||||
export { ProgressBar } from './ProgressBar.js';
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ScrollArea } from './ScrollArea.js';
|
||||
export type { ScrollAreaProps } from './ScrollArea.js';
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Sparkline } from './Sparkline.js';
|
||||
export type { SparklineProps } from './Sparkline.js';
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { Stepper } from './Stepper.js';
|
||||
export type { StepperProps, Step } from './Stepper.js';
|
||||
Generated
-1054
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,547 +0,0 @@
|
||||
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