Author SHA1 Message Date
Codex c3185688f9 prototype: explore Odin InkUI TUI workflow 2026-09-28 02:35:01 +05:30
26 changed files with 2481 additions and 139 deletions
-139
View File
@@ -1,139 +0,0 @@
# Storage measurements and trustworthy health advice
Research for [Establish storage measurements and trustworthy health advice](https://git.bongbetic.com/xavierk/odin/issues/4), part of Odin's Wayfinder map. Access date for every source: **2026-09-25**.
This report establishes evidence and candidate policies. It does not select Odin's final workloads, thresholds, privileged execution design, or score. No benchmark, device query, self-test, installation, or hardware change was performed during this investigation.
## Findings that shape the decision
The strongest candidate is **fio for file-based performance measurements, smartmontools for cross-protocol health findings, and an optional nvme-cli adapter for additional NVMe evidence**. These tools cover different responsibilities. A fast benchmark cannot establish drive health; a passing SMART status cannot establish future reliability. Health findings should therefore remain visible independently of the performance score and capability coverage.
There is a material compatibility change already: nvme-cli **v3.1**, released September 18, 2026, documents `nvme log smart`; `nvme smart-log` is a deprecated compatibility alias. Its default output format version is now 2, with version 1 available. fio **3.43** was released September 23. A bleeding-edge development environment is compatible with reproducible measurements only if Odin records tool versions and keeps workload and parser versions explicit. Package-manager availability alone does not establish supported commands or JSON schemas. [S1][S3]
## Candidate tools and measurement scope
| Candidate | Useful responsibility | Limits and recommendation to consider |
| --- | --- | --- |
| fio | Sequential/random reads and writes, block sizes, queue depths, latency distributions, bounded I/O, optional verification | Best primary workload candidate. Select a small fixed workload vocabulary; do not accept arbitrary user-supplied job files into privileged execution. |
| smartctl | ATA, SCSI and NVMe identity, health, existing error/self-test logs; JSON; many bridge/controller adapters | Best baseline health reader. Decode protocol-specific semantics and command status separately. Some transports are unsafe for automatic probing. |
| nvme-cli | NVMe-specific identity, SMART and detailed logs | Useful optional supplement. Version 2/3 command and JSON differences need explicit compatibility handling. Avoid duplicating the same controller's health as several independent findings. |
| Native `/proc` and `/sys` | I/O pressure, completed I/O, queue activity and available sensors | Low-dependency contextual evidence, not a workload or a substitute for SMART. |
| GNU `dd` | Bounded sequential copying, optionally direct I/O and final synchronization | Possible explicitly labelled basic fallback. Its copy-oriented output does not supply fio's workload control or latency distributions; its result must not silently substitute into the same scored workload. |
Sources: fio HOWTO, smartctl manual, nvme-cli released documentation, kernel PSI/I/O documentation and GNU manual. [S1–S4][S8][S9][S14]
A compact candidate performance set is:
| Measurement | Candidate workload, still to be selected | What its result means |
| --- | --- | --- |
| Sequential read/write | Large blocks, for example 1 MiB, one job, depth 1 | Large-file throughput through the selected filesystem and storage path |
| Random read/write | 4 KiB, one job, depth 1 | Small-request responsiveness; report latency and IOPS |
| Queued random read | Same block size at a documented higher depth, such as 16 or 32 | Concurrency capability; a different workload from depth 1 |
| Durable small writes | A separate small, bounded workload with defined sync frequency | Application-visible cost of requesting persistence |
| Optional integrity check | Write and verify Odin-owned file blocks with fio checksums | Whether the tested data path returned those bytes correctly; not a full-surface drive or whole-RAM certification |
Record read and write throughput in explicit units, IOPS, completed bytes, operation count, errors, elapsed time, and p50/p95/p99 latency where sample counts support them. Distinguish fio completion latency from total latency: total includes submission latency. Record achieved queue-depth distribution; requesting depth greater than one does not make a synchronous engine asynchronous. fio `psync` is a useful depth-1 compatibility candidate; `io_uring` and `libaio` are queued-engine candidates where supported. Engine changes must be visible in results and comparability rules. [S1]
Measure one storage workload at a time during reference runs. Concurrent CPU or memory stress can instead be an explicitly identified contention experiment. Record filesystem, mount options, device topology, encryption/RAID/virtualization, kernel, selected engine, power/thermal state, background I/O, and pre/post free space. The measurement describes this path under these conditions, not the NVMe/HDD in isolation.
## Safe operation and comparability constraints
The following are proposed invariants, rather than finalized profile numbers:
1. **Own every writable byte.** Create a private run directory on a deliberately selected filesystem and exclusively create its regular files. Validate ownership, type and target identity; reject symlink redirection and raw block/character devices. `O_CREAT|O_EXCL` supplies exclusive creation semantics. Do not use arbitrary existing user files as write targets. Avoid selecting `/tmp` automatically: tmpfs stores files in virtual memory and may use swap. [S7][S11]
2. **Budget storage space and cumulative writes separately.** fio `size` defines the working region, while `io_size` can independently bound I/O. `runtime` stops at the earlier of completion or time limit; `time_based` loops the workload. Thus a small file plus a timed loop can write many times its size. Prefer explicit byte and time bounds without `time_based` for ordinary write profiles. Count fixture preparation, repetitions and verification-related writes in a per-run host-write budget. Reserve free space, account for quotas and metadata, recheck during execution, and stop on ENOSPC or I/O errors. Space and byte thresholds remain product decisions. [S1]
3. **Do not promise a physical NAND-write limit.** A workload's host bytes are measurable; filesystem/controller write amplification and unrelated host activity are additional. NVMe Data Units Written measures host data in units of 1,000 × 512 bytes, rounded up, excluding metadata; it is not a universal NAND-wear counter. Background activity also prevents attributing its entire delta to Odin. [S5]
4. **Make cache and durability modes explicit.** fio `direct=1` normally requests `O_DIRECT`; support and alignment vary by filesystem and kernel, and misaligned requests can fail or fall back to buffered I/O. Direct I/O does not by itself provide `O_SYNC` persistence guarantees, bypass every device cache, or prove sustained media speed. `invalidate` is conditional on platform/file support. Avoid global `drop_caches`: kernel documentation warns of additional I/O and CPU costs. A buffered fallback must be labelled and excluded from direct-I/O comparisons. [S1][S7][S12]
5. **Include preparation and flush costs honestly.** Read tests over newly created fixtures still require writes. A user choosing no writes can reuse an identified valid fixture or skip that workload; Odin should not create one silently. Do not measure unwritten sparse-file holes as disk reads. For writes, document `end_fsync` or other synchronization and report end-to-end time including the final flush separately from unsynchronized throughput. Control data compressibility/deduplication using a declared fio buffer policy; generating fresh data adds CPU cost. Preserve normal filesystem settings rather than silently disabling compression or copy-on-write. [S1][S7]
6. **Treat cancellation and cleanup as part of the run.** Bound the entire job group; stop launching work on cancellation, retain partial status, reap workers, and remove only proven Odin-owned artifacts. A worker stuck in kernel I/O may not stop immediately. Crash recovery needs a manifest and ownership checks before deletion. Cleanup failure is a reported outcome, never a reason to recursively delete a user-selected directory.
7. **Collect health before load and reduce work when evidence is serious.** A candidate policy is to skip storage stress when critical media/reliability findings or current unreadable data are already present. Pause on documented thermal alarms or loss of safety headroom. Display estimated host writes before a write run. Avoid automatic discard/TRIM, formatting, SMART feature changes, firmware updates, cache-policy changes or repair operations as benchmark preparation.
Short bounded tests cannot establish steady-state SSD performance after exhaustion of a large write cache, or scan every HDD sector. Making test data larger than all caches can conflict with a quick run and a conservative write budget. Report the actual duration and working set; do not extrapolate a short burst into an endurance or sustained-performance guarantee. The tradeoff between low impact and sustained measurements needs an explicit run-profile decision.
## Health evidence and field interpretation
Prefer structured output with the original tool version, schema identifier, command outcome, timestamp, device identity and transport. A missing field is unknown, not zero. Preserve large counters losslessly: smartctl JSON can emit string/byte-array companions for integers exceeding JavaScript's safe integer range; `--json=v` requests them consistently. This matters for an Ink/JavaScript consumer. [S2]
For smartctl NVMe output, the primary object is `nvme_smart_health_information_log`. The inspected source confirms the following keys and conversions. Raw NVMe temperature is Kelvin; smartctl's `temperature` here is already Celsius. Do not convert it twice. [S6]
| Evidence | Meaning | Candidate interpretation |
| --- | --- | --- |
| `critical_warning` bit 0; `available_spare` vs `available_spare_threshold` | Spare capacity below the controller's threshold | Urgent preservation/service finding; display the device-provided threshold |
| Bit 1; `temperature`, warning/critical temperature time | Above an over-temperature or below an under-temperature threshold | Stop heat-producing tests; investigate cooling/environment. This alone is not proof that replacement is needed |
| Bit 2 | NVM subsystem reliability degraded | Urgent backup and replacement/service assessment |
| Bit 3 | Media placed read-only for a device reliability condition | Urgent preservation and replacement/service assessment; distinct from user namespace write protection |
| Bits 4/5 | Volatile-memory backup failure; persistent-memory region read-only/unreliable | Urgent loss-of-protection/service finding when applicable; explain the specific subsystem |
| `percentage_used` | Vendor estimate of endurance consumed | 100 means estimated endurance consumed, **not guaranteed failure**; values may exceed 100. Plan replacement according to manufacturer guidance and workload, without inventing days remaining |
| `media_errors` | Unrecovered data-integrity errors, including ECC/CRC/tag errors | Investigate any nonzero history; escalating recent deltas plus failed I/O are much stronger urgent evidence than an isolated old count |
| `num_err_log_entries` | Lifetime number of error-information entries | Inspect status/cause and recency. It is not interchangeable with media errors |
| `unsafe_shutdowns` | Loss of power without shutdown notification | Investigate shutdown/power history and correlate with errors; not proof of failed media |
| `data_units_written`, power-on hours, thermal counters | Usage/history with specified units and reporting limits | Useful trends and context; no universal lifespan formula |
NVMe warning bits are current state, not persistent event history; zero today does not erase yesterday's finding. Some temperature fields are optional, and zero can mean unsupported. Per-namespace SMART is optional; the global namespace identifier can describe a controller's aggregate. Preserve scope rather than assigning identical controller totals to every namespace. [S3][S5][S6]
**ATA needs a separate mapping.** Keep attribute ID, raw representation, normalized current/worst value, threshold, type and failure state. smartctl states that these meanings are vendor-specific; SSD meanings can differ and displayed names can be wrong for models absent from its drive database. The label `Pre-fail` by itself does not mean a drive is failing: the current normalized value must cross its threshold. [S2]
Common drive-database candidates include reallocated sectors (5), pending sectors (197), offline uncorrectable sectors (198), and interface CRC errors (199). Interpret them only with a matching model/firmware/database rule; do not apply a universal raw-count threshold or turn interface errors directly into a disk-replacement recommendation. Preserve lifetime history and recent deltas separately. SMART RETURN STATUS, failed applicable thresholds, existing self-test failures, and observed host I/O errors are stronger when they agree. SCSI health uses its own exception/sense reporting rather than ATA attribute assumptions. [S2][S15]
smartctl exit status is a bitmask. Bits 0–2 can describe invocation/access/command problems; bits 3–7 describe failing status, thresholds and historical error/self-test evidence. A nonzero exit must not discard usable JSON, and access failure must not become “bad drive.” Reading an existing self-test log is different from starting a test. The manual notes that running self-tests can degrade performance and normal I/O can extend their duration; any future self-test workflow needs a separate user decision and scheduling. [S2]
## Candidate advice rubric
This rubric is a proposed interpretation layer over the documented evidence, not a manufacturer's warranty or an adopted Odin policy.
| Finding class | Evidence sufficient to consider it | Appropriate wording/action |
| --- | --- | --- |
| **Replace/service now** | Credible ATA failing status/current applicable prefailure threshold; NVMe degraded reliability/read-only media; serious repeated data-integrity failures attributable to the device | “Preserve accessible data now; avoid further stress; arrange replacement or service.” Cite exact flags, device scope and timestamps. Hardware attribution may still need confirmation |
| **Investigate urgently** | New media errors, pending/uncorrectable sectors, recent failed self-tests, resets/timeouts, thermal alarms, loss of power-loss protection | Identify the failing path; correlate controller, connection, power and filesystem evidence. Do not automatically blame the medium |
| **Monitor / plan replacement** | Stable historical findings or vendor-estimated endurance consumed without current failure evidence | Retain trends, explain wear status and manufacturer limits, and plan according to importance/workload. No invented remaining-life percentage |
| **No concerning evidence observed** | Successful supported collection with no relevant current finding | State what was checked and when; keep normal backup advice independent of a performance score |
| **Unknown / limited coverage** | Missing permission/tool/field, sleeping drive, unsupported bridge/controller, virtual device, ambiguous identity | Explain the missing capability and a bounded next step. Never convert unavailable evidence into a healthy badge |
The smartctl manual recommends preserving data promptly when the drive reports failing health. Conversely, Google's primary HDD population study found that SMART-only models were unlikely to predict individual failures reliably. That older HDD result is not a calibrated modern-SSD failure model, but it reinforces the distinction between a useful warning and a guarantee of future health. NVMe's own endurance-field semantics explicitly reject equating 100% usage with failure. [S2][S5][S16]
## Compatibility, privilege and general health
USB, SAT and RAID support must follow known transport rules. The smartctl manual documents bridge-specific NVMe adapters and per-physical-disk MegaRAID addressing; a RAID logical volume is not automatically one physical drive. Particularly important: its **JMB39x/JMS56x transport uses READ/WRITE commands to a RAID-volume sector**. It warns that the wrong device can be overwritten and interruption can prevent restoration. Exclude these from routine automated probing; “try every device type” is not a safe compatibility strategy. Even standby-aware queries may wake a disk during autodetection, so record unsupported power-state handling. [S2]
VMs need an explicit virtual-device classification. QEMU's NVMe implementation constructs SMART data from its emulated controller and block-accounting state. A guest can therefore show valid-looking SMART without revealing the host drive's health. Guest tests establish guest-path performance; actual physical passthrough and device identity require separate verification. VM coverage cannot establish USB, physical RAID, real wear counters or thermal behavior. [S17]
Keep ordinary file workloads unprivileged. Device queries may require additional device permissions or kernel capabilities; NVMe's Linux passthrough code explicitly gates classes of commands. A future privileged mechanism should allow only validated read operations and selected devices, with no arbitrary shell or passthrough-command forwarding. Permission denial is an expected capability outcome, not an instruction to run the whole TUI as root. [S18]
For overall system health, useful complementary evidence is:
- `/proc/pressure/io`: `some` measures time with some stalled tasks, `full` time with all non-idle tasks stalled; use same-window deltas alongside workload latency. This detects pressure, not its sole cause. [S8]
- `/proc/diskstats` or per-device sysfs statistics: completed I/O, time and queue context. Counters have concurrency/accounting caveats; busy percentage alone does not establish NVMe saturation. [S9]
- Available hwmon readings, limits and alarm flags: retain sensor identity and units; chip-specific alarms and missing sensors preclude a universal hard-coded temperature cutoff. Standard hwmon ABI readings are intended to be readable by unprivileged applications. [S10]
- Kernel errors and existing EDAC/RAS evidence: distinguish corrected errors from uncorrected/fatal errors and report available history. EDAC documentation explicitly says corrected errors may, but need not, predict later uncorrected errors. Missing reporting hardware/driver is unknown. Kernel log access can require `CAP_SYSLOG` when `dmesg_restrict=1`; do not assume systemd/journald on Void or other distributions. [S13][S19]
Kernel or mount optimizations should be suggestions tied to an observed limitation and a documented tradeoff, recorded for subsequent comparable runs. This research supports observing current settings and thermal/power/error evidence; it supplies no evidence for blanket scheduler, write-cache, governor, or filesystem changes.
## Remaining decisions and evidence gaps
The next human decisions are the ordinary run's write authorization/budget, minimum free-space reserve, workload lengths and repetitions, required versus optional queued/sync/verification tests, how reduced-capability results affect score eligibility, the supported transport list, the privilege interaction, and the exact advice wording. A sustained-media profile would need a separate impact budget.
Implementation work will need parser fixtures from supported smartctl/nvme-cli versions; success, partial and denied-permission results; real ATA/NVMe/USB/RAID samples; healthy and failing vendor examples; and proof of cancellation, space reservation, direct-I/O handling and cleanup across filesystems. No such hardware validation occurred here. No calibrated cross-device replacement thresholds or modern SSD remaining-life model were found or claimed.
Context7 library resolution succeeded for fio, smartmontools, nvme-cli, Linux kernel, GNU Coreutils and QEMU. Both allowed nvme-cli documentation fetches returned “Could not fetch documentation snippets”; its official released documents and source were inspected instead. The NVM Express specifications landing page returned HTTP 403, so NVMe field semantics here are grounded in maintained libnvme definitions and smartmontools implementation rather than a directly retrieved current specification PDF. Those are material evidence limits, not silently filled gaps.
## Sources inspected
- **S1:** [fio 3.43 HOWTO](https://github.com/axboe/fio/blob/fio-3.43/HOWTO.rst), relevant workload, size/runtime, buffering, engines, percentile, verification and error sections; [release](https://github.com/axboe/fio/releases/tag/fio-3.43).
- **S2:** [smartctl manual source](https://github.com/smartmontools/smartmontools/blob/master/smartmontools/smartctl.8.in), health, attributes, JSON, exit status, device transports, standby and self-tests.
- **S3:** nvme-cli v3.1 [SMART log command](https://github.com/linux-nvme/nvme-cli/blob/v3.1/Documentation/nvme-log-smart.txt), [global options](https://github.com/linux-nvme/nvme-cli/blob/v3.1/Documentation/global-options.txt), [legacy alias](https://github.com/linux-nvme/nvme-cli/blob/master/Documentation/nvme-smart-log.txt), and [release](https://github.com/linux-nvme/nvme-cli/releases/tag/v3.1).
- **S4:** [smartmontools NVMe support examples](https://www.smartmontools.org/wiki/NVMe_Support), inspected through Context7.
- **S5:** [libnvme types](https://github.com/linux-nvme/libnvme/blob/master/src/nvme/types.h), `nvme_smart_log` and `nvme_smart_crit` documentation.
- **S6:** [smartmontools NVMe JSON implementation](https://github.com/smartmontools/smartmontools/blob/master/smartmontools/nvmeprint.cpp).
- **S7:** [Linux man-pages open(2)](https://man7.org/linux/man-pages/man2/open.2.html), exclusive creation and direct/synchronized I/O.
- **S8:** [Linux PSI documentation](https://www.kernel.org/doc/html/latest/accounting/psi.html).
- **S9:** [Linux I/O statistics documentation](https://www.kernel.org/doc/html/latest/admin-guide/iostats.html).
- **S10:** [Linux hwmon sysfs interface](https://www.kernel.org/doc/html/latest/hwmon/sysfs-interface.html).
- **S11:** [Linux tmpfs documentation](https://docs.kernel.org/filesystems/tmpfs.html).
- **S12:** [Linux VM sysctl documentation](https://docs.kernel.org/admin-guide/sysctl/vm.html), `drop_caches`.
- **S13:** [Linux RAS documentation source](https://www.kernel.org/doc/html/latest/_sources/admin-guide/RAS/main.rst.txt), error categories and EDAC.
- **S14:** [GNU Coreutils dd manual](https://www.gnu.org/software/coreutils/manual/html_node/dd-invocation.html).
- **S15:** [smartmontools drive database](https://github.com/smartmontools/smartmontools/blob/master/smartmontools/drivedb.h), default and model-dependent attribute mappings.
- **S16:** [Google, Failure Trends in a Large Disk Drive Population](https://research.google/pubs/failure-trends-in-a-large-disk-drive-population/), primary publication abstract, 2007.
- **S17:** [QEMU NVMe implementation](https://github.com/qemu/qemu/blob/master/hw/nvme/ctrl.c), `nvme_smart_info`; [NVMe device documentation](https://github.com/qemu/qemu/blob/master/docs/system/devices/nvme.rst), inspected through Context7.
- **S18:** [Linux NVMe ioctl authorization](https://github.com/torvalds/linux/blob/master/drivers/nvme/host/ioctl.c).
- **S19:** [Linux kernel sysctl documentation](https://www.kernel.org/doc/html/latest/admin-guide/sysctl/kernel.html), `dmesg_restrict`.
+1
View File
@@ -0,0 +1 @@
node_modules/
+9
View File
@@ -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.
+110
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+7
View File
@@ -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
+547
View File
@@ -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 --&gt; STEP 2 MEASURE --&gt; 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' } });