Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3185688f9 |
@@ -1,135 +0,0 @@
|
||||
# Establish browser, shell, and language workload validity
|
||||
|
||||
Research for [Establish browser, shell, and language workload validity](https://git.bongbetic.com/xavierk/odin/issues/5), part of [Find the way to Odin’s build-ready specification](https://git.bongbetic.com/xavierk/odin/issues/1).
|
||||
|
||||
Access date: **2026-09-25**. This is a candidate analysis, not the final suite. No browsers, toolchains, or dependencies were installed; no benchmarks or builds were run.
|
||||
|
||||
## Recommendation for the selection decision
|
||||
|
||||
Preserve five distinct measurement purposes: browser application responsiveness, shell interaction, process startup, compilation, and warmed application execution. A single fast loop cannot represent all five. Retain upstream benchmark names, versions, metrics, and correctness rules; an Odin aggregate is a separate decision.
|
||||
|
||||
A practical initial option is **Speedometer 3.1**, a small Bash latency/throughput set, and optional language packs with pinned workload inputs. JetStream, MotionMark, representative compilation projects, and larger JVM applications fit extended investigation. Installed software answers “how usable is this machine as configured”; pinned reference software improves comparison across machines. Store these as distinct modes rather than treating them as interchangeable.
|
||||
|
||||
## 1. Browser candidates and official measurement rules
|
||||
|
||||
BrowserBench’s live index currently points to **Speedometer 3.1**, **JetStream 3.0**, and **MotionMark 1.3.2**. [1]
|
||||
|
||||
| Candidate | What it measures | Conditional role and interpretation |
|
||||
| --- | --- | --- |
|
||||
| Speedometer 3.1 | End-to-end simulated web-app interactions: TodoMVC frameworks, editors, charts, and news applications. | Strong default browser-responsiveness candidate. Preserve the full default suite and iteration protocol for an upstream-style result. It does not measure browser process startup or network loading. |
|
||||
| JetStream 3.0 | JavaScript/WebAssembly workloads, combining startup, average, and worst-case execution behavior. | Useful extended engine-compute view. “Startup” is workload first-iteration behavior, not opening the browser. Its 77 default workloads and upstream geometric aggregation should remain intact. |
|
||||
| MotionMark 1.3.2 | Browser graphics scenes adjusted toward a target frame rate, with confidence intervals. | Useful optional browser-rendering view where a suitable graphics/display path exists. It is not a standalone GPU benchmark or evidence that the intended hardware driver is active. |
|
||||
|
||||
Speedometer’s release/3.1 source currently resolves to commit `1386415be8fef2f6b6bbdbe1828872471c5d802a`. Its `package.json` still says `3.0.0-alpha`, demonstrating why package metadata alone is not a benchmark identity. The release source defaults to **10 iterations** and an **800×600 workload viewport**; URL parameters can change iterations, suites, warmup, and measurement behavior. The result includes individual measurements and JSON exports. Preserve these settings and raw results; do not silently shorten the suite and report an ordinary “Speedometer 3.1” score. [2]
|
||||
|
||||
Official Speedometer instructions recommend a latest stable browser, a separate clean profile, closed background applications/tabs, a focused benchmark page, AC power, no interaction, and cooling between runs where needed. A pinned historical browser is useful for longitudinal comparison but must be identified as a reference-browser experiment. Speedometer aggregates inverse geometric-mean durations, then averages scores across iterations and reports a 95% confidence interval around that mean. Retain that native statistic and uncertainty. Any Odin median across complete benchmark runs is a separate statistic; do not relabel either or average heterogeneous subtest times. [2][3]
|
||||
|
||||
JetStream explicitly says scores are not comparable across its versions. Its startup, worst-case, and average components are deliberately part of the metric; generic harness warmups must not erase those startup observations. MotionMark depends on drawing-area class, viewport, refresh behavior, and browser rendering. Its instructions recommend maximizing the window at the default resolution and 60 Hz for comparison across browsers; a changed display policy is a recorded experimental condition, not something Odin should silently impose. [4][5]
|
||||
|
||||
### Offline hosting and licensing
|
||||
|
||||
Speedometer documents a local static HTTP server, so a prepared source/assets snapshot can be served on loopback without fetching the public site during measurement. Its root license permits source/binary redistribution with the copyright, conditions, and disclaimer retained. That does not replace the license notices of bundled frameworks, fonts, datasets, or other third-party assets. JetStream and MotionMark’s deployed HTML also carries permissive two-condition notices, while their workload provenance points to multiple projects. Before distributing a pack, inventory the exact selected files and retain their individual notices. [2][4][5][6]
|
||||
|
||||
Recommended preparation contract: content-addressed assets, release/commit identity, dependency/license manifest, complete local resource closure, and a future offline-network audit. Bind the server to loopback and prepare everything before timing. A copied public page without its subresources is not a verified offline benchmark. Keep upstream workload code unchanged where feasible; record every adapter patch. The existence of localhost support does not prove the entire pack is self-contained until that artifact is checked.
|
||||
|
||||
### Automation and the Linux boundary
|
||||
|
||||
Two viable routes serve different purposes:
|
||||
|
||||
1. **Native installed browser plus matching WebDriver.** Speedometer itself documents Selenium testing with ChromeDriver, GeckoDriver, and other browser drivers. This route can use distro-supported native browsers on Void/musl or other systems outside Playwright’s matrix, provided the actual browser/driver pair exists and is qualified. Use explicit browser and driver paths and versions. Selenium Manager can download software by default; its documented offline mode disables network requests/downloads. Its documentation still describes limited Linux architecture support, so automatic management cannot be presumed to cover aarch64 or musl. Native automation remains a capability to prove, not a promise. [7]
|
||||
2. **Pinned browser supplied with Playwright on its supported platforms.** Current documentation lists Debian 12/13 and Ubuntu 22.04/24.04/26.04 on x86-64/arm64. Its bundled Firefox/WebKit builds require glibc; musl distributions are unsupported. Stock Firefox cannot simply be substituted because Playwright requires its patched Firefox. Branded Chrome/Edge support and custom executable paths also do not establish universal compatibility. [8]
|
||||
|
||||
**Odin can launch on a system even when its browser module is unavailable.** On musl, use a qualified native browser/driver pair or report an explicit capability skip. A glibc container/chroot or software-rendered browser is a separate environment and cannot transparently stand in for native desktop usability.
|
||||
|
||||
The user has approved **both headed and headless browser modes**, with terminal controls/results and distinct measurement labels. Chrome’s unified headless mode shares browser code with headed Chrome, but Playwright also provides a distinct headless-shell binary and documents behavioral differences. Sharing code does not establish equal scores, display scheduling, GPU access, or compositor behavior. Label a headless cohort explicitly and avoid claiming compliance with focused-window desktop run conditions. A headed cohort needs a graphical session and the official run conditions. Do not merge their reference distributions or invent a conversion factor. The default mode for each run profile remains a selection decision. [8][9]
|
||||
|
||||
Automation should start the pinned page, observe completion, and collect its full result without repeated polling/instrumentation inside timed work. Correctness failures, missing workloads, or timeouts invalidate a full-suite result. Shortened or filtered runs can be Odin browser probes with their own identities; they are not substitutes for official full-suite statistics.
|
||||
|
||||
Record browser binary/version/channel, driver and automation versions, full flags, profile policy, headed/headless/shell distinction, viewport/device scale, display resolution/refresh, X11/Wayland/virtual display, renderer and acceleration evidence when exposed, kernel/libc/architecture, power state, suite digest, and all query parameters. An unavailable GPU detail is unknown, not proof of hardware acceleration. Browser startup, if desired, should be a separate process-to-ready-page test with its cache/profile policy declared.
|
||||
|
||||
## 2. Meaningful Bash and shell measurements
|
||||
|
||||
Bash startup depends on invocation. Login shells read `/etc/profile` and the first readable user login profile; interactive non-login shells read `.bashrc`; noninteractive shells may execute the file named by `BASH_ENV`. `--noprofile` and `--norc` control different paths. Therefore “time bash” is underspecified. [10]
|
||||
|
||||
| Candidate | Measurement definition | Boundary to preserve |
|
||||
| --- | --- | --- |
|
||||
| Clean process startup | Directly launch a known Bash binary with controlled environment/startup-file policy and a trivial command; measure process launch through exit. | This includes loader/process/exit costs. It is not prompt readiness. Explicitly control `BASH_ENV`, inherited functions, locale, and working directory. |
|
||||
| Interactive readiness | Launch a clean interactive shell under a PTY; measure until a declared prompt-ready marker, then measure prompt return after a builtin command. | Account for PTY setup and marker instrumentation. `bash -i -c exit` does not observe a real prompt. Test login and non-login separately if both are offered. |
|
||||
| Builtin workload throughput | Fixed arithmetic, parameter expansion, arrays, and parsing over seeded in-memory inputs, returning a verified checksum. | Bounded output; no per-iteration `date`, `cat`, `grep`, or other subprocesses. Input size and Bash version are part of the workload identity. |
|
||||
| External-command/pipeline workload | Fixed commands, input corpus, and pipe structure, with exact executable versions. | Measures the combined shell, process-launch, tool, and I/O stack. Report it as such. |
|
||||
| User configuration readiness | Opt-in observation using the user’s real startup files and prompt configuration. | These files execute arbitrary user-configured commands, may contact networks or change state, and may not be repeatable. Report local usability separately from the clean reference result. |
|
||||
|
||||
**Harness trap:** Hyperfine defaults to an intermediate `/bin/sh` on Unix and calibrates/subtracts shell-spawn time. For Bash startup, use its direct-command mode or another direct process timer so the Bash process is the measured payload. Hyperfine also supports warmups and repetitions; warmed filesystem caches are a declared condition, not “cold boot.” Do not globally drop caches or alter system tuning just to manufacture a shell metric. [11]
|
||||
|
||||
Treat time-to-first-prompt and prompt-to-next-prompt as distributions, not a single best sample. Bound captured PTY output, support cancellation, and keep configured-shell timeout/error details. Store configuration fingerprints with care; benchmark records should not copy potentially secret startup-file contents.
|
||||
|
||||
## 3. Language workload candidates
|
||||
|
||||
Each result describes a **workload + implementation + toolchain + environment**, not an intrinsic language ranking. Numeric kernels are useful but should not be the only evidence. Libraries, parsing, allocation, and application behavior matter; Python’s C-backed JSON module and a Rust regex library do not represent the same implementation simply because both are called language tests.
|
||||
|
||||
| Language / candidate | Useful workload selection | Evidence, tradeoffs, and role |
|
||||
| --- | --- | --- |
|
||||
| Python: pyperformance + pyperf | `python_startup`, `python_startup_no_site`, `json_loads`/`json_dumps`, one regex workload, and optionally a pure-Python workload such as `richards`. | Maintained Python project favors real applications. Current PyPI metadata: pyperformance **1.14.0**, Python ≥3.10; pyperf **2.10.0**, Python ≥3.9. Suite docs contain older requirements, so prefer release metadata. MIT project license; inspect selected workload notices. Small subsets fit standard profiles. [12] |
|
||||
| Rust: rustc-perf subsets | Runtime groups include parsing, text search, compression, hash maps, and numeric/graphics kernels; compile candidates include pinned real crates such as `syn`. | Official compiler-performance project clearly separates compilation from generated-program execution. Runtime suite is explicitly **experimental**, with changing workloads; pin a commit and validate chosen outputs. MIT infrastructure, separate compile-benchmark licenses. Avoid adopting the full collector by default: it has profiling/environment dependencies. [13] |
|
||||
| C++: LLVM test-suite subset | A small C++ application/proxy workload such as the serial miniFE variant, plus a smaller verified workload; optionally time compiling the same pinned sources. | Suite compiles/runs whole programs, checks reference output, and records compile and execution times separately. CMake/lit and dependencies increase setup cost. Apache-2.0 with LLVM exceptions for project code; third-party directories have separate terms. Good standard/extended candidate after footprint qualification. [14] |
|
||||
| Java: selected workloads under JMH | A packaged, reviewed data-processing/allocation workload with validated results and configured warmup/forks. | JMH **1.37** is the current published Maven release inspected. It is a harness, not a representative suite by itself; samples explain pitfalls rather than defining an Odin score. GPLv2 with the Classpath exception on designated files. Suitable where a small, controlled JVM workload is desired. [15] |
|
||||
| Java: DaCapo application subset | `lusearch` for search, `h2` for database-style execution, or another justified application. | Latest inspected release **23.11-MR2-chopin**; application suite with nontrivial memory use and output validation. Release notes establish Java 11–21 compatibility, not arbitrary newer JDKs. Harness Apache-2.0; component programs retain their licenses. Better extended candidate than a compulsory quick test. [16] |
|
||||
|
||||
pyperformance explicitly says it is not tuned for PyPy. CPython, PyPy, free-threaded builds, optional JIT builds, and distro build choices require distinct metadata and qualification. Interpreter startup with and without `site` measures different initialization; preserve both names if offered. Some pyperformance dependencies have native extensions, so a Python package being source-available does not prove wheel availability on every musl/architecture combination. [12]
|
||||
|
||||
## 4. Fair measurement protocol
|
||||
|
||||
**Startup, compilation, and warmed execution need separate records.** For Rust/C++, compile once before execution measurements unless compilation is the workload. For Java, bytecode compilation with `javac` differs from runtime JIT work. For Python, process startup/imports differ from repeated execution in an initialized interpreter. If the workload intentionally includes setup, say so and apply the same rule on every system.
|
||||
|
||||
pyperf uses calibration, multiple processes, warmups, values, and metadata. Its fast mode explicitly trades accuracy for speed. JMH provides forks, warmup/measurement iterations, state setup, and result consumption to avoid dead-code elimination; its maintainers warn that a harness does not eliminate benchmarking mistakes. Retain cold/startup samples and warmed samples separately. Use predetermined warmup/measurement rules and report instability; do not keep warming until a favorable result appears. [15][17]
|
||||
|
||||
Proposed compilation policy for selection:
|
||||
|
||||
- Pin sources, datasets, dependency lockfiles, language standard/edition, compiler identity, target triple, optimization flags, linker, libc, and library versions.
|
||||
- Use a documented release optimization configuration. Cargo’s release defaults include `opt-level=3`, incremental off, and 16 codegen units; its bench profile inherits release. Record overrides and dependency profiles. C++ should use an explicitly chosen optimization level and standard; `-Ofast` changes standards-compliance assumptions and cannot silently replace a strict floating-point contract. [18]
|
||||
- Keep baseline-ISA builds and host-tuned builds distinct. `target-cpu=native`/`-march=native`, SIMD dispatch, LTO, PGO, allocator choice, and thread counts can materially change results. A single binary is not portable across x86_64 and aarch64; equal flags do not mean equal generated instructions.
|
||||
- Compilation tests distinguish clean builds, no-op rebuilds, and controlled incremental edits. Pre-fetch dependencies and hold build parallelism/cache policy fixed. Network resolution is not compilation speed; compiler caches must be controlled or explicitly measured.
|
||||
- Validate outputs/checksums and numerical tolerances. Prevent constant folding/dead work, but avoid timing validation if the workload definition excludes it. Do not compare implementations with different precision, algorithms, data sizes, or hidden thread counts under a shared metric name.
|
||||
|
||||
An **installed-toolchain mode** best reveals current developer experience. A **reference-toolchain mode** uses pinned, prepared artifacts for stronger cross-machine comparison, with acquisition size, libc/ISA support, licenses, and security updates owned explicitly. It must not install into or replace the user’s default toolchain during a run. Report compiler/runtime version changes as changes in execution conditions, not unexplained hardware improvements.
|
||||
|
||||
## 5. Profile options, budgets, and unavailable tools
|
||||
|
||||
The following are provisional **application-domain budgets**, not measured runtime promises or the whole Odin run duration:
|
||||
|
||||
| Profile | Candidate scope | Budget policy to evaluate |
|
||||
| --- | --- | --- |
|
||||
| Quick | Browser capability probe; clean Bash startup and one builtin workload; startup/small verified work on selected installed languages. | Approximately 30–60 seconds for this domain. No full browser score if the official suite does not fit. Short observations carry lower-confidence status. |
|
||||
| Standard | Full Speedometer 3.1 on one qualified browser; Bash set; selected language pack(s) with normal warmup/repetitions. | Reserve minutes rather than seconds; a pilot target is 2–5 minutes for browser and 1–3 minutes per selected language pack. Slow hosts may exceed it. |
|
||||
| Extended | JetStream, optional MotionMark, more process forks, compilation workloads, selected DaCapo applications, additional browsers/toolchains. | User-visible per-module time/memory/disk ceilings; an initial 15–30-minute application allocation needs calibration on low-end physical hardware. |
|
||||
|
||||
Preparation/download/compilation costs are shown separately unless compilation is the named workload. Check tools and resource requirements before beginning. Do not shorten an upstream test behind the user’s back to meet a deadline: mark it incomplete and preserve diagnostics.
|
||||
|
||||
Missing interpreter/compiler/browser/driver, unsupported ABI, unavailable display, insufficient resources, timeout, and failed output validation are different outcomes. None is a zero performance measurement. Skips reduce **capability coverage**; the score-design ticket must determine eligibility for an aggregate. Optional-tool absence should leave completed results usable. No hardware/browser/type of VM was executed here to substantiate duration estimates.
|
||||
|
||||
## Remaining decisions and evidence gaps
|
||||
|
||||
Both browser modes are already approved. Select the default mode for each profile; the native-browser/driver and reference-browser support matrix; exact upstream full-suite versus Odin-probe identities; language subsets; installed/reference toolchain policy; fixed compilation/warmup rules; and measured profile budgets. Browser automation overhead, all-assets-offline closure, per-asset redistribution inventory, selected-suite behavior on musl/aarch64 and newer JDKs, and correctness/variance qualification remain future proof work. An application score must not conceal these unresolved cohort boundaries.
|
||||
|
||||
## Sources and method
|
||||
|
||||
Primary sources inspected **2026-09-25**. Context7 resolution preceded queries for Speedometer/WebKit, Bash, Hyperfine, Playwright, pyperformance/pyperf, Rust, LLVM, JMH, GCC, and Selenium. Exact Speedometer and pyperformance lookups returned unrelated entries; those were rejected and their own repositories inspected. Queries stayed within the three-command limit per lookup; no quota error occurred.
|
||||
|
||||
1. [BrowserBench current index](https://browserbench.org/).
|
||||
2. Speedometer release source: [commit](https://github.com/WebKit/Speedometer/commit/1386415be8fef2f6b6bbdbe1828872471c5d802a), [parameters](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/params.mjs), [result client](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/resources/main.mjs), [package metadata](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/package.json), [local hosting](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/Development.md).
|
||||
3. [Speedometer 3.1 instructions](https://browserbench.org/Speedometer3.1/instructions.html), [workload descriptions](https://browserbench.org/Speedometer3.1/about.html), [measurement/scoring explanation](https://github.com/WebKit/Speedometer/blob/main/README.md).
|
||||
4. [JetStream 3.0 implementation page](https://browserbench.org/JetStream3.0/), [in-depth methodology and provenance](https://browserbench.org/JetStream3.0/in-depth.html).
|
||||
5. [MotionMark 1.3.2 implementation page](https://browserbench.org/MotionMark1.3.2/), [methodology, display requirements, and version history](https://browserbench.org/MotionMark1.3.2/about.html).
|
||||
6. [Speedometer release license](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/LICENSE).
|
||||
7. [Speedometer browser-driver testing documentation](https://github.com/WebKit/Speedometer/blob/1386415be8fef2f6b6bbdbe1828872471c5d802a/Testing.md), [Selenium Manager: explicit drivers, offline mode, and architecture limitations](https://www.selenium.dev/documentation/selenium_manager/).
|
||||
8. [Playwright system requirements](https://playwright.dev/docs/intro), [browser distributions and headless modes](https://github.com/microsoft/playwright/blob/main/docs/src/browsers.md), [musl limitations](https://github.com/microsoft/playwright/blob/main/docs/src/docker.md).
|
||||
9. [Chrome unified headless and headless-shell distinction](https://developer.chrome.com/docs/chromium/headless).
|
||||
10. [GNU Bash startup files](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html), [invocation options](https://www.gnu.org/software/bash/manual/html_node/Invoking-Bash.html).
|
||||
11. [Hyperfine README: intermediate-shell correction, direct mode, warmups, repetitions](https://github.com/sharkdp/hyperfine/blob/master/README.md).
|
||||
12. [pyperformance purpose and license](https://github.com/python/pyperformance/blob/main/README.rst), [PyPI release metadata](https://pypi.org/pypi/pyperformance/json), [pyperf release metadata](https://pypi.org/pypi/pyperf/json), [benchmark manifest](https://github.com/python/pyperformance/blob/main/pyperformance/data-files/benchmarks/MANIFEST), [workload descriptions](https://github.com/python/pyperformance/blob/main/doc/benchmarks.rst), [dependency/runtime guidance](https://github.com/python/pyperformance/blob/main/doc/usage.rst), [startup implementation](https://github.com/python/pyperformance/blob/main/pyperformance/data-files/benchmarks/bm_python_startup/run_benchmark.py).
|
||||
13. [rustc-perf purpose/licenses](https://github.com/rust-lang/rustc-perf/blob/master/README.md), [compile suite](https://github.com/rust-lang/rustc-perf/blob/master/collector/compile-benchmarks/README.md), [experimental runtime suite](https://github.com/rust-lang/rustc-perf/blob/master/collector/runtime-benchmarks/README.md), [runtime groups](https://github.com/rust-lang/rustc-perf/tree/master/collector/runtime-benchmarks), [collector requirements](https://github.com/rust-lang/rustc-perf/blob/master/collector/README.md).
|
||||
14. [LLVM test-suite guide](https://llvm.org/docs/TestSuiteGuide.html), [testing/output-validation model](https://llvm.org/docs/TestingGuide.html), [serial miniFE description](https://github.com/llvm/llvm-test-suite/blob/main/MultiSource/Benchmarks/DOE-ProxyApps-C++/miniFE/README), [license and third-party exceptions](https://github.com/llvm/llvm-test-suite/blob/main/LICENSE.TXT).
|
||||
15. [JMH purpose and cautions](https://github.com/openjdk/jmh/blob/master/README.md), [published version metadata](https://repo.maven.apache.org/maven2/org/openjdk/jmh/jmh-core/maven-metadata.xml), [parameter/warmup/fork example](https://github.com/openjdk/jmh/blob/master/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_27_Params.java), [result consumption and profilers](https://github.com/openjdk/jmh/blob/master/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_35_Profilers.java), [license](https://github.com/openjdk/jmh/blob/master/LICENSE).
|
||||
16. [DaCapo latest release](https://github.com/dacapobench/dacapobench/releases/tag/v23.11-MR2-chopin), [release compatibility and workload notes](https://github.com/dacapobench/dacapobench/blob/0db32562cf169730c163d88df2eeb28217ca7d03/benchmarks/RELEASE_NOTES.md), [purpose/reporting/license guidance](https://github.com/dacapobench/dacapobench/blob/master/README.md).
|
||||
17. [pyperf runner configuration](https://github.com/psf/pyperf/blob/main/doc/runner.rst), [process/calibration architecture](https://github.com/psf/pyperf/blob/main/doc/run_benchmark.rst).
|
||||
18. [Cargo profile defaults](https://doc.rust-lang.org/cargo/reference/profiles.html), [rustc code-generation options](https://doc.rust-lang.org/rustc/codegen-options/index.html), [GCC optimization semantics](https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html).
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,9 @@
|
||||
# Odin TUI workflow prototype
|
||||
|
||||
Disposable InkUI prototype for choosing between three structurally different benchmark workflows. All measurements and findings are sample data; no benchmark runs or system changes occur.
|
||||
|
||||
Run with `./run.sh` from this directory. On first run, the script installs the pinned local dependencies.
|
||||
|
||||
Controls: `[` / `]` change layout; `p` changes profile; `Enter` moves through the run; `c` requests cancellation; `g` plan, `l` live run, `s` summary, `h` history, `o` advice; `t` theme; `a` ASCII fallback; `q` quit.
|
||||
|
||||
Mouse support is experimental. Click visible action rows in terminals that send SGR mouse reports. Resize terminal while running to inspect reflow. If your shell sets `NO_COLOR`, use `env -u NO_COLOR ./run.sh` to inspect the color theme.
|
||||
@@ -0,0 +1,110 @@
|
||||
// Auto-generated by sync-registry.ts
|
||||
// Inlines @inkui-cli/core theme and tokens
|
||||
|
||||
export const spacing = {
|
||||
none: 0,
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
} as const;
|
||||
|
||||
export const borderStyles = {
|
||||
single: {
|
||||
topLeft: '┌', top: '─', topRight: '┐',
|
||||
left: '│', right: '│',
|
||||
bottomLeft: '└', bottom: '─', bottomRight: '┘',
|
||||
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
|
||||
},
|
||||
double: {
|
||||
topLeft: '╔', top: '═', topRight: '╗',
|
||||
left: '║', right: '║',
|
||||
bottomLeft: '╚', bottom: '═', bottomRight: '╝',
|
||||
topT: '╦', bottomT: '╩', leftT: '╠', rightT: '╣', cross: '╬',
|
||||
},
|
||||
rounded: {
|
||||
topLeft: '╭', top: '─', topRight: '╮',
|
||||
left: '│', right: '│',
|
||||
bottomLeft: '╰', bottom: '─', bottomRight: '╯',
|
||||
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
|
||||
},
|
||||
bold: {
|
||||
topLeft: '┏', top: '━', topRight: '┓',
|
||||
left: '┃', right: '┃',
|
||||
bottomLeft: '┗', bottom: '━', bottomRight: '┛',
|
||||
topT: '┳', bottomT: '┻', leftT: '┣', rightT: '┫', cross: '╋',
|
||||
},
|
||||
ascii: {
|
||||
topLeft: '+', top: '-', topRight: '+',
|
||||
left: '|', right: '|',
|
||||
bottomLeft: '+', bottom: '-', bottomRight: '+',
|
||||
topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type BorderStyle = keyof typeof borderStyles;
|
||||
|
||||
export const spinnerFrames = {
|
||||
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||
line: ['−', '\\', '|', '/'],
|
||||
arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
|
||||
bounce: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
|
||||
} as const;
|
||||
|
||||
export type SpinnerType = keyof typeof spinnerFrames;
|
||||
|
||||
|
||||
export interface InkUITheme {
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
muted: string;
|
||||
text: string;
|
||||
textInverse: string;
|
||||
border: string;
|
||||
focus: string;
|
||||
selection: string;
|
||||
};
|
||||
border: 'single' | 'double' | 'rounded' | 'bold' | 'ascii';
|
||||
}
|
||||
|
||||
export const darkTheme: InkUITheme = {
|
||||
colors: {
|
||||
primary: 'cyan',
|
||||
secondary: 'magenta',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
info: 'blue',
|
||||
muted: 'gray',
|
||||
text: 'white',
|
||||
textInverse: 'black',
|
||||
border: 'gray',
|
||||
focus: 'cyan',
|
||||
selection: 'blue',
|
||||
},
|
||||
border: 'single',
|
||||
};
|
||||
|
||||
export const lightTheme: InkUITheme = {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
secondary: 'magenta',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
info: 'cyan',
|
||||
muted: 'gray',
|
||||
text: 'black',
|
||||
textInverse: 'white',
|
||||
border: 'gray',
|
||||
focus: 'blue',
|
||||
selection: 'cyan',
|
||||
},
|
||||
border: 'rounded',
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export interface BadgeProps {
|
||||
/** Badge label text */
|
||||
children: string;
|
||||
/** Visual style — maps to theme colors */
|
||||
variant?: BadgeVariant;
|
||||
/** Theme override — defaults to darkTheme */
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
function variantColor(variant: BadgeVariant, theme: InkUITheme): string {
|
||||
switch (variant) {
|
||||
case 'success': return theme.colors.success;
|
||||
case 'warning': return theme.colors.warning;
|
||||
case 'error': return theme.colors.error;
|
||||
case 'info': return theme.colors.info;
|
||||
default: return theme.colors.muted;
|
||||
}
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const color = variantColor(variant, theme);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={color} bold inverse>{` ${children} `}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BadgeProps, BadgeVariant } from './Badge.js';
|
||||
export { Badge } from './Badge.js';
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface GaugeProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
variant?: 'arc' | 'bar' | 'ring';
|
||||
colorStops?: [number, string][];
|
||||
width?: number;
|
||||
showValue?: boolean;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Gauge: React.FC<GaugeProps> = ({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
label,
|
||||
unit = '%',
|
||||
variant = 'bar',
|
||||
colorStops,
|
||||
width = 20,
|
||||
showValue = true,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
|
||||
const defaultStops: [number, string][] = [
|
||||
[0, theme.colors.success],
|
||||
[60, theme.colors.warning],
|
||||
[80, theme.colors.error],
|
||||
];
|
||||
const stops = colorStops ?? defaultStops;
|
||||
let color = stops[0][1];
|
||||
for (const [threshold, c] of stops) {
|
||||
if (pct >= threshold) color = c;
|
||||
}
|
||||
|
||||
const displayValue = `${Math.round(value)}${unit}`;
|
||||
|
||||
if (variant === 'bar') {
|
||||
const filled = Math.round((pct / 100) * width);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={color}>{bar}</Text>
|
||||
{showValue && <Text color={color}>{displayValue}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'ring') {
|
||||
const ringWidth = Math.floor(width / 2);
|
||||
const topFilled = Math.round((pct / 100) * ringWidth);
|
||||
const topBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
|
||||
const botBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={color}>{'◜'}{topBar}{'◝'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'} <Text color={color}>{displayValue.padStart(ringWidth - 1)}</Text>{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={color}>{'◟'}{botBar}{'◞'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// arc variant
|
||||
const arcWidth = Math.max(10, width);
|
||||
const filled = Math.round((pct / 100) * arcWidth);
|
||||
const arcBar = '█'.repeat(filled) + '░'.repeat(arcWidth - filled);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={theme.colors.border}>{' ╭' + '─'.repeat(arcWidth) + '╮'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╱'}{' '.repeat(arcWidth + 2)}{'╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'╱ '}<Text color={color}>{arcBar}</Text>{' ╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'│'}{' '.repeat(Math.floor(arcWidth / 2))}<Text color={color}>{displayValue}</Text>{' '.repeat(Math.ceil(arcWidth / 2))}{'│'}</Text>
|
||||
<Text color={theme.colors.border}>{'╲'}{' '.repeat(arcWidth + 2)}{'╱'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╰' + '─'.repeat(arcWidth) + '╯'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{' '}{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Gauge } from './Gauge.js';
|
||||
export type { GaugeProps } from './Gauge.js';
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Text, Box, useStdout } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export type HeaderStyle = 'box' | 'line' | 'filled';
|
||||
|
||||
export interface HeaderProps {
|
||||
title: string;
|
||||
version?: string;
|
||||
subtitle?: string;
|
||||
style?: HeaderStyle;
|
||||
align?: 'left' | 'center';
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
version,
|
||||
subtitle,
|
||||
style = 'box',
|
||||
align = 'left',
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const w = stdout?.columns ?? 80;
|
||||
const fullTitle = version ? `${title} v${version}` : title;
|
||||
|
||||
if (style === 'box') {
|
||||
// ┌─── MyApp v1.0 ──────────────────┐
|
||||
const inner = w - 2; // exclude ┌ and ┐
|
||||
const label = ` ${fullTitle} `;
|
||||
const left = 3; // ─── before label
|
||||
const right = Math.max(0, inner - left - label.length);
|
||||
const top = '┌' + '─'.repeat(left) + label + '─'.repeat(right) + '┐';
|
||||
const bot = '└' + '─'.repeat(inner) + '┘';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{top}</Text>
|
||||
{subtitle && (
|
||||
<Text color={theme.colors.primary}>
|
||||
{'│'} <Text color={theme.colors.muted}>{subtitle.padEnd(inner - 2)}</Text> {'│'}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.colors.primary}>{bot}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === 'line') {
|
||||
// ══ MyApp v1.0 ════════════════════
|
||||
const label = ` ${fullTitle} `;
|
||||
const pre = '══';
|
||||
const remaining = Math.max(0, w - pre.length - label.length);
|
||||
const line = pre + label + '═'.repeat(remaining);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// filled: ███ MyApp v1.0 ████████████
|
||||
const label = ` ${fullTitle} `;
|
||||
const pre = '██ ';
|
||||
const remaining = Math.max(0, w - pre.length - label.length);
|
||||
const line = pre + label + '█'.repeat(remaining);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HeaderProps, HeaderStyle } from './Header.js';
|
||||
export { Header } from './Header.js';
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface KeyHintItem {
|
||||
/** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */
|
||||
key: string;
|
||||
/** Description label, e.g. "Select", "Navigate", "Toggle" */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface KeyHintProps {
|
||||
keys: KeyHintItem[];
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const KeyHint: React.FC<KeyHintProps> = ({
|
||||
keys,
|
||||
theme = darkTheme,
|
||||
}) => (
|
||||
<Box gap={2}>
|
||||
{keys.map(({ key, label }) => (
|
||||
<Box key={key} gap={1}>
|
||||
<Text bold dimColor>[{key}]</Text>
|
||||
<Text color={theme.colors.muted}>{label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { KeyHintProps, KeyHintItem } from './KeyHint.js';
|
||||
export { KeyHint } from './KeyHint.js';
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import { borderStyles, darkTheme } from '../_core.js';
|
||||
import type { BorderStyle, InkUITheme } from '../_core.js';
|
||||
|
||||
export interface SplitPaneProps {
|
||||
/** Split direction */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
/** Sizes as flex ratios, e.g. [1, 2] for 33%/67% */
|
||||
sizes?: number[];
|
||||
/** Gap between panes (columns/rows) */
|
||||
gap?: number;
|
||||
theme?: InkUITheme;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SplitPane({
|
||||
direction = 'horizontal',
|
||||
sizes,
|
||||
gap = 1,
|
||||
children,
|
||||
}: SplitPaneProps) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection={direction === 'horizontal' ? 'row' : 'column'}
|
||||
gap={gap}
|
||||
>
|
||||
{childArray.map((child, i) => {
|
||||
const flex = sizes ? sizes[i] ?? 1 : 1;
|
||||
return (
|
||||
<Box key={i} flexGrow={flex} flexShrink={1} flexBasis={0}>
|
||||
{child}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Map our BorderStyle names → Ink/cli-boxes names
|
||||
// ('rounded' → 'round', 'ascii' → 'classic', others match directly)
|
||||
const INK_BORDER_MAP: Record<BorderStyle, string> = {
|
||||
single: 'single',
|
||||
double: 'double',
|
||||
rounded: 'round',
|
||||
bold: 'bold',
|
||||
ascii: 'classic',
|
||||
};
|
||||
|
||||
export interface PanelProps {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
borderStyle?: BorderStyle;
|
||||
/** Fixed column width (includes borders). 'auto' = fill terminal width. */
|
||||
width?: number | 'auto';
|
||||
/** Inner horizontal padding on each side, default 1 */
|
||||
padding?: number;
|
||||
borderColor?: string;
|
||||
titleColor?: string;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
function repeat(char: string, n: number): string {
|
||||
return n <= 0 ? '' : char.repeat(n);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
children,
|
||||
borderStyle = 'rounded',
|
||||
width = 'auto',
|
||||
padding = 1,
|
||||
borderColor,
|
||||
titleColor: _titleColor,
|
||||
theme = darkTheme,
|
||||
}: PanelProps) {
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Track terminal width for auto-sizing
|
||||
const [termCols, setTermCols] = useState(stdout?.columns ?? 80);
|
||||
useEffect(() => {
|
||||
if (!stdout) return;
|
||||
const handler = () => setTermCols(stdout.columns ?? 80);
|
||||
stdout.on('resize', handler);
|
||||
return () => { stdout.off('resize', handler); };
|
||||
}, [stdout]);
|
||||
|
||||
const chars = borderStyles[borderStyle];
|
||||
const bc = borderColor ?? theme.colors.border;
|
||||
|
||||
// Total panel width (includes the 2 border chars on each side)
|
||||
const totalWidth: number = width === 'auto' ? termCols : (width as number);
|
||||
// Inner width = space between left and right border chars
|
||||
const innerWidth = Math.max(4, totalWidth - 2);
|
||||
|
||||
// ── Build the custom top border line with optional embedded title ──
|
||||
let topLine: string;
|
||||
if (title) {
|
||||
// Max visible title length: innerWidth - "─ " - " ─" = innerWidth - 4
|
||||
const maxTitleLen = Math.max(0, innerWidth - 4);
|
||||
const raw = stringWidth(title) > maxTitleLen
|
||||
? title.slice(0, Math.max(0, maxTitleLen - 1)) + '…'
|
||||
: title;
|
||||
// titleCell = " Title "
|
||||
const titleCell = ' ' + raw + ' ';
|
||||
const leading = 1; // one '─' before the title
|
||||
const remaining = innerWidth - leading - stringWidth(titleCell);
|
||||
topLine =
|
||||
chars.topLeft +
|
||||
repeat(chars.top, leading) +
|
||||
titleCell +
|
||||
repeat(chars.top, Math.max(0, remaining)) +
|
||||
chars.topRight;
|
||||
} else {
|
||||
topLine =
|
||||
chars.topLeft +
|
||||
repeat(chars.top, innerWidth) +
|
||||
chars.topRight;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={totalWidth}>
|
||||
{/* Custom top border with optional title */}
|
||||
<Text color={bc}>{topLine}</Text>
|
||||
|
||||
{/*
|
||||
Native Ink Box with borderStyle for left, right, bottom.
|
||||
borderTop={false} removes the top line — our custom line above handles it.
|
||||
The corner chars from Ink's bottom border (╰ and ╯ for rounded) naturally
|
||||
connect to our top line since we used the same borderStyle chars.
|
||||
*/}
|
||||
<Box
|
||||
borderStyle={INK_BORDER_MAP[borderStyle] as 'single'}
|
||||
borderTop={false}
|
||||
borderColor={bc}
|
||||
paddingX={padding}
|
||||
flexDirection="column"
|
||||
width={totalWidth}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Panel, SplitPane } from './Panel.js';
|
||||
export type { PanelProps, SplitPaneProps } from './Panel.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** Progress value 0–100 */
|
||||
value: number;
|
||||
/** Optional label shown to the left of the bar */
|
||||
label?: string;
|
||||
/** Show percentage at the right end */
|
||||
showPercent?: boolean;
|
||||
/** Fixed bar width in columns — defaults to auto (fills terminal width) */
|
||||
width?: number;
|
||||
/** Theme override — defaults to darkTheme */
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
const FILL = '█';
|
||||
const EMPTY = '░';
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
value,
|
||||
label,
|
||||
showPercent = true,
|
||||
width,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const termWidth = stdout?.columns ?? 80;
|
||||
|
||||
// Clamp value to [0, 100]
|
||||
const pct = Math.min(100, Math.max(0, value));
|
||||
|
||||
// Build the bar width:
|
||||
// total - label - space - percent suffix (e.g. " 100%") - 2 padding spaces
|
||||
const percentSuffix = showPercent ? ` ${String(Math.round(pct)).padStart(3)}%` : '';
|
||||
const labelPrefix = label ? `${label} ` : '';
|
||||
const overhead = labelPrefix.length + percentSuffix.length;
|
||||
const barWidth = width ?? Math.max(8, termWidth - overhead);
|
||||
|
||||
const filled = Math.round((pct / 100) * barWidth);
|
||||
const empty = barWidth - filled;
|
||||
|
||||
const filledStr = FILL.repeat(filled);
|
||||
const emptyStr = EMPTY.repeat(empty);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label ? <Text>{label} </Text> : null}
|
||||
<Text color={theme.colors.primary}>{filledStr}</Text>
|
||||
<Text color={theme.colors.muted}>{emptyStr}</Text>
|
||||
{showPercent ? (
|
||||
<Text color={theme.colors.muted}>{percentSuffix}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ProgressBarProps } from './ProgressBar.js';
|
||||
export { ProgressBar } from './ProgressBar.js';
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface ScrollAreaProps {
|
||||
/** Visible height in rows */
|
||||
height: number;
|
||||
/** Scrollbar visibility */
|
||||
scrollbar?: boolean;
|
||||
/** Scrollbar thumb character */
|
||||
scrollbarChar?: string;
|
||||
/** Track character */
|
||||
trackChar?: string;
|
||||
/** Called when scroll position changes */
|
||||
onScroll?: (offset: number, total: number) => void;
|
||||
/** Whether this component accepts keyboard input */
|
||||
focus?: boolean;
|
||||
/** Color theme */
|
||||
theme?: InkUITheme;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollArea: React.FC<ScrollAreaProps> = ({
|
||||
height,
|
||||
scrollbar = true,
|
||||
scrollbarChar = '█',
|
||||
trackChar = '░',
|
||||
onScroll,
|
||||
focus = true,
|
||||
theme = darkTheme,
|
||||
children,
|
||||
}) => {
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
const items = React.Children.toArray(children);
|
||||
const totalItems = items.length;
|
||||
const maxOffset = Math.max(0, totalItems - height);
|
||||
|
||||
const scroll = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(maxOffset, prev + delta));
|
||||
onScroll?.(next, totalItems);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset, totalItems, onScroll]
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || input === 'k') scroll(-1);
|
||||
else if (key.downArrow || input === 'j') scroll(1);
|
||||
else if (key.pageUp || input === 'u') scroll(-Math.floor(height / 2));
|
||||
else if (key.pageDown || input === 'd') scroll(Math.floor(height / 2));
|
||||
else if (input === 'g') scroll(-totalItems);
|
||||
else if (input === 'G') scroll(totalItems);
|
||||
},
|
||||
{ isActive: focus }
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + height);
|
||||
|
||||
// Scrollbar calculation
|
||||
const thumbSize = Math.max(1, Math.round((height / Math.max(totalItems, 1)) * height));
|
||||
const thumbPos =
|
||||
maxOffset > 0
|
||||
? Math.round((scrollOffset / maxOffset) * (height - thumbSize))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{visibleItems}
|
||||
</Box>
|
||||
{scrollbar && totalItems > height && (
|
||||
<Box flexDirection="column" width={1}>
|
||||
{Array.from({ length: height }, (_, i) => {
|
||||
const isThumb = i >= thumbPos && i < thumbPos + thumbSize;
|
||||
return (
|
||||
<Text key={i} color={isThumb ? theme.colors.primary : theme.colors.muted}>
|
||||
{isThumb ? scrollbarChar : trackChar}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ScrollArea } from './ScrollArea.js';
|
||||
export type { ScrollAreaProps } from './ScrollArea.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
label?: string;
|
||||
showRange?: boolean;
|
||||
showLatest?: boolean;
|
||||
color?: string;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Sparkline: React.FC<SparklineProps> = ({
|
||||
data,
|
||||
width = 20,
|
||||
height = 1,
|
||||
label,
|
||||
showRange = false,
|
||||
showLatest = true,
|
||||
color,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
if (!data || data.length === 0) {
|
||||
return <Text color={theme.colors.muted}>{label ?? ''} (no data)</Text>;
|
||||
}
|
||||
|
||||
const barColor = color ?? theme.colors.primary;
|
||||
const minVal = Math.min(...data);
|
||||
const maxVal = Math.max(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Downsample or use as-is
|
||||
let points: number[];
|
||||
if (data.length > width) {
|
||||
const step = data.length / width;
|
||||
points = Array.from({ length: width }, (_, i) => {
|
||||
const start = Math.floor(i * step);
|
||||
const end = Math.floor((i + 1) * step);
|
||||
const slice = data.slice(start, end);
|
||||
return slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||
});
|
||||
} else if (data.length < width) {
|
||||
points = [...data, ...Array(width - data.length).fill(data[data.length - 1])];
|
||||
} else {
|
||||
points = data;
|
||||
}
|
||||
|
||||
const chars = points.map((v) => {
|
||||
const normalized = (v - minVal) / range;
|
||||
const idx = Math.min(7, Math.round(normalized * 7));
|
||||
return BLOCKS[idx];
|
||||
});
|
||||
|
||||
const sparkStr = chars.join('');
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={barColor}>{sparkStr}</Text>
|
||||
{showLatest && <Text color={barColor}>{Math.round(data[data.length - 1])}</Text>}
|
||||
{showRange && (
|
||||
<Text color={theme.colors.muted} dimColor>
|
||||
(min: {Math.round(minVal)} max: {Math.round(maxVal)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sparkline } from './Sparkline.js';
|
||||
export type { SparklineProps } from './Sparkline.js';
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface Step {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface StepperProps {
|
||||
steps: Step[];
|
||||
currentStep: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
showNumbers?: boolean;
|
||||
completedSteps?: string[];
|
||||
errorSteps?: string[];
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Stepper: React.FC<StepperProps> = ({
|
||||
steps,
|
||||
currentStep,
|
||||
orientation = 'horizontal',
|
||||
showNumbers = false,
|
||||
completedSteps = [],
|
||||
errorSteps = [],
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const getIndicator = (step: Step, index: number) => {
|
||||
if (errorSteps.includes(step.key)) return { char: '✕', color: theme.colors.error };
|
||||
if (completedSteps.includes(step.key)) return { char: '✓', color: theme.colors.success };
|
||||
if (step.key === currentStep) return { char: '●', color: theme.colors.primary };
|
||||
return { char: '○', color: theme.colors.muted };
|
||||
};
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{steps.map((step, i) => {
|
||||
const { char, color } = getIndicator(step, i);
|
||||
const isCurrent = step.key === currentStep;
|
||||
const connector = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
|
||||
|
||||
return (
|
||||
<Box key={step.key} flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text bold={isCurrent} color={isCurrent ? theme.colors.text : theme.colors.muted}>
|
||||
{step.title}
|
||||
{step.optional && <Text color={theme.colors.muted}> (optional)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
{step.description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.colors.muted}>│ {step.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{i < steps.length - 1 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={connector}>│</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{steps.map((step, i) => {
|
||||
const { char, color } = getIndicator(step, i);
|
||||
const isCurrent = step.key === currentStep;
|
||||
const connectorColor = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text color={isCurrent ? theme.colors.text : theme.colors.muted} bold={isCurrent}>
|
||||
{' '}{step.title}{step.optional ? ' (opt)' : ''}
|
||||
</Text>
|
||||
{i < steps.length - 1 && (
|
||||
<Text color={connectorColor}>{' ── '}</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
{steps.map((step, i) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{step.description && (
|
||||
<Text color={theme.colors.muted}>
|
||||
{' '}{step.description}
|
||||
{i < steps.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Stepper } from './Stepper.js';
|
||||
export type { StepperProps, Step } from './Stepper.js';
|
||||
Generated
+1054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "odin-tui-workflow-prototype",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/main.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "7.1.1",
|
||||
"react": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "4.23.15"
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
exec npm start
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
measureElement,
|
||||
render,
|
||||
useApp,
|
||||
useInput,
|
||||
useWindowSize,
|
||||
} from 'ink';
|
||||
import { Badge } from '../components/ui/badge/index.js';
|
||||
import { Header } from '../components/ui/header/index.js';
|
||||
import { KeyHint } from '../components/ui/key-hint/index.js';
|
||||
import { ProgressBar } from '../components/ui/progress-bar/index.js';
|
||||
import { ScrollArea } from '../components/ui/scroll-area/index.js';
|
||||
import { Sparkline } from '../components/ui/sparkline/index.js';
|
||||
import { Stepper } from '../components/ui/stepper/index.js';
|
||||
import { Gauge } from '../components/ui/gauge/index.js';
|
||||
import { darkTheme, lightTheme } from '../components/ui/_core.js';
|
||||
|
||||
const phases = [
|
||||
{ id: 'plan', key: 'g', title: 'Plan' },
|
||||
{ id: 'run', key: 'l', title: 'Live run' },
|
||||
{ id: 'results', key: 's', title: 'Summary' },
|
||||
{ id: 'history', key: 'h', title: 'History' },
|
||||
{ id: 'advice', key: 'o', title: 'Advice' },
|
||||
];
|
||||
|
||||
const profiles = [
|
||||
{ id: 'quick', label: 'Quick', time: '~4 min', impact: 'Low', note: 'Read-only triage' },
|
||||
{ id: 'standard', label: 'Standard', time: '~14 min', impact: 'Moderate', note: 'All capable domains' },
|
||||
{ id: 'extended', label: 'Extended', time: '~38 min', impact: 'High', note: 'Sustained load; opt in' },
|
||||
];
|
||||
|
||||
const domains = [
|
||||
{ name: 'CPU', state: 'Ready', score: '96.4' },
|
||||
{ name: 'Memory', state: 'Ready', score: '91.2' },
|
||||
{ name: 'GPU', state: 'Unavailable', score: '—', reason: 'No supported compute driver' },
|
||||
{ name: 'Storage', state: 'Ready', score: '94.1' },
|
||||
{ name: 'Browser', state: 'Ready', score: '90.5' },
|
||||
{ name: 'Bash', state: 'Ready', score: '97.6' },
|
||||
{ name: 'Languages', state: 'Ready', score: '93.0' },
|
||||
];
|
||||
|
||||
const variantNames = ['Mission Control', 'Guided Run', 'Run Ledger'];
|
||||
const scoreValues = [96.4, 91.2, 94.1, 90.5, 97.6, 93.0];
|
||||
|
||||
function Card({ title, children, ascii, theme, width, flexGrow = 0 }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle={ascii ? 'classic' : 'round'}
|
||||
borderColor={theme.colors.border}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
flexGrow={flexGrow}
|
||||
flexShrink={width ? 0 : 1}
|
||||
>
|
||||
<Text bold color={theme.colors.secondary}>{title}</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClickRow({ id, register, children, selected = false, theme, ascii }) {
|
||||
return (
|
||||
<Box ref={register(id)}>
|
||||
<Text
|
||||
inverse={selected}
|
||||
color={selected ? theme.colors.textInverse : theme.colors.text}
|
||||
bold={selected}
|
||||
>
|
||||
{selected ? (ascii ? '> ' : '› ') : ' '}{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StageNav({ phase, register, theme, ascii, vertical = false }) {
|
||||
return (
|
||||
<Box flexDirection={vertical ? 'column' : 'row'} gap={vertical ? 0 : 1}>
|
||||
{phases.map((item) => (
|
||||
<Box key={item.id} ref={register(`phase:${item.id}`)}>
|
||||
<Text
|
||||
bold={phase === item.id}
|
||||
color={phase === item.id ? theme.colors.primary : theme.colors.muted}
|
||||
inverse={phase === item.id}
|
||||
>
|
||||
{ascii ? `[${item.key}] ${item.title}` : ` ${item.title} `}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRows({ profile, setProfile, register, theme, ascii, compact = false }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={compact ? 0 : 1}>
|
||||
{profiles.map((item) => (
|
||||
<Box key={item.id} ref={register(`profile:${item.id}`)}>
|
||||
<Text
|
||||
bold={profile === item.id}
|
||||
color={profile === item.id ? theme.colors.primary : theme.colors.text}
|
||||
inverse={profile === item.id}
|
||||
>
|
||||
{profile === item.id ? (ascii ? '> ' : '◆ ') : ' '}
|
||||
{item.label} {item.time}{compact ? '' : ` ${item.impact}`}
|
||||
</Text>
|
||||
{!compact && <Text color={theme.colors.muted}> {item.note}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={theme.colors.muted}>{ascii ? 'p changes profile' : 'p changes profile; arrows scroll domains'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StageContent({
|
||||
phase,
|
||||
profile,
|
||||
theme,
|
||||
ascii,
|
||||
progress,
|
||||
cancelPrompt,
|
||||
cancelQueued,
|
||||
recovered,
|
||||
runStatus,
|
||||
register,
|
||||
}) {
|
||||
if (phase === 'plan') {
|
||||
const selected = profiles.find((item) => item.id === profile) ?? profiles[1];
|
||||
return (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="success" theme={theme}>6 READY</Badge>
|
||||
<Badge variant="warning" theme={theme}>1 UNAVAILABLE</Badge>
|
||||
</Box>
|
||||
<Text>Selected: {selected.label} · {selected.time} · {selected.impact} impact</Text>
|
||||
<Text color={theme.colors.muted}>{selected.note}. No score if coverage stays incomplete.</Text>
|
||||
<Text>GPU unavailable: no supported compute driver detected.</Text>
|
||||
<Text>Workload writes: none in this profile.</Text>
|
||||
<Box ref={register('start')} marginTop={1}>
|
||||
<Text bold color={theme.colors.success} inverse> Enter to begin {selected.label} plan </Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'run') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="info" theme={theme}>RUNNING</Badge>
|
||||
<Text>CPU mixed load · trial 3 of 5 · ~6 min left</Text>
|
||||
</Box>
|
||||
{ascii
|
||||
? <Text>[########------------] {progress}%</Text>
|
||||
: <ProgressBar value={progress} width={28} theme={theme} />}
|
||||
<Text color={theme.colors.warning} bold>
|
||||
{cancelQueued ? 'Cancel queued for the next safe trial boundary.' : 'Timed sample active · display updates pause during measurement.'}
|
||||
</Text>
|
||||
<Text>GPU: skipped · no supported compute driver</Text>
|
||||
<Text>Worker isolated · UI input cannot change workload conditions.</Text>
|
||||
{cancelPrompt ? (
|
||||
<Box borderStyle={ascii ? 'classic' : 'round'} borderColor={theme.colors.warning} paddingX={1} marginTop={1}>
|
||||
<Text bold>Stop after this trial? [y] Confirm [n] Continue</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box ref={register('cancel')}><Text bold color={theme.colors.error} inverse> c Request safe stop </Text></Box>
|
||||
<Box ref={register('recover')}><Text color={theme.colors.secondary}> u Simulate interruption recovery </Text></Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'results') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="warning" theme={theme}>HEADLINE WITHHELD</Badge>
|
||||
<Text>{runStatus === 'cancelled' || runStatus === 'recovered' ? 'Partial coverage · no median' : '6/7 valid · GPU unavailable'}</Text>
|
||||
</Box>
|
||||
<Text bold>{runStatus === 'cancelled' ? 'Cancelled safely · partial record preserved' : runStatus === 'recovered' ? 'Recovered after interruption · partial record preserved' : runStatus === 'complete' ? 'Run complete · capability coverage remains 6/7' : 'Sample run · partial record preserved'}</Text>
|
||||
{runStatus === 'cancelled' || runStatus === 'recovered'
|
||||
? <Text>Partial evidence remains; unrun domains have no measurements.</Text>
|
||||
: <Box flexDirection="row" flexWrap="wrap" gap={1}>
|
||||
{domains.filter((item) => item.score !== '—').map((item) => (
|
||||
<Text key={item.name} color={theme.colors.primary}>{item.name} {item.score}</Text>
|
||||
))}
|
||||
</Box>}
|
||||
<Text>Prior complete run: median 93.2 · reference only</Text>
|
||||
<Text color={theme.colors.warning}>Warning · thermal throttling observed after trial 3</Text>
|
||||
<Text color={theme.colors.muted}>Evidence: temperature rise + clock drop · corroborated</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'history') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>LOCAL RUN HISTORY</Text>
|
||||
<Text>OD-0928 · {profiles.find((item) => item.id === profile)?.label} · {runStatus === 'cancelled' ? 'Cancelled · partial' : runStatus === 'recovered' ? 'Recovered · partial' : 'Complete attempt'} · headline withheld</Text>
|
||||
<Text>OD-0921 · Standard · Complete · 7/7 · median 93.2</Text>
|
||||
<Text color={theme.colors.warning}>Partial run is not comparable; prior complete run remains available.</Text>
|
||||
{recovered && <Text color={theme.colors.warning}>Recovered after interruption · partial evidence retained · cleanup confirmed</Text>}
|
||||
<Text color={theme.colors.muted}>Notes stay separate from original measurements.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.colors.warning}>Finding · WARNING · corroborated</Text>
|
||||
<Text>CPU clocks fell as temperature reached 91°C during sustained load.</Text>
|
||||
<Text>Advice: inspect cooling before repeating extended profile.</Text>
|
||||
<Text color={theme.colors.success}>Expected benefit: steadier sustained throughput.</Text>
|
||||
<Text>Tradeoff: downtime for inspection. Odin changes no settings.</Text>
|
||||
<Text color={theme.colors.muted}>Evidence and uncertainty remain visible beside advice.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { exit } = useApp();
|
||||
const { columns = 80, rows = 24 } = useWindowSize();
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [phase, setPhase] = useState('plan');
|
||||
const [profile, setProfile] = useState('standard');
|
||||
const [themeMode, setThemeMode] = useState('dark');
|
||||
const [ascii, setAscii] = useState(false);
|
||||
const [cancelPrompt, setCancelPrompt] = useState(false);
|
||||
const [cancelQueued, setCancelQueued] = useState(false);
|
||||
const [progress, setProgress] = useState(64);
|
||||
const [recovered, setRecovered] = useState(false);
|
||||
const [runStatus, setRunStatus] = useState('sample');
|
||||
const [mouseLast, setMouseLast] = useState('No click yet');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const hitTargets = useRef(new Map());
|
||||
const handlers = useRef({});
|
||||
const themeBase = themeMode === 'dark' ? darkTheme : lightTheme;
|
||||
const colors = ascii || process.env.NO_COLOR !== undefined
|
||||
? Object.fromEntries(Object.keys(themeBase.colors).map((key) => [key, undefined]))
|
||||
: {
|
||||
...themeBase.colors,
|
||||
primary: themeMode === 'dark' ? 'magenta' : 'blue',
|
||||
secondary: themeMode === 'dark' ? 'cyan' : 'magenta',
|
||||
};
|
||||
const theme = { ...themeBase, colors };
|
||||
const roomy = columns >= 80 && rows >= 19;
|
||||
const register = (id) => (node) => {
|
||||
if (node) hitTargets.current.set(id, node);
|
||||
else hitTargets.current.delete(id);
|
||||
};
|
||||
|
||||
const nextVariant = (delta) => setVariant((prev) => (prev + delta + variantNames.length) % variantNames.length);
|
||||
const nextProfile = () => {
|
||||
const index = profiles.findIndex((item) => item.id === profile);
|
||||
setProfile(profiles[(index + 1) % profiles.length].id);
|
||||
};
|
||||
const startRun = () => {
|
||||
setPhase('run');
|
||||
setProgress(64);
|
||||
setRunStatus('running');
|
||||
setCancelPrompt(false);
|
||||
setCancelQueued(false);
|
||||
};
|
||||
const cancelRun = () => {
|
||||
setCancelPrompt(false);
|
||||
setCancelQueued(false);
|
||||
setRunStatus('cancelled');
|
||||
setPhase('results');
|
||||
};
|
||||
const advanceRun = () => {
|
||||
if (progress >= 96) {
|
||||
setRunStatus('complete');
|
||||
setPhase('results');
|
||||
} else setProgress((value) => Math.min(96, value + 16));
|
||||
};
|
||||
const recoverRun = () => {
|
||||
setRecovered(true);
|
||||
setRunStatus('recovered');
|
||||
setPhase('history');
|
||||
setCancelPrompt(false);
|
||||
};
|
||||
|
||||
handlers.current = {
|
||||
'variant:prev': () => nextVariant(-1),
|
||||
'variant:next': () => nextVariant(1),
|
||||
'theme': () => setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'),
|
||||
'ascii': () => setAscii((value) => !value),
|
||||
'phase:plan': () => setPhase('plan'),
|
||||
'phase:run': () => setPhase('run'),
|
||||
'phase:results': () => setPhase('results'),
|
||||
'phase:history': () => setPhase('history'),
|
||||
'phase:advice': () => setPhase('advice'),
|
||||
'profile:quick': () => setProfile('quick'),
|
||||
'profile:standard': () => setProfile('standard'),
|
||||
'profile:extended': () => setProfile('extended'),
|
||||
'start': startRun,
|
||||
'cancel': () => { setCancelQueued(true); setCancelPrompt(true); },
|
||||
'recover': recoverRun,
|
||||
};
|
||||
|
||||
const clickAt = (column, row) => {
|
||||
for (const [id, node] of hitTargets.current.entries()) {
|
||||
const rect = measureElement(node);
|
||||
if (column >= rect.x && column < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) {
|
||||
handlers.current[id]?.();
|
||||
setMouseLast(id.replace(':', ' '));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
||||
const enable = '\u001b[?1000h\u001b[?1006h';
|
||||
const disable = '\u001b[?1006l\u001b[?1000l';
|
||||
process.stdout.write(enable);
|
||||
let carry = '';
|
||||
const onData = (chunk) => {
|
||||
carry += chunk.toString('utf8');
|
||||
const pattern = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(carry)) !== null) {
|
||||
const button = Number(match[1]) & 3;
|
||||
if (button === 0 && match[4] === 'M') {
|
||||
clickAt(Number(match[2]) - 1, Number(match[3]) - 1);
|
||||
}
|
||||
}
|
||||
carry = carry.slice(-80);
|
||||
};
|
||||
const onExit = () => process.stdout.write(disable);
|
||||
process.stdin.on('data', onData);
|
||||
process.once('exit', onExit);
|
||||
return () => {
|
||||
process.stdin.off('data', onData);
|
||||
process.off('exit', onExit);
|
||||
process.stdout.write(disable);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (cancelPrompt) {
|
||||
if (input.toLowerCase() === 'y' || key.return) cancelRun();
|
||||
else if (input.toLowerCase() === 'n' || key.escape) { setCancelPrompt(false); setCancelQueued(false); }
|
||||
return;
|
||||
}
|
||||
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
|
||||
if (input === '[') { nextVariant(-1); return; }
|
||||
if (input === ']') { nextVariant(1); return; }
|
||||
if (input === 'p') { nextProfile(); return; }
|
||||
if (input === 't') { setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'); return; }
|
||||
if (input === 'a') { setAscii((value) => !value); return; }
|
||||
if (input === '?') { setShowHelp((value) => !value); return; }
|
||||
if (input === 'g' || key.tab && key.shift) { setPhase('plan'); return; }
|
||||
if (input === 'l') { setPhase('run'); return; }
|
||||
if (input === 's') { setPhase('results'); return; }
|
||||
if (input === 'h') { setPhase('history'); return; }
|
||||
if (input === 'o') { setPhase('advice'); return; }
|
||||
if (key.leftArrow) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + phases.length - 1) % phases.length].id); return; }
|
||||
if (key.rightArrow || key.tab) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + 1) % phases.length].id); return; }
|
||||
if (phase === 'run' && input.toLowerCase() === 'c') { setCancelQueued(true); setCancelPrompt(true); return; }
|
||||
if (phase === 'run' && input.toLowerCase() === 'u') { recoverRun(); return; }
|
||||
if (phase === 'plan' && key.return) { startRun(); return; }
|
||||
if (phase === 'run' && key.return) { advanceRun(); return; }
|
||||
if (phase === 'results' && key.return) { setPhase('history'); return; }
|
||||
if (phase === 'history' && key.return) { setPhase('advice'); return; }
|
||||
if (phase === 'advice' && key.return) { setPhase('plan'); return; }
|
||||
});
|
||||
|
||||
const renderHeader = () => ascii
|
||||
? <Box flexDirection="row" gap={1}><Text bold color={theme.colors.primary}>ODIN</Text><Text>by Bongbetic · local benchmark + system health</Text></Box>
|
||||
: <Header title="ODIN · Bongbetic" version="prototype" subtitle="LOCAL BENCHMARK + SYSTEM HEALTH" style="line" theme={theme} />;
|
||||
|
||||
const renderProfiles = (compact = false) => (
|
||||
<ProfileRows profile={profile} register={register} theme={theme} ascii={ascii} compact={compact} />
|
||||
);
|
||||
|
||||
const renderDomains = (height = 5) => (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>COVERAGE · 6/7</Text>
|
||||
{ascii
|
||||
? <ScrollArea height={height} theme={theme} scrollbarChar="#" trackChar="." focus>
|
||||
{domains.map((item) => <Text key={item.name}>{item.name}: {item.state}{item.reason ? ` — ${item.reason}` : ''}</Text>)}
|
||||
</ScrollArea>
|
||||
: <ScrollArea height={height} theme={theme} focus>
|
||||
{domains.map((item) => (
|
||||
<Box key={item.name} flexDirection="row" gap={1}>
|
||||
<Text color={item.state === 'Ready' ? theme.colors.success : theme.colors.warning}>{item.state === 'Ready' ? '●' : '○'}</Text>
|
||||
<Text>{item.name}</Text>
|
||||
<Text color={theme.colors.muted}>{item.state === 'Ready' ? 'ready' : 'unavailable'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>}
|
||||
<Text color={theme.colors.muted}>Arrows scroll · status words work without color</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<StageContent
|
||||
phase={phase}
|
||||
profile={profile}
|
||||
theme={theme}
|
||||
ascii={ascii}
|
||||
progress={progress}
|
||||
cancelPrompt={cancelPrompt}
|
||||
cancelQueued={cancelQueued}
|
||||
recovered={recovered}
|
||||
runStatus={runStatus}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderVariantSwitch = () => (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box ref={register('variant:prev')}><Text color={theme.colors.muted}>[</Text></Box>
|
||||
<Text color={theme.colors.secondary} bold>Layout {String.fromCharCode(65 + variant)} · {variantNames[variant]}</Text>
|
||||
<Box ref={register('variant:next')}><Text color={theme.colors.muted}>]</Text></Box>
|
||||
<Box ref={register('theme')}><Text color={theme.colors.muted}>[t] theme</Text></Box>
|
||||
<Box ref={register('ascii')}><Text color={theme.colors.muted}>[a] ASCII</Text></Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderFooter = () => (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{showHelp ? (
|
||||
<Text color={theme.colors.muted}>[[]/]] layout · [g/l/s/h/o] views · [p] profile · [Enter] advance · [c] cancel · [u] recover · [t] theme · [a] ASCII · [q] quit</Text>
|
||||
) : (
|
||||
<KeyHint theme={theme} keys={[
|
||||
{ key: 'Tab/←→', label: 'View' },
|
||||
{ key: 'p', label: 'Profile' },
|
||||
{ key: 'Enter', label: phase === 'run' ? 'Next trial' : 'Open' },
|
||||
{ key: 'c', label: 'Cancel run' },
|
||||
{ key: '?', label: 'Help' },
|
||||
{ key: 'q', label: 'Quit' },
|
||||
]} />
|
||||
)}
|
||||
<Text color={theme.colors.muted}>Mouse: {process.stdin.isTTY ? 'SGR click mode · ' : 'unavailable · '}{mouseLast} · {columns}×{rows} · {ascii ? 'ASCII + monochrome' : process.env.NO_COLOR !== undefined ? 'Unicode · NO_COLOR' : `${themeMode} color`}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderA = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box marginBottom={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Box flexDirection={roomy ? 'row' : 'column'} gap={1} flexGrow={1}>
|
||||
<Card title="RUN SETUP" ascii={ascii} theme={theme} width={roomy ? Math.min(31, Math.floor(columns * 0.38)) : columns}>
|
||||
{renderProfiles(true)}
|
||||
{renderDomains(roomy ? 5 : 3)}
|
||||
</Card>
|
||||
<Card title={phases.find((item) => item.id === phase)?.title.toUpperCase()} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderB = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{!ascii && <Stepper
|
||||
steps={[{ key: 'plan', title: 'Plan' }, { key: 'run', title: 'Measure' }, { key: 'review', title: 'Review' }]}
|
||||
currentStep={phase === 'plan' ? 'plan' : phase === 'run' ? 'run' : 'review'}
|
||||
completedSteps={phase === 'run' ? ['plan'] : phase === 'results' || phase === 'history' || phase === 'advice' ? ['plan', 'run'] : []}
|
||||
theme={theme}
|
||||
/>}
|
||||
{ascii && <Text>STEP 1 PLAN --> STEP 2 MEASURE --> STEP 3 REVIEW</Text>}
|
||||
<Box marginTop={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Card title={phase === 'plan' ? 'CHOOSE A PROFILE' : phase === 'run' ? 'MEASUREMENT IN PROGRESS' : phase === 'results' ? 'REVIEW RESULT' : phase === 'history' ? 'SAVED RUNS' : 'NEXT ACTION'} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{phase === 'plan' && renderProfiles(false)}
|
||||
{renderContent()}
|
||||
{phase === 'plan' && renderDomains(4)}
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderC = () => {
|
||||
const wideConsole = columns >= 112 && rows >= 22;
|
||||
if (!wideConsole) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} />
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text>LOCAL · NO UPLOAD</Text>
|
||||
<Text color={theme.colors.success}>6 ready / 7</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
</Box>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
{phase === 'results' && <Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>VALID DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 · MEM 91 · GPU N/A · DISK 94 · WEB 90 · BASH 98 · LANG 93</Text>
|
||||
: <Sparkline data={scoreValues} width={Math.min(30, columns - 8)} label="Scores" theme={theme} />}
|
||||
</Box>}
|
||||
</Card>
|
||||
<Text color={theme.colors.muted}>EVENT · GPU unavailable · thermal warning corroborated · worker separate from redraw</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} flexGrow={1}>
|
||||
<Card title="ODIN" ascii={ascii} theme={theme} width={27}>
|
||||
<Text color={theme.colors.muted}>LOCAL · NO UPLOAD</Text>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} vertical />
|
||||
<Text color={theme.colors.secondary}>PROFILE · {profile.toUpperCase()}</Text>
|
||||
<Text>6 ready · 1 unavailable</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
<Text color={theme.colors.muted}>[[] / ]] layouts</Text>
|
||||
</Card>
|
||||
<Box flexDirection="column" flexGrow={1} gap={1}>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>{renderContent()}</Box>
|
||||
{phase === 'results' && <Box flexDirection="column" width={30}>
|
||||
<Text bold>DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 | MEM 91 | GPU N/A | DISK 94 | WEB 90 | BASH 98 | LANG 93</Text>
|
||||
: <><Gauge value={93} width={16} label="Valid domains" unit="%" theme={theme} /><Sparkline data={scoreValues} width={18} label="Scores" theme={theme} /></>}
|
||||
</Box>}
|
||||
</Box>
|
||||
</Card>
|
||||
<Card title="EVENT / EVIDENCE LOG" ascii={ascii} theme={theme}>
|
||||
<Text>14:03 capability scan · GPU unavailable · reason recorded</Text>
|
||||
<Text>14:04 thermal finding · clocks down as temperature rose</Text>
|
||||
<Text color={theme.colors.muted}>Worker activity remains separate from terminal redraws.</Text>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={columns} paddingX={1}>
|
||||
{renderHeader()}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginY={1}>
|
||||
{renderVariantSwitch()}
|
||||
<Text color={theme.colors.muted}>{profile.toUpperCase()} · {profiles.find((item) => item.id === profile)?.time}</Text>
|
||||
</Box>
|
||||
{variant === 0 ? renderA() : variant === 1 ? renderB() : renderC()}
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, { alternateScreen: true, kittyKeyboard: { mode: 'auto' } });
|
||||
Reference in New Issue
Block a user