Author SHA1 Message Date
Codex 20681cd4f1 Research InkUI architecture and Linux delivery options 2026-09-25 23:30:37 +05:30
26 changed files with 141 additions and 2481 deletions
+141
View File
@@ -0,0 +1,141 @@
# Establish InkUI architecture and Linux delivery options
Research for [Establish InkUI architecture and Linux delivery options](https://git.bongbetic.com/xavierk/odin/issues/6), 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 report records evidence and conditional recommendations, not an architecture decision. No dependencies were installed, application code built, or benchmarks run. InkUI is a user requirement throughout.
## Decision summary
The strongest initial candidate is a TypeScript/React terminal application using **copied InkUI components**, a maintained Node runtime, and separately supervised workload processes. Node 24 provides a maintained baseline today. Ink 7.1.1 offers useful mouse-layout and terminal-lifecycle primitives, but moving InkUI beyond its declared Ink 6 range requires a focused compatibility proof. Staying on Ink 6.8.0 avoids that version change while leaving more application work around positioning and terminal ownership.
Bun is a credible alternative delivery/runtime candidate because it publishes glibc and musl binaries for both requested architectures and supports compiled executables. Its documented Node compatibility differences mean it needs an explicit subprocess, terminal, and asset-loading qualification before adoption. Neither runtime choice makes every benchmark available on every machine. **Capability coverage**, operating-system support, and comparable **performance measurements** need separate contracts.
## 1. Exact InkUI identity and maturity
The requested project is **kamlesh723/InkUI**, not the separate `vadimdemedes/ink-ui` library. Its installer is `@inkui-cli/inkui`; npm reports **0.5.0**, published May 4, 2026. GitHub’s latest release is still **v0.4.0**, published April 12. The inspected main commit is `e3110d89b3f0933bcb297a6af33318124c889f36`, also May 4. Pin the actual source commit and installer version rather than treating those release signals as interchangeable. [1][2]
InkUI copies `.tsx` source and shared theme code into the consuming project. Individual component packages also exist. The source-copy route fits adapting mouse handling, accessible output, and Bongbetic themes, but transfers responsibility for reviewing upstream fixes and maintaining local changes. Its MIT notice must accompany copied/substantial source. Upstream documentation specifies Node ≥20, React `^19.0.0`, and Ink `^6.0.0`; its CI covers Ubuntu with Node 20, not a Linux/libc/terminal matrix. These facts establish an early component project with useful source, not broad deployment certification. [1][3]
Verified component capabilities:
- **Themes:** semantic color tokens, dark/light defaults, custom colors, and border styles. A global theme context is application code in the guide, not automatic global behavior.
- **Keyboard:** input components use Ink input handlers; hooks provide indexed focus cycling and key bindings. Odin still owns modal focus, consistent shortcuts, and cancellation.
- **Graphs:** Sparkline uses Unicode blocks, averages samples when downsampling, and can display latest/min/max values. Its declared `height` parameter is unused in the inspected implementation. Gauge supports bar, ring, and arc forms with thresholds. These are small displays, not a complete plotting/interaction system.
- **Fallback:** ASCII borders exist, but graph glyphs remain Unicode. Terminal sizing tracks resize events and defaults to 80×24.
- **Mouse/accessibility:** inspection found no terminal mouse implementation or ARIA annotations in InkUI components; mouse references in the repository belong to its website. The README’s accessibility description is not evidence of complete accessible interactions. [4]
## 2. Runtime choices and support floors
| Candidate | Evidence and fit | Decision condition |
| --- | --- | --- |
| Node 24 + React 19 + Ink 6.8.0 | Ink 6.8 requires Node ≥20 and React ≥19; it satisfies InkUI’s declared major range. Node 24 remains supported through April 2028. | Prefer if preserving the declared InkUI range matters most and the interaction proof can supply the missing mouse/terminal behavior cleanly. |
| Node 24 + React ≥19.2 + Ink 7.1.1 | The released Ink 7.1.1 manifest requires Node ≥22 and React ≥19.2. It adds public element coordinates and terminal controls useful to Odin. | Prefer if copied InkUI components pass adaptation tests; do not force installation past peer constraints and call that compatibility. |
| Node 26 | Scheduled Current release today, with LTS due October 28, 2026. Official Linux builds also require `libatomic`, unlike the Node 24 build documentation. | Consider as a forward-compatibility lane or release baseline after qualification; “newest” alone is insufficient. |
| Bun 1.4.2 | Latest inspected release; official x64/arm64 glibc/musl artifacts and executable compilation. Node `tty` is documented as implemented, with behavioral differences; `child_process` and `process` remain partially compatible. | Candidate only after the same terminal, process-tree cancellation, signals, and dependency-asset tests as Node. No InkUI-on-Bun certification was established here. |
Node 20 reached its scheduled EOL on April 30, 2026; Node 22 is maintained until April 2027, while Node 25 is already EOL. React’s current registry version is 19.3.0, but an actual runtime/renderer pair must be pinned together. The released Ink 7.1.1 manifest differs from its evolving main branch, so use release-tag evidence for implementation. [5][6][7]
**Node 24’s official Linux baseline** is kernel ≥4.18, glibc ≥2.28, and libstdc++ providing `GLIBCXX_3.4.25`, for x64 and arm64 Tier 1 platforms. Older kernels may run but are not the documented official-binary baseline. Upstream also excludes vendor-EOL operating systems. x64 musl appears as Experimental; arm64 musl is absent from that platform table. Unofficial musl builds exist for both architectures but explicitly disclaim rigorous testing and guarantees. [8]
Void supplies downstream Node builds: its inspected package recipe is Node 24.18.0 with distribution library dependencies and cross-build handling. That is a viable source of a musl runtime, not proof that an arbitrary downloaded Node binary works on musl. Void itself supports glibc and musl variants. The handbook specifically states that proprietary NVIDIA drivers do not support musl; Odin must explain that limitation rather than attempting to hide it with packaging. [9]
Bun’s current documentation specifies glibc ≥2.17, x64 SSE4.2/Nehalem or newer, official musl alternatives, and both requested architectures. It recommends kernel ≥5.6 while claiming operation down to 3.10 with degraded newer syscalls. Current x64 documentation says one binary selects AVX paths at runtime; old advice about choosing separate baseline/modern builds is stale. The inspected installation page does not establish a precise musl version floor. Certify the chosen release and artifact; do not interpret a runtime boot floor as Odin’s complete feature floor. [7]
## 3. Mouse, terminal behavior, and accessibility
**Mouse is the main UI feasibility gate.** Xterm defines reporting modes and SGR mouse encoding; enabling reporting is only the transport. Odin also needs event decoding, click/wheel semantics, clipping, focus transfer, hit testing, and restoration of modes. A small established input adapter is worth assessing before custom parsing, but this investigation does not endorse an unexamined package. InkUI remains the component source. [10]
Ink 6’s public measurement API returns only dimensions. Ink 7.1.1 returns `x`, `y`, width, and height after layout. Its documentation explicitly warns that these coordinates are relative to the live layout, not the terminal viewport. Static output above the live region, scroll offsets, borders, and resizing must be accounted for before comparing mouse coordinates. Neither inspected Ink API supplies a complete mouse interaction system. The proof should cover clicking a scrolled table row, scrolling inside a panel, overlapping dialogs, resizing, and keyboard parity. [5][11]
Ink 7.1.1 documents alternate-screen ownership, noninteractive output, render-rate control, terminal suspension, and screen-reader mode. Noninteractive rendering emits only the final non-static frame at unmount, which is not a sufficient progress/reporting policy by itself. Ink’s basic screen-reader mode supports a subset of ARIA and `INK_SCREEN_READER=true`; Odin must add meaningful labels, numeric graph alternatives, state descriptions, and restrained updates. Release-tag documentation says raw-mode setters can throw when unsupported; indexed main-branch documentation described newer behavior. Do not mix these contracts. [11]
Recommended terminal contract for the subsequent UX decision:
- Full interactive layout in certified terminals; keyboard access to every action; mouse where reporting is available. Mouse absence should not make a run unusable.
- An explicit plain mode for redirected output, unavailable raw input, `TERM=dumb`, and accessibility preferences. Keep readable progress, result summaries, and noninteractive arguments for Bash automation. Bash support means a normal executable and stable exit behavior; orchestration should not depend on users’ shell aliases or startup files.
- Independent controls for color, Unicode decoration, animation, and interactive layout. `NO_COLOR` concerns ANSI color and explicitly does not disable bold/underline; it is not an ASCII or screen-reader switch. Graph numbers and health states must remain understandable without color. [12]
- No unsupported claim that `$TERM` alone proves capability. Qualify an xterm-compatible/VTE terminal, a modern enhanced-protocol terminal, SSH with a PTY, tmux with mouse on/off, Linux console, and pipes. tmux mediates events and terminal features; its configuration and remote terminfo are part of the compatibility environment. [13]
- On normal exit, cancellation, exceptions, SIGTERM, and terminal handoff: restore raw mode, mouse reporting, cursor, and screen state. SIGKILL cannot execute application cleanup; qualify recovery behavior and a readable rerun, without promising impossible restoration guarantees.
## 4. Distribution and offline execution
Treat **xbps, deb, and rpm as delivery formats**, not interchangeable dependency namespaces or compatibility guarantees. A native payload still carries architecture, libc, shared-library, and CPU constraints. Debian’s policies distinguish absolute dependencies from recommendations and require generated shared-library dependencies; RPM records requirements and package signatures; Void separates architecture/libc repositories and requires signed remote repositories. [14]
Three delivery options merit a final choice:
| Delivery | Advantage | Ownership cost / qualification |
| --- | --- | --- |
| Native packages using a maintained distro Node | Distro security updates, conventional dependency ownership, natural Void musl integration. | Supported distro releases must provide the selected runtime range; package names and versions vary. JS-only files can be architecture-independent, but included runtime/helper binaries cannot. |
| Portable archive containing pinned Node plus built JS/assets | Predictable runtime without npm on the target; simple inspection and Bash launcher. | Separate x64/arm64 and glibc/musl artifacts; Bongbetic owns runtime security updates, signatures, licenses, and musl build provenance. A bundled runtime still has system-library requirements. |
| Bun compiled executable | Documented targets cover the four architecture/libc combinations and can embed assets. | A separate binary per target still needs qualification. Current docs enable `.env`/`bunfig.toml` autoloading by default; deterministic execution should disable or explicitly control that behavior. Subprocess compatibility remains a gate. |
Node 24’s single-executable feature is in active development and embeds a CommonJS script; ESM dependencies, bundled assets, and cross-platform code-cache restrictions add work. It should not be the default merely to produce one file. A portable directory can be a complete product. [15]
Recommend separating acquisition from a **benchmark run**. Core UI, manifests, fixtures, and chosen baseline workloads should run offline once prepared. Optional tools, browser engines, language toolchains, and large datasets need declared versions, origins, digests, licenses, sizes, and cache locations. Distribution packages or a verified offline bundle can supply them. Installation should be an explicit preparation action; a timed measurement must not fetch “latest,” upgrade drivers, or change kernel settings.
When a dependency is unavailable, record a precise capability-coverage reason. Do not replace a workload silently and reuse its score identity. Decide later whether the default installer acquires the full standard run profile or a smaller core with optional packs. Keep package-manager scripts and network access out of privileged measurement operations.
## 5. Measurement and privilege boundaries
A UI runtime does not need to be the workload implementation language. Node’s asynchronous child-process API can orchestrate existing executables without a shell, so the evidence does not require a second application language now. A native helper becomes justified if a selected workload needs precise memory access, direct kernel APIs, or a small enforceable watchdog/privilege boundary that existing tools do not provide. That choice belongs after workload requirements are known. [16]
Proposed ownership boundaries:
1. An unprivileged InkUI process owns navigation, branding, readable progress, and reports.
2. A runner owns the run manifest, executable identity, timing, output limits, process lifecycle, and persisted measurements. Use explicit arguments and controlled environment variables, not shell command construction.
3. Privileged operations, when required, receive narrowly validated operation/target requests. Do not elevate the complete React/Bun/Node application or expose a generic root command executor. Validate device identity, paths, ownership, and temporary-file boundaries; retain original-user ownership of local results. Read-only diagnostics and storage writes require distinct authorization/safety rules.
UI graphs, animation timers, garbage collection, logging, and synchronous parsing can perturb CPU, memory, and I/O measurements. Separate processes prevent event-loop coupling but still share hardware. Recommend pausing decorative updates during timed windows, buffering bounded observations, and reporting afterward. If live charts remain necessary, cap their sampling/render rate and quantify UI-on versus quiet-mode overhead on low-end hardware. Do not silently reserve a core or change affinity/governors: these alter the benchmark’s execution conditions. Record any chosen isolation policy in the run manifest.
Cancellation is more than calling `child.kill()`: Node documents that killing a parent does not kill Linux descendants. A runner needs process-group ownership, staged termination, exit collection, and orphan handling after UI failure. cgroup v2 can provide stronger group limits and termination, but controller availability and delegation permissions must be probed. Kernel documentation states that controller exposure depends on configuration and hierarchy ownership; `cgroup.kill` and memory limits cannot be presumed available on every system. No systemd dependency is necessary for the core model. [16][17]
For dangerous pressure profiles, refuse execution when required containment or the independent watchdog cannot be established; explain the unavailable capability. A plain performance run can use a simpler supervision path if its workload contract allows it. Discover actual kernel interfaces such as PSI files rather than assuming them from version numbers; record missing features without inventing zero pressure. [17][18]
## 6. Proposed certification matrix
These are future release gates, not completed tests. Use exact image/artifact digests and record kernel, architecture, libc, runtime, terminal, and relevant permissions.
| Lane | Suggested coverage | What it establishes |
| --- | --- | --- |
| Native packages | Void glibc + musl; maintained Debian/Ubuntu; maintained Fedora and enterprise RPM family. | Install, upgrade/remove, dependency declarations, offline start, paths/ownership, normal-user behavior. |
| Architecture/libc | x86_64/glibc, x86_64/musl, aarch64/glibc, aarch64/musl. At least Void and Alpine as distinct musl environments if both are claimed. | Loader/runtime and transitive-asset compatibility. Native ARM hardware is required before treating emulation success as ARM performance evidence. |
| Kernel/permissions | Oldest promised supported ABI; supported LTS and current distro kernels; runit and systemd; cgroup v2 delegated/denied/absent; restricted proc/sys access. | Capability probing, fallback, supervision, explicit refusal where safe containment is unavailable. |
| Terminal/input | Local VTE/xterm-compatible terminal, a modern terminal, SSH PTY, tmux, Linux console, 80×24 and narrow resize, Unicode/ASCII, no color, screen reader, piped output. | Input, readable numeric alternatives, output contract, cleanup, no raw-mode crash. |
| Failures | Cancel each workload phase, terminate UI/runner, missing tool, permission denial, low disk space, invalid persisted result. | Process/resource cleanup, bounded logs, durable result state, useful failure explanations. |
| Physical measurement | Intel/AMD x86_64 and ARM; AMD/Intel/NVIDIA graphics where supported; NVMe/SATA/HDD; laptops with thermal/power transitions. | Device visibility, real drivers, SMART interpretation paths, thermal effects, representative performance and UI-overhead measurement. |
VMs are suitable for package/ABI/terminal/failure-path qualification and measuring the VM itself. Virtual storage, virtual GPUs, host scheduling, and hidden hardware telemetry cannot certify physical drive replacement advice, native GPU performance, or host memory health. Passthrough creates a separate recorded environment, not an exemption from those distinctions. Real faults should be covered with parser fixtures and controlled validation; do not deliberately damage hardware to prove a health rule.
## 7. Decisions still required
1. Node 24 versus another maintained runtime; Ink 6 compatibility versus adapting copied components to Ink 7; exact version pin and upgrade policy.
2. Native-package dependencies, portable runtime archives, or both; who maintains and certifies musl builds; minimum OS/kernel/libc/CPU contract.
3. A mouse/focus/terminal-lifecycle prototype retaining InkUI, plus the accessible/plain output acceptance criteria.
4. Core versus optional offline workload packs, licensing and size limits, and dependency acquisition policy.
5. Per-workload privilege/containment requirements, behavior after UI death, and the quantitative UI-overhead acceptance threshold.
6. Exact release certification matrix and which capabilities can be certified only on physical hardware.
Evidence limits: no compatibility or performance claim was established by execution; no universal Node/Bun/InkUI bundle was produced; installed Void package availability on every target was not checked; Bun’s precise musl floor and InkUI-on-Ink-7/Bun behavior remain unverified. Browser/driver/workload selection and scoring are separate investigations.
## Sources and method
All sources below were inspected on **2026-09-25**. Context7 library resolution preceded documentation queries for Node, Ink, Bun, XBPS/Void, Debian Policy, RPM, and tmux. Two earlier exact-InkUI searches returned unrelated libraries and were rejected; the requested repository was inspected directly. Context7 results tracking `master` were checked against released source where version behavior mattered. No quota error occurred.
1. [InkUI README at inspected commit](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/README.md), [installer manifest](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/apps/cli/package.json), [npm registry](https://registry.npmjs.org/@inkui-cli%2Finkui).
2. [InkUI v0.4.0 release](https://github.com/kamlesh723/InkUI/releases/tag/v0.4.0), [inspected commit](https://github.com/kamlesh723/InkUI/commit/e3110d89b3f0933bcb297a6af33318124c889f36).
3. [InkUI MIT license](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/LICENSE), [installation guide](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/apps/docs/content/getting-started/installation.mdx), [CI](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/.github/workflows/ci.yml).
4. InkUI inspected source: [themes](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/packages/core/src/theme.ts), [hooks](https://github.com/kamlesh723/InkUI/tree/e3110d89b3f0933bcb297a6af33318124c889f36/packages/hooks/src), [Sparkline](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/packages/sparkline/src/Sparkline.tsx), [Gauge](https://github.com/kamlesh723/InkUI/blob/e3110d89b3f0933bcb297a6af33318124c889f36/packages/gauge/src/Gauge.tsx).
5. [Ink 6.8.0 README](https://github.com/vadimdemedes/ink/blob/v6.8.0/readme.md), [Ink 7.1.1 manifest](https://github.com/vadimdemedes/ink/blob/v7.1.1/package.json), [Ink npm metadata](https://registry.npmjs.org/ink), [React registry](https://registry.npmjs.org/react/latest).
6. [Node official release schedule](https://github.com/nodejs/Release/blob/main/schedule.json), [Node 26 platform/build document](https://github.com/nodejs/node/blob/v26.x/BUILDING.md).
7. [Bun 1.4.2 release](https://github.com/oven-sh/bun/releases/tag/bun-v1.4.2), [installation/platform requirements](https://bun.com/docs/installation), [Node API compatibility](https://bun.com/docs/runtime/nodejs-compat).
8. [Node 24 supported platforms and official binary requirements](https://github.com/nodejs/node/blob/v24.x/BUILDING.md), [unofficial-builds limitations and targets](https://github.com/nodejs/unofficial-builds/blob/main/README.md).
9. [Void Node package recipe](https://github.com/void-linux/void-packages/blob/master/srcpkgs/nodejs/template), [Void musl support and incompatible software](https://docs.voidlinux.org/installation/musl.html).
10. [Xterm control sequences: mouse tracking and SGR encoding](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking).
11. [Ink 7.1.1 released README](https://github.com/vadimdemedes/ink/blob/v7.1.1/readme.md), [released measurement implementation](https://github.com/vadimdemedes/ink/blob/v7.1.1/src/measure-element.ts).
12. [NO_COLOR informal standard and FAQ](https://no-color.org/).
13. [tmux getting started](https://github.com/tmux/tmux/wiki/Getting-Started), [modifier keys and terminfo](https://github.com/tmux/tmux/wiki/Modifier-Keys), [tmux manual source](https://github.com/tmux/tmux/blob/master/tmux.1).
14. [Void repositories](https://docs.voidlinux.org/xbps/repositories/index.html), [Void signing](https://docs.voidlinux.org/xbps/repositories/signing.html), [Debian package relationships](https://www.debian.org/doc/debian-policy/ch-relationships.html), [Debian shared-library dependencies](https://www.debian.org/doc/debian-policy/ch-sharedlibs.html), [RPM dependency tags](https://github.com/rpm-software-management/rpm/blob/master/docs/manual/tags.md), [RPM package signatures](https://github.com/rpm-software-management/rpm/blob/master/docs/manual/format_v4.md).
15. [Bun executable targets, embedding, and autoload controls](https://bun.com/docs/bundler/executables), [Node 24 single-executable applications](https://github.com/nodejs/node/blob/v24.x/doc/api/single-executable-applications.md).
16. [Node child-process API: spawn, detached processes, signals, and descendant caveat](https://nodejs.org/docs/latest-v24.x/api/child_process.html).
17. [Linux cgroup v2: delegation, controllers, memory limits, and cgroup.kill](https://docs.kernel.org/admin-guide/cgroup-v2.html).
18. [Linux pressure stall information](https://docs.kernel.org/accounting/psi.html).
-1
View File
@@ -1 +0,0 @@
node_modules/
-9
View File
@@ -1,9 +0,0 @@
# Odin TUI workflow prototype
Disposable InkUI prototype for choosing between three structurally different benchmark workflows. All measurements and findings are sample data; no benchmark runs or system changes occur.
Run with `./run.sh` from this directory. On first run, the script installs the pinned local dependencies.
Controls: `[` / `]` change layout; `p` changes profile; `Enter` moves through the run; `c` requests cancellation; `g` plan, `l` live run, `s` summary, `h` history, `o` advice; `t` theme; `a` ASCII fallback; `q` quit.
Mouse support is experimental. Click visible action rows in terminals that send SGR mouse reports. Resize terminal while running to inspect reflow. If your shell sets `NO_COLOR`, use `env -u NO_COLOR ./run.sh` to inspect the color theme.
-110
View File
@@ -1,110 +0,0 @@
// Auto-generated by sync-registry.ts
// Inlines @inkui-cli/core theme and tokens
export const spacing = {
none: 0,
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
} as const;
export const borderStyles = {
single: {
topLeft: '┌', top: '─', topRight: '┐',
left: '│', right: '│',
bottomLeft: '└', bottom: '─', bottomRight: '┘',
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
},
double: {
topLeft: '╔', top: '═', topRight: '╗',
left: '║', right: '║',
bottomLeft: '╚', bottom: '═', bottomRight: '╝',
topT: '╦', bottomT: '╩', leftT: '╠', rightT: '╣', cross: '╬',
},
rounded: {
topLeft: '╭', top: '─', topRight: '╮',
left: '│', right: '│',
bottomLeft: '╰', bottom: '─', bottomRight: '╯',
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
},
bold: {
topLeft: '┏', top: '━', topRight: '┓',
left: '┃', right: '┃',
bottomLeft: '┗', bottom: '━', bottomRight: '┛',
topT: '┳', bottomT: '┻', leftT: '┣', rightT: '┫', cross: '╋',
},
ascii: {
topLeft: '+', top: '-', topRight: '+',
left: '|', right: '|',
bottomLeft: '+', bottom: '-', bottomRight: '+',
topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+',
},
} as const;
export type BorderStyle = keyof typeof borderStyles;
export const spinnerFrames = {
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
line: ['−', '\\', '|', '/'],
arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
bounce: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
} as const;
export type SpinnerType = keyof typeof spinnerFrames;
export interface InkUITheme {
colors: {
primary: string;
secondary: string;
success: string;
warning: string;
error: string;
info: string;
muted: string;
text: string;
textInverse: string;
border: string;
focus: string;
selection: string;
};
border: 'single' | 'double' | 'rounded' | 'bold' | 'ascii';
}
export const darkTheme: InkUITheme = {
colors: {
primary: 'cyan',
secondary: 'magenta',
success: 'green',
warning: 'yellow',
error: 'red',
info: 'blue',
muted: 'gray',
text: 'white',
textInverse: 'black',
border: 'gray',
focus: 'cyan',
selection: 'blue',
},
border: 'single',
};
export const lightTheme: InkUITheme = {
colors: {
primary: 'blue',
secondary: 'magenta',
success: 'green',
warning: 'yellow',
error: 'red',
info: 'cyan',
muted: 'gray',
text: 'black',
textInverse: 'white',
border: 'gray',
focus: 'blue',
selection: 'cyan',
},
border: 'rounded',
};
@@ -1,38 +0,0 @@
import React from 'react';
import { Text, Box } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info';
export interface BadgeProps {
/** Badge label text */
children: string;
/** Visual style — maps to theme colors */
variant?: BadgeVariant;
/** Theme override — defaults to darkTheme */
theme?: InkUITheme;
}
function variantColor(variant: BadgeVariant, theme: InkUITheme): string {
switch (variant) {
case 'success': return theme.colors.success;
case 'warning': return theme.colors.warning;
case 'error': return theme.colors.error;
case 'info': return theme.colors.info;
default: return theme.colors.muted;
}
}
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
theme = darkTheme,
}) => {
const color = variantColor(variant, theme);
return (
<Box>
<Text color={color} bold inverse>{` ${children} `}</Text>
</Box>
);
};
@@ -1,2 +0,0 @@
export type { BadgeProps, BadgeVariant } from './Badge.js';
export { Badge } from './Badge.js';
@@ -1,92 +0,0 @@
import React from 'react';
import { Box, Text } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export interface GaugeProps {
value: number;
min?: number;
max?: number;
label?: string;
unit?: string;
variant?: 'arc' | 'bar' | 'ring';
colorStops?: [number, string][];
width?: number;
showValue?: boolean;
theme?: InkUITheme;
}
export const Gauge: React.FC<GaugeProps> = ({
value,
min = 0,
max = 100,
label,
unit = '%',
variant = 'bar',
colorStops,
width = 20,
showValue = true,
theme = darkTheme,
}) => {
const pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
const defaultStops: [number, string][] = [
[0, theme.colors.success],
[60, theme.colors.warning],
[80, theme.colors.error],
];
const stops = colorStops ?? defaultStops;
let color = stops[0][1];
for (const [threshold, c] of stops) {
if (pct >= threshold) color = c;
}
const displayValue = `${Math.round(value)}${unit}`;
if (variant === 'bar') {
const filled = Math.round((pct / 100) * width);
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
return (
<Box flexDirection="row" gap={1}>
{label && <Text color={theme.colors.muted}>{label}</Text>}
<Text color={color}>{bar}</Text>
{showValue && <Text color={color}>{displayValue}</Text>}
</Box>
);
}
if (variant === 'ring') {
const ringWidth = Math.floor(width / 2);
const topFilled = Math.round((pct / 100) * ringWidth);
const topBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
const botBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
return (
<Box flexDirection="column" alignItems="center">
<Text color={color}>{'◜'}{topBar}{'◝'}</Text>
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
<Text color={theme.colors.muted}>{'█'} <Text color={color}>{displayValue.padStart(ringWidth - 1)}</Text>{' █'}</Text>
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
<Text color={color}>{'◟'}{botBar}{'◞'}</Text>
{label && <Text color={theme.colors.muted} dimColor>{label}</Text>}
</Box>
);
}
// arc variant
const arcWidth = Math.max(10, width);
const filled = Math.round((pct / 100) * arcWidth);
const arcBar = '█'.repeat(filled) + '░'.repeat(arcWidth - filled);
return (
<Box flexDirection="column" alignItems="center">
<Text color={theme.colors.border}>{' ╭' + '─'.repeat(arcWidth) + '╮'}</Text>
<Text color={theme.colors.border}>{' ╱'}{' '.repeat(arcWidth + 2)}{'╲'}</Text>
<Text color={theme.colors.border}>{'╱ '}<Text color={color}>{arcBar}</Text>{' ╲'}</Text>
<Text color={theme.colors.border}>{'│'}{' '.repeat(Math.floor(arcWidth / 2))}<Text color={color}>{displayValue}</Text>{' '.repeat(Math.ceil(arcWidth / 2))}{'│'}</Text>
<Text color={theme.colors.border}>{'╲'}{' '.repeat(arcWidth + 2)}{'╱'}</Text>
<Text color={theme.colors.border}>{' ╰' + '─'.repeat(arcWidth) + '╯'}</Text>
{label && <Text color={theme.colors.muted} dimColor>{' '}{label}</Text>}
</Box>
);
};
@@ -1,2 +0,0 @@
export { Gauge } from './Gauge.js';
export type { GaugeProps } from './Gauge.js';
@@ -1,78 +0,0 @@
import React from 'react';
import { Text, Box, useStdout } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export type HeaderStyle = 'box' | 'line' | 'filled';
export interface HeaderProps {
title: string;
version?: string;
subtitle?: string;
style?: HeaderStyle;
align?: 'left' | 'center';
theme?: InkUITheme;
}
export const Header: React.FC<HeaderProps> = ({
title,
version,
subtitle,
style = 'box',
align = 'left',
theme = darkTheme,
}) => {
const { stdout } = useStdout();
const w = stdout?.columns ?? 80;
const fullTitle = version ? `${title} v${version}` : title;
if (style === 'box') {
// ┌─── MyApp v1.0 ──────────────────┐
const inner = w - 2; // exclude ┌ and ┐
const label = ` ${fullTitle} `;
const left = 3; // ─── before label
const right = Math.max(0, inner - left - label.length);
const top = '┌' + '─'.repeat(left) + label + '─'.repeat(right) + '┐';
const bot = '└' + '─'.repeat(inner) + '┘';
return (
<Box flexDirection="column">
<Text color={theme.colors.primary}>{top}</Text>
{subtitle && (
<Text color={theme.colors.primary}>
{'│'} <Text color={theme.colors.muted}>{subtitle.padEnd(inner - 2)}</Text> {'│'}
</Text>
)}
<Text color={theme.colors.primary}>{bot}</Text>
</Box>
);
}
if (style === 'line') {
// ══ MyApp v1.0 ════════════════════
const label = ` ${fullTitle} `;
const pre = '══';
const remaining = Math.max(0, w - pre.length - label.length);
const line = pre + label + '═'.repeat(remaining);
return (
<Box flexDirection="column">
<Text color={theme.colors.primary}>{line}</Text>
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
</Box>
);
}
// filled: ███ MyApp v1.0 ████████████
const label = ` ${fullTitle} `;
const pre = '██ ';
const remaining = Math.max(0, w - pre.length - label.length);
const line = pre + label + '█'.repeat(remaining);
return (
<Box flexDirection="column">
<Text color={theme.colors.primary}>{line}</Text>
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
</Box>
);
};
@@ -1,2 +0,0 @@
export type { HeaderProps, HeaderStyle } from './Header.js';
export { Header } from './Header.js';
@@ -1,30 +0,0 @@
import React from 'react';
import { Text, Box } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export interface KeyHintItem {
/** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */
key: string;
/** Description label, e.g. "Select", "Navigate", "Toggle" */
label: string;
}
export interface KeyHintProps {
keys: KeyHintItem[];
theme?: InkUITheme;
}
export const KeyHint: React.FC<KeyHintProps> = ({
keys,
theme = darkTheme,
}) => (
<Box gap={2}>
{keys.map(({ key, label }) => (
<Box key={key} gap={1}>
<Text bold dimColor>[{key}]</Text>
<Text color={theme.colors.muted}>{label}</Text>
</Box>
))}
</Box>
);
@@ -1,2 +0,0 @@
export type { KeyHintProps, KeyHintItem } from './KeyHint.js';
export { KeyHint } from './KeyHint.js';
@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useStdout } from 'ink';
import stringWidth from 'string-width';
import { borderStyles, darkTheme } from '../_core.js';
import type { BorderStyle, InkUITheme } from '../_core.js';
export interface SplitPaneProps {
/** Split direction */
direction?: 'horizontal' | 'vertical';
/** Sizes as flex ratios, e.g. [1, 2] for 33%/67% */
sizes?: number[];
/** Gap between panes (columns/rows) */
gap?: number;
theme?: InkUITheme;
children: React.ReactNode;
}
export function SplitPane({
direction = 'horizontal',
sizes,
gap = 1,
children,
}: SplitPaneProps) {
const childArray = React.Children.toArray(children);
return (
<Box
flexDirection={direction === 'horizontal' ? 'row' : 'column'}
gap={gap}
>
{childArray.map((child, i) => {
const flex = sizes ? sizes[i] ?? 1 : 1;
return (
<Box key={i} flexGrow={flex} flexShrink={1} flexBasis={0}>
{child}
</Box>
);
})}
</Box>
);
}
// Map our BorderStyle names → Ink/cli-boxes names
// ('rounded' → 'round', 'ascii' → 'classic', others match directly)
const INK_BORDER_MAP: Record<BorderStyle, string> = {
single: 'single',
double: 'double',
rounded: 'round',
bold: 'bold',
ascii: 'classic',
};
export interface PanelProps {
title?: string;
children?: React.ReactNode;
borderStyle?: BorderStyle;
/** Fixed column width (includes borders). 'auto' = fill terminal width. */
width?: number | 'auto';
/** Inner horizontal padding on each side, default 1 */
padding?: number;
borderColor?: string;
titleColor?: string;
theme?: InkUITheme;
}
function repeat(char: string, n: number): string {
return n <= 0 ? '' : char.repeat(n);
}
export function Panel({
title,
children,
borderStyle = 'rounded',
width = 'auto',
padding = 1,
borderColor,
titleColor: _titleColor,
theme = darkTheme,
}: PanelProps) {
const { stdout } = useStdout();
// Track terminal width for auto-sizing
const [termCols, setTermCols] = useState(stdout?.columns ?? 80);
useEffect(() => {
if (!stdout) return;
const handler = () => setTermCols(stdout.columns ?? 80);
stdout.on('resize', handler);
return () => { stdout.off('resize', handler); };
}, [stdout]);
const chars = borderStyles[borderStyle];
const bc = borderColor ?? theme.colors.border;
// Total panel width (includes the 2 border chars on each side)
const totalWidth: number = width === 'auto' ? termCols : (width as number);
// Inner width = space between left and right border chars
const innerWidth = Math.max(4, totalWidth - 2);
// ── Build the custom top border line with optional embedded title ──
let topLine: string;
if (title) {
// Max visible title length: innerWidth - "─ " - " ─" = innerWidth - 4
const maxTitleLen = Math.max(0, innerWidth - 4);
const raw = stringWidth(title) > maxTitleLen
? title.slice(0, Math.max(0, maxTitleLen - 1)) + '…'
: title;
// titleCell = " Title "
const titleCell = ' ' + raw + ' ';
const leading = 1; // one '─' before the title
const remaining = innerWidth - leading - stringWidth(titleCell);
topLine =
chars.topLeft +
repeat(chars.top, leading) +
titleCell +
repeat(chars.top, Math.max(0, remaining)) +
chars.topRight;
} else {
topLine =
chars.topLeft +
repeat(chars.top, innerWidth) +
chars.topRight;
}
return (
<Box flexDirection="column" width={totalWidth}>
{/* Custom top border with optional title */}
<Text color={bc}>{topLine}</Text>
{/*
Native Ink Box with borderStyle for left, right, bottom.
borderTop={false} removes the top line — our custom line above handles it.
The corner chars from Ink's bottom border (╰ and ╯ for rounded) naturally
connect to our top line since we used the same borderStyle chars.
*/}
<Box
borderStyle={INK_BORDER_MAP[borderStyle] as 'single'}
borderTop={false}
borderColor={bc}
paddingX={padding}
flexDirection="column"
width={totalWidth}
>
{children}
</Box>
</Box>
);
}
@@ -1,2 +0,0 @@
export { Panel, SplitPane } from './Panel.js';
export type { PanelProps, SplitPaneProps } from './Panel.js';
@@ -1,58 +0,0 @@
import React from 'react';
import { Box, Text, useStdout } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export interface ProgressBarProps {
/** Progress value 0–100 */
value: number;
/** Optional label shown to the left of the bar */
label?: string;
/** Show percentage at the right end */
showPercent?: boolean;
/** Fixed bar width in columns — defaults to auto (fills terminal width) */
width?: number;
/** Theme override — defaults to darkTheme */
theme?: InkUITheme;
}
const FILL = '█';
const EMPTY = '░';
export const ProgressBar: React.FC<ProgressBarProps> = ({
value,
label,
showPercent = true,
width,
theme = darkTheme,
}) => {
const { stdout } = useStdout();
const termWidth = stdout?.columns ?? 80;
// Clamp value to [0, 100]
const pct = Math.min(100, Math.max(0, value));
// Build the bar width:
// total - label - space - percent suffix (e.g. " 100%") - 2 padding spaces
const percentSuffix = showPercent ? ` ${String(Math.round(pct)).padStart(3)}%` : '';
const labelPrefix = label ? `${label} ` : '';
const overhead = labelPrefix.length + percentSuffix.length;
const barWidth = width ?? Math.max(8, termWidth - overhead);
const filled = Math.round((pct / 100) * barWidth);
const empty = barWidth - filled;
const filledStr = FILL.repeat(filled);
const emptyStr = EMPTY.repeat(empty);
return (
<Box>
{label ? <Text>{label} </Text> : null}
<Text color={theme.colors.primary}>{filledStr}</Text>
<Text color={theme.colors.muted}>{emptyStr}</Text>
{showPercent ? (
<Text color={theme.colors.muted}>{percentSuffix}</Text>
) : null}
</Box>
);
};
@@ -1,2 +0,0 @@
export type { ProgressBarProps } from './ProgressBar.js';
export { ProgressBar } from './ProgressBar.js';
@@ -1,91 +0,0 @@
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export interface ScrollAreaProps {
/** Visible height in rows */
height: number;
/** Scrollbar visibility */
scrollbar?: boolean;
/** Scrollbar thumb character */
scrollbarChar?: string;
/** Track character */
trackChar?: string;
/** Called when scroll position changes */
onScroll?: (offset: number, total: number) => void;
/** Whether this component accepts keyboard input */
focus?: boolean;
/** Color theme */
theme?: InkUITheme;
children: React.ReactNode;
}
export const ScrollArea: React.FC<ScrollAreaProps> = ({
height,
scrollbar = true,
scrollbarChar = '█',
trackChar = '░',
onScroll,
focus = true,
theme = darkTheme,
children,
}) => {
const [scrollOffset, setScrollOffset] = useState(0);
const items = React.Children.toArray(children);
const totalItems = items.length;
const maxOffset = Math.max(0, totalItems - height);
const scroll = useCallback(
(delta: number) => {
setScrollOffset((prev) => {
const next = Math.max(0, Math.min(maxOffset, prev + delta));
onScroll?.(next, totalItems);
return next;
});
},
[maxOffset, totalItems, onScroll]
);
useInput(
(input, key) => {
if (key.upArrow || input === 'k') scroll(-1);
else if (key.downArrow || input === 'j') scroll(1);
else if (key.pageUp || input === 'u') scroll(-Math.floor(height / 2));
else if (key.pageDown || input === 'd') scroll(Math.floor(height / 2));
else if (input === 'g') scroll(-totalItems);
else if (input === 'G') scroll(totalItems);
},
{ isActive: focus }
);
const visibleItems = items.slice(scrollOffset, scrollOffset + height);
// Scrollbar calculation
const thumbSize = Math.max(1, Math.round((height / Math.max(totalItems, 1)) * height));
const thumbPos =
maxOffset > 0
? Math.round((scrollOffset / maxOffset) * (height - thumbSize))
: 0;
return (
<Box flexDirection="row">
<Box flexDirection="column" flexGrow={1}>
{visibleItems}
</Box>
{scrollbar && totalItems > height && (
<Box flexDirection="column" width={1}>
{Array.from({ length: height }, (_, i) => {
const isThumb = i >= thumbPos && i < thumbPos + thumbSize;
return (
<Text key={i} color={isThumb ? theme.colors.primary : theme.colors.muted}>
{isThumb ? scrollbarChar : trackChar}
</Text>
);
})}
</Box>
)}
</Box>
);
};
@@ -1,2 +0,0 @@
export { ScrollArea } from './ScrollArea.js';
export type { ScrollAreaProps } from './ScrollArea.js';
@@ -1,74 +0,0 @@
import React from 'react';
import { Box, Text } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
export interface SparklineProps {
data: number[];
width?: number;
height?: number;
label?: string;
showRange?: boolean;
showLatest?: boolean;
color?: string;
theme?: InkUITheme;
}
export const Sparkline: React.FC<SparklineProps> = ({
data,
width = 20,
height = 1,
label,
showRange = false,
showLatest = true,
color,
theme = darkTheme,
}) => {
if (!data || data.length === 0) {
return <Text color={theme.colors.muted}>{label ?? ''} (no data)</Text>;
}
const barColor = color ?? theme.colors.primary;
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const range = maxVal - minVal || 1;
// Downsample or use as-is
let points: number[];
if (data.length > width) {
const step = data.length / width;
points = Array.from({ length: width }, (_, i) => {
const start = Math.floor(i * step);
const end = Math.floor((i + 1) * step);
const slice = data.slice(start, end);
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
} else if (data.length < width) {
points = [...data, ...Array(width - data.length).fill(data[data.length - 1])];
} else {
points = data;
}
const chars = points.map((v) => {
const normalized = (v - minVal) / range;
const idx = Math.min(7, Math.round(normalized * 7));
return BLOCKS[idx];
});
const sparkStr = chars.join('');
return (
<Box flexDirection="row" gap={1}>
{label && <Text color={theme.colors.muted}>{label}</Text>}
<Text color={barColor}>{sparkStr}</Text>
{showLatest && <Text color={barColor}>{Math.round(data[data.length - 1])}</Text>}
{showRange && (
<Text color={theme.colors.muted} dimColor>
(min: {Math.round(minVal)} max: {Math.round(maxVal)})
</Text>
)}
</Box>
);
};
@@ -1,2 +0,0 @@
export { Sparkline } from './Sparkline.js';
export type { SparklineProps } from './Sparkline.js';
@@ -1,111 +0,0 @@
import React from 'react';
import { Box, Text } from 'ink';
import { darkTheme } from '../_core.js';
import type { InkUITheme } from '../_core.js';
export interface Step {
key: string;
title: string;
description?: string;
optional?: boolean;
}
export interface StepperProps {
steps: Step[];
currentStep: string;
orientation?: 'horizontal' | 'vertical';
showNumbers?: boolean;
completedSteps?: string[];
errorSteps?: string[];
theme?: InkUITheme;
}
export const Stepper: React.FC<StepperProps> = ({
steps,
currentStep,
orientation = 'horizontal',
showNumbers = false,
completedSteps = [],
errorSteps = [],
theme = darkTheme,
}) => {
const getIndicator = (step: Step, index: number) => {
if (errorSteps.includes(step.key)) return { char: '✕', color: theme.colors.error };
if (completedSteps.includes(step.key)) return { char: '✓', color: theme.colors.success };
if (step.key === currentStep) return { char: '●', color: theme.colors.primary };
return { char: '○', color: theme.colors.muted };
};
if (orientation === 'vertical') {
return (
<Box flexDirection="column">
{steps.map((step, i) => {
const { char, color } = getIndicator(step, i);
const isCurrent = step.key === currentStep;
const connector = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
return (
<Box key={step.key} flexDirection="column">
<Box flexDirection="row" gap={1}>
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
<Text color={color}>{char}</Text>
<Text bold={isCurrent} color={isCurrent ? theme.colors.text : theme.colors.muted}>
{step.title}
{step.optional && <Text color={theme.colors.muted}> (optional)</Text>}
</Text>
</Box>
{step.description && (
<Box marginLeft={2}>
<Text color={theme.colors.muted}>│ {step.description}</Text>
</Box>
)}
{i < steps.length - 1 && (
<Box marginLeft={2}>
<Text color={connector}>│</Text>
</Box>
)}
</Box>
);
})}
</Box>
);
}
// Horizontal
return (
<Box flexDirection="column">
<Box flexDirection="row" alignItems="center">
{steps.map((step, i) => {
const { char, color } = getIndicator(step, i);
const isCurrent = step.key === currentStep;
const connectorColor = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
return (
<React.Fragment key={step.key}>
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
<Text color={color}>{char}</Text>
<Text color={isCurrent ? theme.colors.text : theme.colors.muted} bold={isCurrent}>
{' '}{step.title}{step.optional ? ' (opt)' : ''}
</Text>
{i < steps.length - 1 && (
<Text color={connectorColor}>{' ── '}</Text>
)}
</React.Fragment>
);
})}
</Box>
<Box flexDirection="row">
{steps.map((step, i) => (
<React.Fragment key={step.key}>
{step.description && (
<Text color={theme.colors.muted}>
{' '}{step.description}
{i < steps.length - 1 ? ' ' : ''}
</Text>
)}
</React.Fragment>
))}
</Box>
</Box>
);
};
@@ -1,2 +0,0 @@
export { Stepper } from './Stepper.js';
export type { StepperProps, Step } from './Stepper.js';
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
{
"name": "odin-tui-workflow-prototype",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/main.tsx"
},
"dependencies": {
"ink": "7.1.1",
"react": "19.3.0"
},
"devDependencies": {
"tsx": "4.23.15"
}
}
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -d node_modules ]]; then
npm install --no-audit --no-fund
fi
exec npm start
-547
View File
@@ -1,547 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
Text,
measureElement,
render,
useApp,
useInput,
useWindowSize,
} from 'ink';
import { Badge } from '../components/ui/badge/index.js';
import { Header } from '../components/ui/header/index.js';
import { KeyHint } from '../components/ui/key-hint/index.js';
import { ProgressBar } from '../components/ui/progress-bar/index.js';
import { ScrollArea } from '../components/ui/scroll-area/index.js';
import { Sparkline } from '../components/ui/sparkline/index.js';
import { Stepper } from '../components/ui/stepper/index.js';
import { Gauge } from '../components/ui/gauge/index.js';
import { darkTheme, lightTheme } from '../components/ui/_core.js';
const phases = [
{ id: 'plan', key: 'g', title: 'Plan' },
{ id: 'run', key: 'l', title: 'Live run' },
{ id: 'results', key: 's', title: 'Summary' },
{ id: 'history', key: 'h', title: 'History' },
{ id: 'advice', key: 'o', title: 'Advice' },
];
const profiles = [
{ id: 'quick', label: 'Quick', time: '~4 min', impact: 'Low', note: 'Read-only triage' },
{ id: 'standard', label: 'Standard', time: '~14 min', impact: 'Moderate', note: 'All capable domains' },
{ id: 'extended', label: 'Extended', time: '~38 min', impact: 'High', note: 'Sustained load; opt in' },
];
const domains = [
{ name: 'CPU', state: 'Ready', score: '96.4' },
{ name: 'Memory', state: 'Ready', score: '91.2' },
{ name: 'GPU', state: 'Unavailable', score: '—', reason: 'No supported compute driver' },
{ name: 'Storage', state: 'Ready', score: '94.1' },
{ name: 'Browser', state: 'Ready', score: '90.5' },
{ name: 'Bash', state: 'Ready', score: '97.6' },
{ name: 'Languages', state: 'Ready', score: '93.0' },
];
const variantNames = ['Mission Control', 'Guided Run', 'Run Ledger'];
const scoreValues = [96.4, 91.2, 94.1, 90.5, 97.6, 93.0];
function Card({ title, children, ascii, theme, width, flexGrow = 0 }) {
return (
<Box
flexDirection="column"
borderStyle={ascii ? 'classic' : 'round'}
borderColor={theme.colors.border}
paddingX={1}
width={width}
flexGrow={flexGrow}
flexShrink={width ? 0 : 1}
>
<Text bold color={theme.colors.secondary}>{title}</Text>
{children}
</Box>
);
}
function ClickRow({ id, register, children, selected = false, theme, ascii }) {
return (
<Box ref={register(id)}>
<Text
inverse={selected}
color={selected ? theme.colors.textInverse : theme.colors.text}
bold={selected}
>
{selected ? (ascii ? '> ' : '› ') : ' '}{children}
</Text>
</Box>
);
}
function StageNav({ phase, register, theme, ascii, vertical = false }) {
return (
<Box flexDirection={vertical ? 'column' : 'row'} gap={vertical ? 0 : 1}>
{phases.map((item) => (
<Box key={item.id} ref={register(`phase:${item.id}`)}>
<Text
bold={phase === item.id}
color={phase === item.id ? theme.colors.primary : theme.colors.muted}
inverse={phase === item.id}
>
{ascii ? `[${item.key}] ${item.title}` : ` ${item.title} `}
</Text>
</Box>
))}
</Box>
);
}
function ProfileRows({ profile, setProfile, register, theme, ascii, compact = false }) {
return (
<Box flexDirection="column" marginTop={compact ? 0 : 1}>
{profiles.map((item) => (
<Box key={item.id} ref={register(`profile:${item.id}`)}>
<Text
bold={profile === item.id}
color={profile === item.id ? theme.colors.primary : theme.colors.text}
inverse={profile === item.id}
>
{profile === item.id ? (ascii ? '> ' : '◆ ') : ' '}
{item.label} {item.time}{compact ? '' : ` ${item.impact}`}
</Text>
{!compact && <Text color={theme.colors.muted}> {item.note}</Text>}
</Box>
))}
<Text color={theme.colors.muted}>{ascii ? 'p changes profile' : 'p changes profile; arrows scroll domains'}</Text>
</Box>
);
}
function StageContent({
phase,
profile,
theme,
ascii,
progress,
cancelPrompt,
cancelQueued,
recovered,
runStatus,
register,
}) {
if (phase === 'plan') {
const selected = profiles.find((item) => item.id === profile) ?? profiles[1];
return (
<Box flexDirection="column" gap={0}>
<Box flexDirection="row" gap={1}>
<Badge variant="success" theme={theme}>6 READY</Badge>
<Badge variant="warning" theme={theme}>1 UNAVAILABLE</Badge>
</Box>
<Text>Selected: {selected.label} · {selected.time} · {selected.impact} impact</Text>
<Text color={theme.colors.muted}>{selected.note}. No score if coverage stays incomplete.</Text>
<Text>GPU unavailable: no supported compute driver detected.</Text>
<Text>Workload writes: none in this profile.</Text>
<Box ref={register('start')} marginTop={1}>
<Text bold color={theme.colors.success} inverse> Enter to begin {selected.label} plan </Text>
</Box>
</Box>
);
}
if (phase === 'run') {
return (
<Box flexDirection="column">
<Box flexDirection="row" gap={1}>
<Badge variant="info" theme={theme}>RUNNING</Badge>
<Text>CPU mixed load · trial 3 of 5 · ~6 min left</Text>
</Box>
{ascii
? <Text>[########------------] {progress}%</Text>
: <ProgressBar value={progress} width={28} theme={theme} />}
<Text color={theme.colors.warning} bold>
{cancelQueued ? 'Cancel queued for the next safe trial boundary.' : 'Timed sample active · display updates pause during measurement.'}
</Text>
<Text>GPU: skipped · no supported compute driver</Text>
<Text>Worker isolated · UI input cannot change workload conditions.</Text>
{cancelPrompt ? (
<Box borderStyle={ascii ? 'classic' : 'round'} borderColor={theme.colors.warning} paddingX={1} marginTop={1}>
<Text bold>Stop after this trial? [y] Confirm [n] Continue</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<Box ref={register('cancel')}><Text bold color={theme.colors.error} inverse> c Request safe stop </Text></Box>
<Box ref={register('recover')}><Text color={theme.colors.secondary}> u Simulate interruption recovery </Text></Box>
</Box>
)}
</Box>
);
}
if (phase === 'results') {
return (
<Box flexDirection="column">
<Box flexDirection="row" gap={1}>
<Badge variant="warning" theme={theme}>HEADLINE WITHHELD</Badge>
<Text>{runStatus === 'cancelled' || runStatus === 'recovered' ? 'Partial coverage · no median' : '6/7 valid · GPU unavailable'}</Text>
</Box>
<Text bold>{runStatus === 'cancelled' ? 'Cancelled safely · partial record preserved' : runStatus === 'recovered' ? 'Recovered after interruption · partial record preserved' : runStatus === 'complete' ? 'Run complete · capability coverage remains 6/7' : 'Sample run · partial record preserved'}</Text>
{runStatus === 'cancelled' || runStatus === 'recovered'
? <Text>Partial evidence remains; unrun domains have no measurements.</Text>
: <Box flexDirection="row" flexWrap="wrap" gap={1}>
{domains.filter((item) => item.score !== '—').map((item) => (
<Text key={item.name} color={theme.colors.primary}>{item.name} {item.score}</Text>
))}
</Box>}
<Text>Prior complete run: median 93.2 · reference only</Text>
<Text color={theme.colors.warning}>Warning · thermal throttling observed after trial 3</Text>
<Text color={theme.colors.muted}>Evidence: temperature rise + clock drop · corroborated</Text>
</Box>
);
}
if (phase === 'history') {
return (
<Box flexDirection="column">
<Text bold>LOCAL RUN HISTORY</Text>
<Text>OD-0928 · {profiles.find((item) => item.id === profile)?.label} · {runStatus === 'cancelled' ? 'Cancelled · partial' : runStatus === 'recovered' ? 'Recovered · partial' : 'Complete attempt'} · headline withheld</Text>
<Text>OD-0921 · Standard · Complete · 7/7 · median 93.2</Text>
<Text color={theme.colors.warning}>Partial run is not comparable; prior complete run remains available.</Text>
{recovered && <Text color={theme.colors.warning}>Recovered after interruption · partial evidence retained · cleanup confirmed</Text>}
<Text color={theme.colors.muted}>Notes stay separate from original measurements.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Text bold color={theme.colors.warning}>Finding · WARNING · corroborated</Text>
<Text>CPU clocks fell as temperature reached 91°C during sustained load.</Text>
<Text>Advice: inspect cooling before repeating extended profile.</Text>
<Text color={theme.colors.success}>Expected benefit: steadier sustained throughput.</Text>
<Text>Tradeoff: downtime for inspection. Odin changes no settings.</Text>
<Text color={theme.colors.muted}>Evidence and uncertainty remain visible beside advice.</Text>
</Box>
);
}
function App() {
const { exit } = useApp();
const { columns = 80, rows = 24 } = useWindowSize();
const [variant, setVariant] = useState(0);
const [phase, setPhase] = useState('plan');
const [profile, setProfile] = useState('standard');
const [themeMode, setThemeMode] = useState('dark');
const [ascii, setAscii] = useState(false);
const [cancelPrompt, setCancelPrompt] = useState(false);
const [cancelQueued, setCancelQueued] = useState(false);
const [progress, setProgress] = useState(64);
const [recovered, setRecovered] = useState(false);
const [runStatus, setRunStatus] = useState('sample');
const [mouseLast, setMouseLast] = useState('No click yet');
const [showHelp, setShowHelp] = useState(false);
const hitTargets = useRef(new Map());
const handlers = useRef({});
const themeBase = themeMode === 'dark' ? darkTheme : lightTheme;
const colors = ascii || process.env.NO_COLOR !== undefined
? Object.fromEntries(Object.keys(themeBase.colors).map((key) => [key, undefined]))
: {
...themeBase.colors,
primary: themeMode === 'dark' ? 'magenta' : 'blue',
secondary: themeMode === 'dark' ? 'cyan' : 'magenta',
};
const theme = { ...themeBase, colors };
const roomy = columns >= 80 && rows >= 19;
const register = (id) => (node) => {
if (node) hitTargets.current.set(id, node);
else hitTargets.current.delete(id);
};
const nextVariant = (delta) => setVariant((prev) => (prev + delta + variantNames.length) % variantNames.length);
const nextProfile = () => {
const index = profiles.findIndex((item) => item.id === profile);
setProfile(profiles[(index + 1) % profiles.length].id);
};
const startRun = () => {
setPhase('run');
setProgress(64);
setRunStatus('running');
setCancelPrompt(false);
setCancelQueued(false);
};
const cancelRun = () => {
setCancelPrompt(false);
setCancelQueued(false);
setRunStatus('cancelled');
setPhase('results');
};
const advanceRun = () => {
if (progress >= 96) {
setRunStatus('complete');
setPhase('results');
} else setProgress((value) => Math.min(96, value + 16));
};
const recoverRun = () => {
setRecovered(true);
setRunStatus('recovered');
setPhase('history');
setCancelPrompt(false);
};
handlers.current = {
'variant:prev': () => nextVariant(-1),
'variant:next': () => nextVariant(1),
'theme': () => setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'),
'ascii': () => setAscii((value) => !value),
'phase:plan': () => setPhase('plan'),
'phase:run': () => setPhase('run'),
'phase:results': () => setPhase('results'),
'phase:history': () => setPhase('history'),
'phase:advice': () => setPhase('advice'),
'profile:quick': () => setProfile('quick'),
'profile:standard': () => setProfile('standard'),
'profile:extended': () => setProfile('extended'),
'start': startRun,
'cancel': () => { setCancelQueued(true); setCancelPrompt(true); },
'recover': recoverRun,
};
const clickAt = (column, row) => {
for (const [id, node] of hitTargets.current.entries()) {
const rect = measureElement(node);
if (column >= rect.x && column < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) {
handlers.current[id]?.();
setMouseLast(id.replace(':', ' '));
return;
}
}
};
useEffect(() => {
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
const enable = '\u001b[?1000h\u001b[?1006h';
const disable = '\u001b[?1006l\u001b[?1000l';
process.stdout.write(enable);
let carry = '';
const onData = (chunk) => {
carry += chunk.toString('utf8');
const pattern = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g;
let match;
while ((match = pattern.exec(carry)) !== null) {
const button = Number(match[1]) & 3;
if (button === 0 && match[4] === 'M') {
clickAt(Number(match[2]) - 1, Number(match[3]) - 1);
}
}
carry = carry.slice(-80);
};
const onExit = () => process.stdout.write(disable);
process.stdin.on('data', onData);
process.once('exit', onExit);
return () => {
process.stdin.off('data', onData);
process.off('exit', onExit);
process.stdout.write(disable);
};
}, []);
useInput((input, key) => {
if (cancelPrompt) {
if (input.toLowerCase() === 'y' || key.return) cancelRun();
else if (input.toLowerCase() === 'n' || key.escape) { setCancelPrompt(false); setCancelQueued(false); }
return;
}
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
if (input === '[') { nextVariant(-1); return; }
if (input === ']') { nextVariant(1); return; }
if (input === 'p') { nextProfile(); return; }
if (input === 't') { setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'); return; }
if (input === 'a') { setAscii((value) => !value); return; }
if (input === '?') { setShowHelp((value) => !value); return; }
if (input === 'g' || key.tab && key.shift) { setPhase('plan'); return; }
if (input === 'l') { setPhase('run'); return; }
if (input === 's') { setPhase('results'); return; }
if (input === 'h') { setPhase('history'); return; }
if (input === 'o') { setPhase('advice'); return; }
if (key.leftArrow) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + phases.length - 1) % phases.length].id); return; }
if (key.rightArrow || key.tab) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + 1) % phases.length].id); return; }
if (phase === 'run' && input.toLowerCase() === 'c') { setCancelQueued(true); setCancelPrompt(true); return; }
if (phase === 'run' && input.toLowerCase() === 'u') { recoverRun(); return; }
if (phase === 'plan' && key.return) { startRun(); return; }
if (phase === 'run' && key.return) { advanceRun(); return; }
if (phase === 'results' && key.return) { setPhase('history'); return; }
if (phase === 'history' && key.return) { setPhase('advice'); return; }
if (phase === 'advice' && key.return) { setPhase('plan'); return; }
});
const renderHeader = () => ascii
? <Box flexDirection="row" gap={1}><Text bold color={theme.colors.primary}>ODIN</Text><Text>by Bongbetic · local benchmark + system health</Text></Box>
: <Header title="ODIN · Bongbetic" version="prototype" subtitle="LOCAL BENCHMARK + SYSTEM HEALTH" style="line" theme={theme} />;
const renderProfiles = (compact = false) => (
<ProfileRows profile={profile} register={register} theme={theme} ascii={ascii} compact={compact} />
);
const renderDomains = (height = 5) => (
<Box flexDirection="column">
<Text bold>COVERAGE · 6/7</Text>
{ascii
? <ScrollArea height={height} theme={theme} scrollbarChar="#" trackChar="." focus>
{domains.map((item) => <Text key={item.name}>{item.name}: {item.state}{item.reason ? ` — ${item.reason}` : ''}</Text>)}
</ScrollArea>
: <ScrollArea height={height} theme={theme} focus>
{domains.map((item) => (
<Box key={item.name} flexDirection="row" gap={1}>
<Text color={item.state === 'Ready' ? theme.colors.success : theme.colors.warning}>{item.state === 'Ready' ? '●' : '○'}</Text>
<Text>{item.name}</Text>
<Text color={theme.colors.muted}>{item.state === 'Ready' ? 'ready' : 'unavailable'}</Text>
</Box>
))}
</ScrollArea>}
<Text color={theme.colors.muted}>Arrows scroll · status words work without color</Text>
</Box>
);
const renderContent = () => (
<StageContent
phase={phase}
profile={profile}
theme={theme}
ascii={ascii}
progress={progress}
cancelPrompt={cancelPrompt}
cancelQueued={cancelQueued}
recovered={recovered}
runStatus={runStatus}
register={register}
/>
);
const renderVariantSwitch = () => (
<Box flexDirection="row" gap={1}>
<Box ref={register('variant:prev')}><Text color={theme.colors.muted}>[</Text></Box>
<Text color={theme.colors.secondary} bold>Layout {String.fromCharCode(65 + variant)} · {variantNames[variant]}</Text>
<Box ref={register('variant:next')}><Text color={theme.colors.muted}>]</Text></Box>
<Box ref={register('theme')}><Text color={theme.colors.muted}>[t] theme</Text></Box>
<Box ref={register('ascii')}><Text color={theme.colors.muted}>[a] ASCII</Text></Box>
</Box>
);
const renderFooter = () => (
<Box flexDirection="column" marginTop={1}>
{showHelp ? (
<Text color={theme.colors.muted}>[[]/]] layout · [g/l/s/h/o] views · [p] profile · [Enter] advance · [c] cancel · [u] recover · [t] theme · [a] ASCII · [q] quit</Text>
) : (
<KeyHint theme={theme} keys={[
{ key: 'Tab/←→', label: 'View' },
{ key: 'p', label: 'Profile' },
{ key: 'Enter', label: phase === 'run' ? 'Next trial' : 'Open' },
{ key: 'c', label: 'Cancel run' },
{ key: '?', label: 'Help' },
{ key: 'q', label: 'Quit' },
]} />
)}
<Text color={theme.colors.muted}>Mouse: {process.stdin.isTTY ? 'SGR click mode · ' : 'unavailable · '}{mouseLast} · {columns}×{rows} · {ascii ? 'ASCII + monochrome' : process.env.NO_COLOR !== undefined ? 'Unicode · NO_COLOR' : `${themeMode} color`}</Text>
</Box>
);
const renderA = () => (
<Box flexDirection="column" flexGrow={1}>
<Box marginBottom={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
<Box flexDirection={roomy ? 'row' : 'column'} gap={1} flexGrow={1}>
<Card title="RUN SETUP" ascii={ascii} theme={theme} width={roomy ? Math.min(31, Math.floor(columns * 0.38)) : columns}>
{renderProfiles(true)}
{renderDomains(roomy ? 5 : 3)}
</Card>
<Card title={phases.find((item) => item.id === phase)?.title.toUpperCase()} ascii={ascii} theme={theme} flexGrow={1}>
{renderContent()}
</Card>
</Box>
</Box>
);
const renderB = () => (
<Box flexDirection="column" flexGrow={1}>
{!ascii && <Stepper
steps={[{ key: 'plan', title: 'Plan' }, { key: 'run', title: 'Measure' }, { key: 'review', title: 'Review' }]}
currentStep={phase === 'plan' ? 'plan' : phase === 'run' ? 'run' : 'review'}
completedSteps={phase === 'run' ? ['plan'] : phase === 'results' || phase === 'history' || phase === 'advice' ? ['plan', 'run'] : []}
theme={theme}
/>}
{ascii && <Text>STEP 1 PLAN --&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' } });