From 58762fbc95868254621f4906226022e9d3f4fa67 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Wed, 19 Aug 2026 21:05:00 +0800 Subject: [PATCH 01/11] feat: init tui package --- knip.ts | 3 +++ package.json | 3 ++- pnpm-lock.yaml | 2 ++ pnpm-workspace.yaml | 1 + tui/package.json | 33 +++++++++++++++++++++++++++++++++ tui/src/cli.ts | 41 +++++++++++++++++++++++++++++++++++++++++ tui/src/index.ts | 10 ++++++++++ tui/tsconfig.json | 16 ++++++++++++++++ tui/vite.config.ts | 10 ++++++++++ 9 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tui/package.json create mode 100644 tui/src/cli.ts create mode 100644 tui/src/index.ts create mode 100644 tui/tsconfig.json create mode 100644 tui/vite.config.ts diff --git a/knip.ts b/knip.ts index 2a2d3c2bbf..c90b1b6db1 100644 --- a/knip.ts +++ b/knip.ts @@ -38,6 +38,9 @@ const config: KnipConfig = { project: ['**/*.{ts,vue,cjs,mjs,css}'], ignoreDependencies: ['@nuxtjs/mdc'], }, + 'tui': { + project: ['src/**/*.ts!'], + }, }, } diff --git a/package.json b/package.json index 356acb6c98..56d9e844ad 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:test": "TEST=1 vp run build", "dev": "nuxt dev", "dev:docs": "vp run --filter npmx-docs dev --port=3001", + "npmx-tui": "vp run --filter npmx-tui dev", "i18n:check:fix": "node scripts/compare-translations.ts --fix", "i18n:report:fix": "node scripts/remove-unused-translations.ts", "knip:fix": "knip --fix", @@ -36,7 +37,7 @@ "test:browser:ui": "vp run build:test && vp run test:browser:prebuilt --ui", "test:browser:update": "vp run build:test && vp run test:browser:prebuilt --update-snapshots", "test:nuxt": "vp test --project nuxt", - "test:types": "vp run generate:lexicons && nuxt prepare && vue-tsc -b --noEmit && vp run --filter npmx-connector test:types", + "test:types": "vp run generate:lexicons && nuxt prepare && vue-tsc -b --noEmit && vp run --filter npmx-connector test:types && vp run --filter npmx-tui test:types", "test:unit": "vp test --project unit", "start:playwright:webserver": "TEST=1 vp run preview --port 5678", "storybook": "STORYBOOK=true storybook dev -p 6006", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfc0c4e3a4..9c34e648db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,8 @@ importers: specifier: 4.3.3 version: 4.3.3 + tui: {} + packages: '@adobe/css-tools@4.5.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1cb412088d..5569c64f29 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - . - cli - docs + - tui allowBuilds: '@atcute/time-ms': true diff --git a/tui/package.json b/tui/package.json new file mode 100644 index 0000000000..d658edeb84 --- /dev/null +++ b/tui/package.json @@ -0,0 +1,33 @@ +{ + "name": "npmx-tui", + "version": "0.0.1", + "description": "Terminal UI for npmx.dev", + "homepage": "https://npmx.dev", + "bugs": { + "url": "https://github.com/npmx-dev/npmx.dev/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/npmx-dev/npmx.dev.git", + "directory": "tui" + }, + "bin": { + "npmx-tui": "./dist/cli.mjs" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./dist/index.mjs" + }, + "scripts": { + "build": "vp pack", + "dev": "node src/cli.ts", + "test:types": "tsc --noEmit" + }, + "engines": { + "node": ">=24.4.0" + } +} diff --git a/tui/src/cli.ts b/tui/src/cli.ts new file mode 100644 index 0000000000..faa0ab610a --- /dev/null +++ b/tui/src/cli.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import process from 'node:process' +import { parseArgs } from 'node:util' +import { runTui } from './index.ts' + +const VERSION = '0.0.1' + +const { values } = parseArgs({ + options: { + help: { + type: 'boolean', + short: 'h', + }, + version: { + type: 'boolean', + short: 'v', + }, + }, +}) + +if (values.help) { + console.log(`npmx-tui + +Usage: + npmx-tui [options] + +Options: + -h, --help Show help + -v, --version Show version`) + process.exit(0) +} + +if (values.version) { + console.log(VERSION) + process.exit(0) +} + +runTui({ version: VERSION }).catch(error => { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/tui/src/index.ts b/tui/src/index.ts new file mode 100644 index 0000000000..a2928be5fa --- /dev/null +++ b/tui/src/index.ts @@ -0,0 +1,10 @@ +export interface RunTuiOptions { + version?: string +} + +export async function runTui(options: RunTuiOptions = {}): Promise { + const version = options.version ?? '0.0.1' + + // Placeholder until the interactive TUI dependencies and flows are chosen. + console.log(`npmx-tui ${version}`) +} diff --git a/tui/tsconfig.json b/tui/tsconfig.json new file mode 100644 index 0000000000..ac3cf0787f --- /dev/null +++ b/tui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "nodenext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "declaration": true, + "types": ["node"], + "declarationMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tui/vite.config.ts b/tui/vite.config.ts new file mode 100644 index 0000000000..69bf8b3b92 --- /dev/null +++ b/tui/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite-plus' + +export default defineConfig({ + pack: { + entry: ['src/index.ts', 'src/cli.ts'], + format: 'esm', + dts: true, + outDir: 'dist', + }, +}) From b791a7c3d3297ea159a2abbefb704009b97df434 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Wed, 19 Aug 2026 21:27:21 +0800 Subject: [PATCH 02/11] chore: add opentui --- pnpm-lock.yaml | 131 +++++++++++++++++++++++++++++++++++++++++++++- tui/.node-version | 1 + tui/README.md | 30 +++++++++++ tui/package.json | 7 ++- tui/src/cli.ts | 3 +- tui/src/dev.ts | 62 ++++++++++++++++++++++ tui/src/index.ts | 112 ++++++++++++++++++++++++++++++++++++++- 7 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 tui/.node-version create mode 100644 tui/README.md create mode 100644 tui/src/dev.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c34e648db..bfafb423b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,7 +444,11 @@ importers: specifier: 4.3.3 version: 4.3.3 - tui: {} + tui: + dependencies: + '@opentui/core': + specifier: 0.5.4 + version: 0.5.4(typescript-native-bridge@6.0.3-bridge.13.tsgo.7.0.2)(web-tree-sitter@0.25.10) packages: @@ -2821,6 +2825,53 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentui/core-darwin-arm64@0.5.4': + resolution: {integrity: sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.5.4': + resolution: {integrity: sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.5.4': + resolution: {integrity: sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.5.4': + resolution: {integrity: sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.5.4': + resolution: {integrity: sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.5.4': + resolution: {integrity: sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.5.4': + resolution: {integrity: sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.5.4': + resolution: {integrity: sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.5.4': + resolution: {integrity: sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w==} + peerDependencies: + web-tree-sitter: 0.25.10 + '@oxc-parser/binding-android-arm-eabi@0.144.0': resolution: {integrity: sha512-IaoGBEp/huvja99PxI/b72TbKFzA/UzxxAka7f233dc/Tg/rRTX9Qn8IquFLWwWf4IddN/5TaJ8S4Subbjq7wQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5882,6 +5933,11 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-ffi-structs@0.3.1: + resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==} + peerDependencies: + typescript: ^5 + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -8003,6 +8059,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@17.0.6: resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} @@ -9739,6 +9800,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -10702,6 +10767,14 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -14266,6 +14339,50 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@opentui/core-darwin-arm64@0.5.4': + optional: true + + '@opentui/core-darwin-x64@0.5.4': + optional: true + + '@opentui/core-linux-arm64-musl@0.5.4': + optional: true + + '@opentui/core-linux-arm64@0.5.4': + optional: true + + '@opentui/core-linux-x64-musl@0.5.4': + optional: true + + '@opentui/core-linux-x64@0.5.4': + optional: true + + '@opentui/core-win32-arm64@0.5.4': + optional: true + + '@opentui/core-win32-x64@0.5.4': + optional: true + + '@opentui/core@0.5.4(typescript-native-bridge@6.0.3-bridge.13.tsgo.7.0.2)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.3.1(typescript-native-bridge@6.0.3-bridge.13.tsgo.7.0.2) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.5.4 + '@opentui/core-darwin-x64': 0.5.4 + '@opentui/core-linux-arm64': 0.5.4 + '@opentui/core-linux-arm64-musl': 0.5.4 + '@opentui/core-linux-x64': 0.5.4 + '@opentui/core-linux-x64-musl': 0.5.4 + '@opentui/core-win32-arm64': 0.5.4 + '@opentui/core-win32-x64': 0.5.4 + transitivePeerDependencies: + - typescript + '@oxc-parser/binding-android-arm-eabi@0.144.0': optional: true @@ -17005,6 +17122,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-ffi-structs@0.3.1(typescript-native-bridge@6.0.3-bridge.13.tsgo.7.0.2): + dependencies: + typescript: typescript-native-bridge@6.0.3-bridge.13.tsgo.7.0.2 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -19528,6 +19649,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + marked@17.0.6: {} marked@18.0.9: {} @@ -22116,6 +22239,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -23163,6 +23290,8 @@ snapshots: web-namespaces@2.0.1: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} diff --git a/tui/.node-version b/tui/.node-version new file mode 100644 index 0000000000..6f4247a625 --- /dev/null +++ b/tui/.node-version @@ -0,0 +1 @@ +26 diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000000..a65ad99f18 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,30 @@ +# npmx-tui + +Terminal UI for npmx.dev. + +## Local development + +The main npmx.dev repository currently targets Node.js 24. Keep using Node 24 for the root app, CI-equivalent checks, and existing workspace packages. + +OpenTUI's native renderer requires Node.js 26.4.0+ with experimental FFI enabled. Use a Node 26.4+ runtime only when running this TUI locally. + +From the repository root: + +```bash +pnpm npmx-tui +``` + +Or from this package: + +```bash +cd tui +pnpm dev +``` + +`pnpm dev` starts the TUI in watch mode. Use the left and right arrow keys to switch between the two demo buttons, press Enter to activate the current button, and press Ctrl+C to exit. + +For a single run without watch: + +```bash +pnpm --filter npmx-tui dev:ffi +``` diff --git a/tui/package.json b/tui/package.json index d658edeb84..173ffd7ba5 100644 --- a/tui/package.json +++ b/tui/package.json @@ -24,9 +24,14 @@ }, "scripts": { "build": "vp pack", - "dev": "node src/cli.ts", + "dev": "node src/dev.ts", + "dev:ffi": "node --experimental-ffi src/cli.ts", + "dev:once": "node src/cli.ts", "test:types": "tsc --noEmit" }, + "dependencies": { + "@opentui/core": "0.5.4" + }, "engines": { "node": ">=24.4.0" } diff --git a/tui/src/cli.ts b/tui/src/cli.ts index faa0ab610a..1633f6d246 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -36,6 +36,7 @@ if (values.version) { } runTui({ version: VERSION }).catch(error => { - console.error(error instanceof Error ? error.message : error) + const message = error instanceof Error ? error.message : String(error) + console.error(message) process.exit(1) }) diff --git a/tui/src/dev.ts b/tui/src/dev.ts new file mode 100644 index 0000000000..eb3a351036 --- /dev/null +++ b/tui/src/dev.ts @@ -0,0 +1,62 @@ +import process from 'node:process' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] + +function parseNodeVersion(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, '') + .split('.') + .map(part => Number.parseInt(part, 10) || 0) + + return [major, minor, patch] +} + +function isAtLeastVersion( + actual: [number, number, number], + minimum: [number, number, number], +): boolean { + for (let index = 0; index < minimum.length; index += 1) { + if (actual[index] > minimum[index]) { + return true + } + + if (actual[index] < minimum[index]) { + return false + } + } + + return true +} + +const nodeVersion = parseNodeVersion(process.version) + +if (!isAtLeastVersion(nodeVersion, MIN_NODE_VERSION)) { + console.error(`OpenTUI dev mode requires Node.js 26.4.0+ with experimental FFI. + +Current Node.js: ${process.version} + +Use a compatible runtime, then run: + + pnpm npmx-tui + +For a single run without watch: + + pnpm --filter npmx-tui dev:ffi`) + process.exit(1) +} + +const child = spawn(process.execPath, ['--experimental-ffi', '--watch', 'src/cli.ts'], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + stdio: 'inherit', +}) + +child.on('exit', code => { + process.exit(code ?? 0) +}) + +child.on('error', error => { + console.error(error.message) + process.exit(1) +}) diff --git a/tui/src/index.ts b/tui/src/index.ts index a2928be5fa..bf50d54963 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,10 +1,118 @@ +import { + Box, + TabSelect, + TabSelectRenderableEvents, + Text, + createCliRenderer, + instantiate, + type TabSelectOption, + type TabSelectRenderable, + type TextRenderable, +} from '@opentui/core' + export interface RunTuiOptions { version?: string } +const buttons: TabSelectOption[] = [ + { + name: 'Button A', + description: 'Switch to the first action', + value: 'Button A', + }, + { + name: 'Button B', + description: 'Switch to the second action', + value: 'Button B', + }, +] + +function getRuntimeHint(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + + return `${message} + +OpenTUI's native renderer requires Node.js 26.4.0+ with experimental FFI enabled. +Run this TUI with a compatible runtime, for example: + + node --experimental-ffi tui/src/cli.ts + +Current Node.js: ${process.version}` +} + export async function runTui(options: RunTuiOptions = {}): Promise { const version = options.version ?? '0.0.1' + let renderer + + try { + renderer = await createCliRenderer({ + exitOnCtrlC: true, + clearOnShutdown: true, + targetFps: 30, + }) + } catch (error) { + throw new Error(getRuntimeHint(error), { cause: error }) + } + + const status = instantiate( + renderer, + Text({ + content: 'Selected: Button A', + fg: '#94A3B8', + height: 1, + }), + ) as TextRenderable + + const tabSelect = instantiate( + renderer, + TabSelect({ + options: buttons, + tabWidth: 16, + width: 36, + height: 3, + wrapSelection: true, + showDescription: false, + showUnderline: true, + textColor: '#CBD5E1', + selectedTextColor: '#0F172A', + selectedBackgroundColor: '#38BDF8', + focusedTextColor: '#FFFFFF', + focusedBackgroundColor: '#334155', + }), + ) as TabSelectRenderable + + tabSelect.on(TabSelectRenderableEvents.SELECTION_CHANGED, (_index, selected) => { + status.content = `Selected: ${selected?.name ?? 'none'}` + }) + + tabSelect.on(TabSelectRenderableEvents.ITEM_SELECTED, (_index, selected) => { + status.content = `Activated: ${selected?.name ?? 'none'}` + }) + + renderer.root.add( + Box( + { + borderStyle: 'rounded', + padding: 1, + flexDirection: 'column', + gap: 1, + width: 44, + height: 10, + }, + Text({ + content: `Hello, OpenTUI! npmx-tui ${version}`, + fg: '#22C55E', + height: 1, + }), + Text({ + content: 'Use left/right arrows to switch, Enter to activate.', + fg: '#E2E8F0', + height: 1, + }), + tabSelect, + status, + ), + ) - // Placeholder until the interactive TUI dependencies and flows are chosen. - console.log(`npmx-tui ${version}`) + tabSelect.focus() } From 56651e91a3b03d715f545d6e178d3c0a31bf8921 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Wed, 19 Aug 2026 21:57:35 +0800 Subject: [PATCH 03/11] feat: impl basic theme system --- tui/src/cli.ts | 19 +++- tui/src/index.ts | 116 +++++++++++++++++---- tui/src/theme/index.ts | 19 ++++ tui/src/theme/manager.ts | 188 ++++++++++++++++++++++++++++++++++ tui/src/theme/registry.ts | 37 +++++++ tui/src/theme/themes/dark.ts | 28 +++++ tui/src/theme/themes/light.ts | 28 +++++ tui/src/theme/types.ts | 45 ++++++++ 8 files changed, 456 insertions(+), 24 deletions(-) create mode 100644 tui/src/theme/index.ts create mode 100644 tui/src/theme/manager.ts create mode 100644 tui/src/theme/registry.ts create mode 100644 tui/src/theme/themes/dark.ts create mode 100644 tui/src/theme/themes/light.ts create mode 100644 tui/src/theme/types.ts diff --git a/tui/src/cli.ts b/tui/src/cli.ts index 1633f6d246..70c9ab0c86 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -2,6 +2,7 @@ import process from 'node:process' import { parseArgs } from 'node:util' import { runTui } from './index.ts' +import { isThemePreference } from './theme/index.ts' const VERSION = '0.0.1' @@ -15,6 +16,10 @@ const { values } = parseArgs({ type: 'boolean', short: 'v', }, + theme: { + type: 'string', + short: 't', + }, }, }) @@ -26,7 +31,8 @@ Usage: Options: -h, --help Show help - -v, --version Show version`) + -v, --version Show version + -t, --theme Theme preference: system, dark, light`) process.exit(0) } @@ -35,7 +41,16 @@ if (values.version) { process.exit(0) } -runTui({ version: VERSION }).catch(error => { +const themePreference = values.theme ?? 'system' + +if (!isThemePreference(themePreference)) { + console.error(`Invalid theme preference: ${themePreference} + +Expected one of: system, dark, light`) + process.exit(1) +} + +runTui({ version: VERSION, themePreference }).catch(error => { const message = error instanceof Error ? error.message : String(error) console.error(message) process.exit(1) diff --git a/tui/src/index.ts b/tui/src/index.ts index bf50d54963..eb8f1f7ed5 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,17 +1,24 @@ import { Box, + CliRenderEvents, + RGBA, TabSelect, TabSelectRenderableEvents, Text, + TextAttributes, createCliRenderer, instantiate, + type BoxRenderable, + type CliRenderer, type TabSelectOption, type TabSelectRenderable, type TextRenderable, } from '@opentui/core' +import { createThemeManager, type Theme, type ThemePreference } from './theme/index.ts' export interface RunTuiOptions { version?: string + themePreference?: ThemePreference } const buttons: TabSelectOption[] = [ @@ -42,23 +49,52 @@ Current Node.js: ${process.version}` export async function runTui(options: RunTuiOptions = {}): Promise { const version = options.version ?? '0.0.1' - let renderer + const themePreference = options.themePreference ?? 'system' + let renderer: CliRenderer try { renderer = await createCliRenderer({ exitOnCtrlC: true, clearOnShutdown: true, targetFps: 30, + backgroundColor: RGBA.defaultBackground(), }) } catch (error) { throw new Error(getRuntimeHint(error), { cause: error }) } + const themeManager = await createThemeManager(renderer, { + preference: themePreference, + }) + let theme = themeManager.theme + const status = instantiate( renderer, Text({ - content: 'Selected: Button A', - fg: '#94A3B8', + content: '> Selected: Button A', + fg: theme.fg.muted, + bg: theme.bg.surface, + height: 1, + }), + ) as TextRenderable + + const title = instantiate( + renderer, + Text({ + content: `Ready: OpenTUI npmx-tui ${version}`, + fg: theme.status.success, + bg: theme.bg.surface, + attributes: TextAttributes.BOLD, + height: 1, + }), + ) as TextRenderable + + const hint = instantiate( + renderer, + Text({ + content: 'Use left/right arrows to switch, Enter to activate.', + fg: theme.fg.secondary, + bg: theme.bg.surface, height: 1, }), ) as TextRenderable @@ -73,46 +109,82 @@ export async function runTui(options: RunTuiOptions = {}): Promise { wrapSelection: true, showDescription: false, showUnderline: true, - textColor: '#CBD5E1', - selectedTextColor: '#0F172A', - selectedBackgroundColor: '#38BDF8', - focusedTextColor: '#FFFFFF', - focusedBackgroundColor: '#334155', + backgroundColor: theme.bg.surface, + textColor: theme.fg.secondary, + selectedTextColor: theme.fg.primary, + selectedBackgroundColor: theme.bg.selected, + focusedTextColor: theme.fg.primary, + focusedBackgroundColor: theme.bg.selected, + selectedDescriptionColor: theme.fg.muted, }), ) as TabSelectRenderable tabSelect.on(TabSelectRenderableEvents.SELECTION_CHANGED, (_index, selected) => { - status.content = `Selected: ${selected?.name ?? 'none'}` + status.content = `> Selected: ${selected?.name ?? 'none'}` }) tabSelect.on(TabSelectRenderableEvents.ITEM_SELECTED, (_index, selected) => { - status.content = `Activated: ${selected?.name ?? 'none'}` + status.content = `> Activated: ${selected?.name ?? 'none'}` }) - renderer.root.add( + const panel = instantiate( + renderer, Box( { borderStyle: 'rounded', + borderColor: theme.border.normal, + focusedBorderColor: theme.border.focused, + backgroundColor: theme.bg.surface, + title: 'npMx', + titleColor: theme.fg.secondary, padding: 1, flexDirection: 'column', gap: 1, width: 44, height: 10, }, - Text({ - content: `Hello, OpenTUI! npmx-tui ${version}`, - fg: '#22C55E', - height: 1, - }), - Text({ - content: 'Use left/right arrows to switch, Enter to activate.', - fg: '#E2E8F0', - height: 1, - }), + title, + hint, tabSelect, status, ), - ) + ) as BoxRenderable + + function applyTheme(nextTheme: Theme): void { + theme = nextTheme + renderer.setBackgroundColor(theme.bg.base) + + panel.backgroundColor = theme.bg.surface + panel.borderColor = theme.border.normal + panel.focusedBorderColor = theme.border.focused + panel.titleColor = theme.fg.secondary + + title.fg = theme.status.success + title.bg = theme.bg.surface + hint.fg = theme.fg.secondary + hint.bg = theme.bg.surface + status.fg = theme.fg.muted + status.bg = theme.bg.surface + + tabSelect.backgroundColor = theme.bg.surface + tabSelect.textColor = theme.fg.secondary + tabSelect.selectedTextColor = theme.fg.primary + tabSelect.selectedBackgroundColor = theme.bg.selected + tabSelect.focusedTextColor = theme.fg.primary + tabSelect.focusedBackgroundColor = theme.bg.selected + tabSelect.selectedDescriptionColor = theme.fg.muted + } + + applyTheme(theme) + themeManager.subscribe(applyTheme) + renderer.on(CliRenderEvents.DESTROY, () => { + themeManager.dispose() + }) + + renderer.root.add(panel) tabSelect.focus() } + +export { createThemeManager } +export type { Theme, ThemeManager, ThemeMode, ThemeName, ThemePreference } from './theme/index.ts' diff --git a/tui/src/theme/index.ts b/tui/src/theme/index.ts new file mode 100644 index 0000000000..9f95fe4371 --- /dev/null +++ b/tui/src/theme/index.ts @@ -0,0 +1,19 @@ +export { createThemeManager, DEFAULT_THEME_PREFERENCE } from './manager.ts' +export type { ThemeManager, ThemeManagerOptions, ThemeChangeListener } from './manager.ts' +export { + defaultThemeNamesByMode, + getThemeDefinition, + resolveThemeDefinition, + themeDefinitions, +} from './registry.ts' +export { darkTheme } from './themes/dark.ts' +export { lightTheme } from './themes/light.ts' +export { isThemePreference, themePreferences } from './types.ts' +export type { + Theme, + ThemeDefinition, + ThemeMode, + ThemeName, + ThemePreference, + ThemeSelection, +} from './types.ts' diff --git a/tui/src/theme/manager.ts b/tui/src/theme/manager.ts new file mode 100644 index 0000000000..1a8b6343bd --- /dev/null +++ b/tui/src/theme/manager.ts @@ -0,0 +1,188 @@ +import process from 'node:process' +import { + CliRenderEvents, + type CliRenderer, + type ThemeMode as OpenTuiThemeMode, +} from '@opentui/core' +import { resolveThemeDefinition } from './registry.ts' +import type { + Theme, + ThemeDefinition, + ThemeMode, + ThemeName, + ThemePreference, + ThemeSelection, +} from './types.ts' + +export interface ThemeManagerOptions { + preference?: ThemePreference + fallbackMode?: ThemeMode + detectionTimeoutMs?: number + themeSelection?: ThemeSelection +} + +export type ThemeChangeListener = ( + theme: Theme, + resolvedMode: ThemeMode, + definition: ThemeDefinition, +) => void + +export interface ThemeManager { + readonly preference: ThemePreference + readonly terminalMode: ThemeMode + readonly resolvedMode: ThemeMode + readonly themeDefinition: ThemeDefinition + readonly theme: Theme + detectTerminalMode: (timeoutMs?: number) => Promise + setPreference: (preference: ThemePreference) => void + setThemeName: (mode: ThemeMode, themeName: ThemeName) => void + subscribe: (listener: ThemeChangeListener) => () => void + dispose: () => void +} + +export const DEFAULT_THEME_PREFERENCE: ThemePreference = 'system' +export const DEFAULT_THEME_DETECTION_TIMEOUT_MS = 500 + +export async function createThemeManager( + renderer: CliRenderer, + options: ThemeManagerOptions = {}, +): Promise { + let preference = options.preference ?? DEFAULT_THEME_PREFERENCE + let terminalMode = + normalizeThemeMode(renderer.themeMode) ?? + inferThemeModeFromColorFgBg() ?? + options.fallbackMode ?? + 'dark' + + const listeners = new Set() + const themeSelection: ThemeSelection = { ...options.themeSelection } + + function getResolvedMode(): ThemeMode { + return preference === 'system' ? terminalMode : preference + } + + function getThemeDefinition(): ThemeDefinition { + return resolveThemeDefinition(getResolvedMode(), themeSelection) + } + + function emitChange(): void { + const definition = getThemeDefinition() + + for (const listener of listeners) { + listener(definition.theme, getResolvedMode(), definition) + } + + renderer.requestRender() + } + + function emitIfResolvedThemeChanged(previousThemeName: ThemeName): void { + if (getThemeDefinition().name !== previousThemeName) { + emitChange() + } + } + + const terminalThemeModeListener = (mode: OpenTuiThemeMode): void => { + const previousThemeName = getThemeDefinition().name + terminalMode = mode + emitIfResolvedThemeChanged(previousThemeName) + } + + renderer.on(CliRenderEvents.THEME_MODE, terminalThemeModeListener) + + const manager: ThemeManager = { + get preference() { + return preference + }, + + get terminalMode() { + return terminalMode + }, + + get resolvedMode() { + return getResolvedMode() + }, + + get themeDefinition() { + return getThemeDefinition() + }, + + get theme() { + return getThemeDefinition().theme + }, + + async detectTerminalMode(timeoutMs = DEFAULT_THEME_DETECTION_TIMEOUT_MS) { + const previousThemeName = getThemeDefinition().name + const detectedMode = + normalizeThemeMode(renderer.themeMode) ?? + normalizeThemeMode(await renderer.waitForThemeMode(timeoutMs)) + + if (detectedMode) { + terminalMode = detectedMode + emitIfResolvedThemeChanged(previousThemeName) + } + + return terminalMode + }, + + setPreference(nextPreference) { + if (preference === nextPreference) { + return + } + + const previousThemeName = getThemeDefinition().name + preference = nextPreference + emitIfResolvedThemeChanged(previousThemeName) + }, + + setThemeName(mode, themeName) { + const definition = resolveThemeDefinition(mode, { [mode]: themeName }) + + if (definition.name !== themeName) { + throw new Error(`Theme "${themeName}" is not registered for ${mode} mode`) + } + + if (themeSelection[mode] === themeName) { + return + } + + const previousThemeName = getThemeDefinition().name + themeSelection[mode] = themeName + emitIfResolvedThemeChanged(previousThemeName) + }, + + subscribe(listener) { + listeners.add(listener) + + return () => { + listeners.delete(listener) + } + }, + + dispose() { + renderer.off(CliRenderEvents.THEME_MODE, terminalThemeModeListener) + listeners.clear() + }, + } + + await manager.detectTerminalMode(options.detectionTimeoutMs) + + return manager +} + +function normalizeThemeMode(mode: OpenTuiThemeMode | null | undefined): ThemeMode | null { + return mode === 'dark' || mode === 'light' ? mode : null +} + +function inferThemeModeFromColorFgBg(value = process.env.COLORFGBG): ThemeMode | null { + if (!value) { + return null + } + + const backgroundCode = Number(value.split(';').at(-1)) + + if (!Number.isFinite(backgroundCode)) { + return null + } + + return backgroundCode >= 7 && backgroundCode <= 15 ? 'light' : 'dark' +} diff --git a/tui/src/theme/registry.ts b/tui/src/theme/registry.ts new file mode 100644 index 0000000000..28ec686891 --- /dev/null +++ b/tui/src/theme/registry.ts @@ -0,0 +1,37 @@ +import { darkTheme } from './themes/dark.ts' +import { lightTheme } from './themes/light.ts' +import type { ThemeDefinition, ThemeMode, ThemeName, ThemeSelection } from './types.ts' + +export const defaultThemeNamesByMode = { + dark: 'default-dark', + light: 'default-light', +} as const satisfies Record + +export const themeDefinitions = { + 'default-dark': { + name: 'default-dark', + mode: 'dark', + theme: darkTheme, + }, + 'default-light': { + name: 'default-light', + mode: 'light', + theme: lightTheme, + }, +} as const satisfies Record + +export function getThemeDefinition(themeName: ThemeName): ThemeDefinition { + return themeDefinitions[themeName] +} + +export function resolveThemeDefinition( + mode: ThemeMode, + selection: ThemeSelection = {}, +): ThemeDefinition { + const selectedThemeName = selection[mode] ?? defaultThemeNamesByMode[mode] + const selectedTheme = getThemeDefinition(selectedThemeName) + + return selectedTheme.mode === mode + ? selectedTheme + : getThemeDefinition(defaultThemeNamesByMode[mode]) +} diff --git a/tui/src/theme/themes/dark.ts b/tui/src/theme/themes/dark.ts new file mode 100644 index 0000000000..9801615b15 --- /dev/null +++ b/tui/src/theme/themes/dark.ts @@ -0,0 +1,28 @@ +import { RGBA } from '@opentui/core' +import type { Theme } from '../types.ts' + +export const darkTheme: Theme = { + bg: { + base: RGBA.defaultBackground(), + surface: '#202124', + elevated: '#282A2E', + selected: '#30343A', + }, + fg: { + primary: RGBA.defaultForeground(), + secondary: '#B8BCC4', + muted: '#737982', + }, + border: { + normal: '#454A52', + subtle: '#34383F', + focused: '#7AA2F7', + }, + status: { + success: '#8EC07C', + warning: '#E5C07B', + danger: '#E06C75', + info: '#61AFEF', + }, + accent: '#7AA2F7', +} diff --git a/tui/src/theme/themes/light.ts b/tui/src/theme/themes/light.ts new file mode 100644 index 0000000000..d4f43af101 --- /dev/null +++ b/tui/src/theme/themes/light.ts @@ -0,0 +1,28 @@ +import { RGBA } from '@opentui/core' +import type { Theme } from '../types.ts' + +export const lightTheme: Theme = { + bg: { + base: RGBA.defaultBackground(), + surface: '#F3F4F6', + elevated: '#FFFFFF', + selected: '#E5E7EB', + }, + fg: { + primary: RGBA.defaultForeground(), + secondary: '#4B5563', + muted: '#8A9099', + }, + border: { + normal: '#C5C9D0', + subtle: '#DFE2E7', + focused: '#2563EB', + }, + status: { + success: '#2F855A', + warning: '#B7791F', + danger: '#C53030', + info: '#2563EB', + }, + accent: '#2563EB', +} diff --git a/tui/src/theme/types.ts b/tui/src/theme/types.ts new file mode 100644 index 0000000000..b0547477cf --- /dev/null +++ b/tui/src/theme/types.ts @@ -0,0 +1,45 @@ +import type { ColorInput } from '@opentui/core' + +export type ThemePreference = 'system' | 'dark' | 'light' +export type ThemeMode = 'dark' | 'light' +export type ThemeName = 'default-dark' | 'default-light' + +export interface Theme { + bg: { + base: ColorInput + surface: ColorInput + elevated: ColorInput + selected: ColorInput + } + fg: { + primary: ColorInput + secondary: ColorInput + muted: ColorInput + } + border: { + normal: ColorInput + subtle: ColorInput + focused: ColorInput + } + status: { + success: ColorInput + warning: ColorInput + danger: ColorInput + info: ColorInput + } + accent: ColorInput +} + +export interface ThemeDefinition { + name: ThemeName + mode: ThemeMode + theme: Theme +} + +export type ThemeSelection = Partial> + +export const themePreferences = ['system', 'dark', 'light'] as const + +export function isThemePreference(value: unknown): value is ThemePreference { + return typeof value === 'string' && themePreferences.includes(value as ThemePreference) +} From 0cb409e4f3a45bddf177b04087484c9f6e89ab5e Mon Sep 17 00:00:00 2001 From: Atriiy Date: Wed, 19 Aug 2026 22:02:30 +0800 Subject: [PATCH 04/11] feat: impl quit shortcut key --- tui/src/index.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tui/src/index.ts b/tui/src/index.ts index eb8f1f7ed5..b3cf0446f2 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -10,6 +10,7 @@ import { instantiate, type BoxRenderable, type CliRenderer, + type KeyEvent, type TabSelectOption, type TabSelectRenderable, type TextRenderable, @@ -47,6 +48,16 @@ Run this TUI with a compatible runtime, for example: Current Node.js: ${process.version}` } +function shouldQuit(key: KeyEvent): boolean { + return ( + key.eventType === 'press' && + key.name.toLowerCase() === 'q' && + !key.ctrl && + !key.meta && + !key.option + ) +} + export async function runTui(options: RunTuiOptions = {}): Promise { const version = options.version ?? '0.0.1' const themePreference = options.themePreference ?? 'system' @@ -68,6 +79,18 @@ export async function runTui(options: RunTuiOptions = {}): Promise { }) let theme = themeManager.theme + const quitHandler = (key: KeyEvent): void => { + if (!shouldQuit(key)) { + return + } + + key.preventDefault() + key.stopPropagation() + renderer.destroy() + } + + renderer.keyInput.on('keypress', quitHandler) + const status = instantiate( renderer, Text({ @@ -178,6 +201,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { applyTheme(theme) themeManager.subscribe(applyTheme) renderer.on(CliRenderEvents.DESTROY, () => { + renderer.keyInput.off('keypress', quitHandler) themeManager.dispose() }) From d56ee1ce93314e4786a87d026aeabb5750b3c1d7 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Wed, 19 Aug 2026 22:24:41 +0800 Subject: [PATCH 05/11] build: move dev.ts and dev-local.ts to scripts folder --- package.json | 4 +- tui/package.json | 4 +- tui/scripts/dev-local.ts | 298 ++++++++++++++++++++++++++++++++++++ tui/{src => scripts}/dev.ts | 0 tui/tsconfig.json | 2 +- 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 tui/scripts/dev-local.ts rename tui/{src => scripts}/dev.ts (100%) diff --git a/package.json b/package.json index 56d9e844ad..2da381547b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "build:test": "TEST=1 vp run build", "dev": "nuxt dev", "dev:docs": "vp run --filter npmx-docs dev --port=3001", - "npmx-tui": "vp run --filter npmx-tui dev", + "npmx-tui": "vp run --filter npmx-tui dev:local", + "npmx-tui:solo": "vp run --filter npmx-tui dev:ffi", + "npmx-tui:watch": "vp run --filter npmx-tui dev:watch", "i18n:check:fix": "node scripts/compare-translations.ts --fix", "i18n:report:fix": "node scripts/remove-unused-translations.ts", "knip:fix": "knip --fix", diff --git a/tui/package.json b/tui/package.json index 173ffd7ba5..84bc33410a 100644 --- a/tui/package.json +++ b/tui/package.json @@ -24,9 +24,11 @@ }, "scripts": { "build": "vp pack", - "dev": "node src/dev.ts", + "dev": "node scripts/dev-local.ts", + "dev:local": "node scripts/dev-local.ts", "dev:ffi": "node --experimental-ffi src/cli.ts", "dev:once": "node src/cli.ts", + "dev:watch": "node scripts/dev.ts", "test:types": "tsc --noEmit" }, "dependencies": { diff --git a/tui/scripts/dev-local.ts b/tui/scripts/dev-local.ts new file mode 100644 index 0000000000..a0ee936613 --- /dev/null +++ b/tui/scripts/dev-local.ts @@ -0,0 +1,298 @@ +import process from 'node:process' +import { spawn, type ChildProcess } from 'node:child_process' +import { Socket } from 'node:net' +import { parseArgs } from 'node:util' +import { fileURLToPath } from 'node:url' + +const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] +const DEFAULT_PORT = 3000 +const DEFAULT_READY_TIMEOUT_MS = 45_000 +const READY_CHECK_INTERVAL_MS = 500 +const READY_CHECK_TIMEOUT_MS = 1000 + +interface ManagedServer { + process: ChildProcess | null + started: boolean + logs: string[] +} + +interface TcpEndpoint { + host: string + port: number +} + +function parseNodeVersion(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, '') + .split('.') + .map(part => Number.parseInt(part, 10) || 0) + + return [major, minor, patch] +} + +function isAtLeastVersion( + actual: [number, number, number], + minimum: [number, number, number], +): boolean { + for (let index = 0; index < minimum.length; index += 1) { + if (actual[index] > minimum[index]) { + return true + } + + if (actual[index] < minimum[index]) { + return false + } + } + + return true +} + +function assertCompatibleNodeVersion(): void { + const nodeVersion = parseNodeVersion(process.version) + + if (isAtLeastVersion(nodeVersion, MIN_NODE_VERSION)) { + return + } + + console.error(`OpenTUI local dev mode requires Node.js 26.4.0+ with experimental FFI. + +Current Node.js: ${process.version} + +Use a compatible runtime, then run: + + pnpm npmx-tui`) + process.exit(1) +} + +function getPnpmCommand(): string { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +function getApiBaseUrl(port: number, explicitBaseUrl?: string): string { + return explicitBaseUrl ?? process.env.NPMX_API_BASE_URL ?? `http://127.0.0.1:${port}` +} + +function getTcpEndpoint(apiBaseUrl: string): TcpEndpoint { + const url = new URL(apiBaseUrl) + const port = Number.parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10) + + return { + host: url.hostname, + port, + } +} + +function isLocalEndpoint(endpoint: TcpEndpoint): boolean { + return endpoint.host === 'localhost' || endpoint.host === '127.0.0.1' || endpoint.host === '::1' +} + +function wait(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + +function isServerReachable(apiBaseUrl: string): Promise { + const { host, port } = getTcpEndpoint(apiBaseUrl) + + return new Promise(resolve => { + const socket = new Socket() + const finish = (reachable: boolean): void => { + socket.removeAllListeners() + socket.destroy() + resolve(reachable) + } + + socket.setTimeout(READY_CHECK_TIMEOUT_MS) + socket.once('connect', () => { + finish(true) + }) + socket.once('timeout', () => { + finish(false) + }) + socket.once('error', () => { + finish(false) + }) + socket.connect(port, host) + }) +} + +function captureLogs(child: ChildProcess, logs: string[]): void { + const append = (chunk: Buffer): void => { + const lines = chunk.toString('utf8').split(/\r?\n/).filter(Boolean) + logs.push(...lines) + + if (logs.length > 80) { + logs.splice(0, logs.length - 80) + } + } + + child.stdout?.on('data', append) + child.stderr?.on('data', append) +} + +function formatServerLogs(logs: string[]): string { + return logs.length > 0 ? `\n\nRecent server logs:\n${logs.join('\n')}` : '' +} + +async function waitForReachableServer( + apiBaseUrl: string, + server: ManagedServer, + timeoutMs: number, +): Promise { + const startedAt = Date.now() + let serverExitCode: number | null = null + + server.process?.once('exit', code => { + serverExitCode = code ?? 0 + }) + + while (Date.now() - startedAt < timeoutMs) { + if (await isServerReachable(apiBaseUrl)) { + return + } + + if (serverExitCode !== null) { + throw new Error( + `npmx dev server exited before opening ${apiBaseUrl} with code ${serverExitCode}.${formatServerLogs(server.logs)}`, + ) + } + + await wait(READY_CHECK_INTERVAL_MS) + } + + throw new Error( + `Timed out waiting for npmx dev server at ${apiBaseUrl}.${formatServerLogs(server.logs)}`, + ) +} + +async function ensureLocalServer( + repoRoot: string, + apiBaseUrl: string, + endpoint: TcpEndpoint, + timeoutMs: number, +): Promise { + if (await isServerReachable(apiBaseUrl)) { + return { + process: null, + started: false, + logs: [], + } + } + + if (!isLocalEndpoint(endpoint)) { + throw new Error( + `API server at ${apiBaseUrl} is not reachable. Local dev mode only starts npmx automatically for localhost or 127.0.0.1 URLs.`, + ) + } + + const child = spawn(getPnpmCommand(), ['dev', '--port', String(endpoint.port)], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const server: ManagedServer = { + process: child, + started: true, + logs: [], + } + + captureLogs(child, server.logs) + await waitForReachableServer(apiBaseUrl, server, timeoutMs) + + return server +} + +function killProcess(child: ChildProcess | null): void { + if (!child?.pid || child.killed) { + return + } + + try { + if (process.platform !== 'win32') { + process.kill(-child.pid, 'SIGTERM') + return + } + } catch { + // Fall through to killing the direct child process. + } + + child.kill('SIGTERM') +} + +function runTui(tuiRoot: string, apiBaseUrl: string): Promise { + const child = spawn(process.execPath, ['--experimental-ffi', 'src/cli.ts'], { + cwd: tuiRoot, + env: { + ...process.env, + NPMX_API_BASE_URL: apiBaseUrl, + }, + stdio: 'inherit', + }) + + return new Promise((resolve, reject) => { + child.once('exit', code => { + resolve(code ?? 0) + }) + child.once('error', reject) + }) +} + +async function main(): Promise { + assertCompatibleNodeVersion() + + const { values } = parseArgs({ + options: { + 'port': { + type: 'string', + short: 'p', + }, + 'api-base-url': { + type: 'string', + }, + 'ready-timeout': { + type: 'string', + }, + }, + }) + + const port = Number.parseInt(values.port ?? String(DEFAULT_PORT), 10) || DEFAULT_PORT + const readyTimeoutMs = + Number.parseInt(values['ready-timeout'] ?? String(DEFAULT_READY_TIMEOUT_MS), 10) || + DEFAULT_READY_TIMEOUT_MS + const apiBaseUrl = getApiBaseUrl(port, values['api-base-url']) + const endpoint = getTcpEndpoint(apiBaseUrl) + const tuiRoot = fileURLToPath(new URL('..', import.meta.url)) + const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) + let server: ManagedServer | null = null + + const cleanup = (): void => { + if (server?.started) { + killProcess(server.process) + } + } + + process.once('SIGINT', () => { + cleanup() + process.exit(130) + }) + process.once('SIGTERM', () => { + cleanup() + process.exit(143) + }) + + try { + server = await ensureLocalServer(repoRoot, apiBaseUrl, endpoint, readyTimeoutMs) + const exitCode = await runTui(tuiRoot, apiBaseUrl) + + cleanup() + process.exit(exitCode) + } catch (error) { + cleanup() + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +await main() diff --git a/tui/src/dev.ts b/tui/scripts/dev.ts similarity index 100% rename from tui/src/dev.ts rename to tui/scripts/dev.ts diff --git a/tui/tsconfig.json b/tui/tsconfig.json index ac3cf0787f..3a7744b419 100644 --- a/tui/tsconfig.json +++ b/tui/tsconfig.json @@ -11,6 +11,6 @@ "types": ["node"], "declarationMap": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "scripts/**/*.ts"], "exclude": ["node_modules", "dist"] } From f343af711c025f577d632a24dfa808fef05d5966 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Thu, 20 Aug 2026 22:23:37 +0800 Subject: [PATCH 06/11] feat: optimize UI and list --- .../api/registry/package-meta/[...pkg].get.ts | 44 +- server/api/registry/search.get.ts | 84 + tui/src/cli.ts | 18 +- tui/src/index.ts | 1492 +++++++++++++++-- tui/src/search.ts | 179 ++ 5 files changed, 1712 insertions(+), 105 deletions(-) create mode 100644 server/api/registry/search.get.ts create mode 100644 tui/src/search.ts diff --git a/server/api/registry/package-meta/[...pkg].get.ts b/server/api/registry/package-meta/[...pkg].get.ts index fca3910fc1..8f00a4d72a 100644 --- a/server/api/registry/package-meta/[...pkg].get.ts +++ b/server/api/registry/package-meta/[...pkg].get.ts @@ -1,4 +1,21 @@ import { normalizeLicense } from '#shared/utils/npm' +import type { PackumentVersion } from '#shared/types' + +function countRecordEntries(value: Record | undefined): number { + return value ? Object.keys(value).length : 0 +} + +function getBinNames(bin: PackumentVersion['bin'] | undefined): string[] { + if (!bin) { + return [] + } + + if (typeof bin === 'string') { + return ['bin'] + } + + return Object.keys(bin) +} /** * Returns lightweight package metadata for search results. @@ -31,7 +48,9 @@ export default defineCachedEventHandler( const latestVersion = packument['dist-tags']?.latest || Object.values(packument['dist-tags'] ?? {})[0] || '' + const latestManifest = latestVersion ? packument.versions?.[latestVersion] : undefined const modified = packument.time?.modified || packument.time?.[latestVersion] || '' + const created = packument.time?.created || '' const date = packument.time?.[latestVersion] || modified // Extract repository URL from the packument's repository field @@ -68,15 +87,32 @@ export default defineCachedEventHandler( author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url } } - const license = normalizeLicense(packument.license) + const license = normalizeLicense(latestManifest?.license ?? packument.license) return { name: packument.name, version: latestVersion, - description: packument.description, - keywords: packument.keywords, + description: packument.description ?? 'No description provided.', + keywords: latestManifest?.keywords ?? packument.keywords ?? [], license, date, + modified, + created, + distTags: packument['dist-tags'] ?? {}, + versionCount: Object.keys(packument.versions ?? {}).length, + deprecated: latestManifest?.deprecated, + unpackedSize: latestManifest?.dist?.unpackedSize, + entryPoints: { + type: latestManifest?.type, + main: latestManifest?.main, + module: latestManifest?.module, + types: latestManifest?.types ?? latestManifest?.typings, + hasExports: latestManifest?.exports !== undefined, + binNames: getBinNames(latestManifest?.bin), + engines: latestManifest?.engines, + dependenciesCount: countRecordEntries(latestManifest?.dependencies), + peerDependenciesCount: countRecordEntries(latestManifest?.peerDependencies), + }, links: { npm: `https://www.npmjs.com/package/${packument.name}`, homepage: packument.homepage, @@ -84,7 +120,7 @@ export default defineCachedEventHandler( bugs: bugsUrl, }, author, - maintainers: packument.maintainers, + maintainers: packument.maintainers ?? [], weeklyDownloads: downloads?.downloads, } } catch (error: unknown) { diff --git a/server/api/registry/search.get.ts b/server/api/registry/search.get.ts new file mode 100644 index 0000000000..61e61c41ed --- /dev/null +++ b/server/api/registry/search.get.ts @@ -0,0 +1,84 @@ +import * as v from 'valibot' +import { SearchQuerySchema } from '#shared/schemas/package' +import type { NpmSearchResponse } from '#shared/types' +import { + CACHE_MAX_AGE_ONE_MINUTE, + ERROR_NPM_FETCH_FAILED, + NPM_REGISTRY, +} from '#shared/utils/constants' + +const DEFAULT_SEARCH_SIZE = 12 +const MAX_SEARCH_SIZE = 25 + +function parseSearchSize(value: unknown): number { + if (typeof value !== 'string') { + return DEFAULT_SEARCH_SIZE + } + + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) { + return DEFAULT_SEARCH_SIZE + } + + return Math.min(Math.max(parsed, 1), MAX_SEARCH_SIZE) +} + +function parseSearchQuery(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function parseSearchOffset(value: unknown): number { + if (typeof value !== 'string') { + return 0 + } + + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) { + return 0 + } + + return Math.max(0, parsed) +} + +export default defineCachedEventHandler( + async event => { + const query = getQuery(event) + + try { + const q = v.parse(SearchQuerySchema, parseSearchQuery(query.q)) + if (!q) { + return { + objects: [], + total: 0, + isStale: false, + time: new Date().toISOString(), + } satisfies NpmSearchResponse + } + + const params = new URLSearchParams({ + text: q, + size: String(parseSearchSize(query.size)), + from: String(parseSearchOffset(query.from)), + }) + + const response = await $fetch(`${NPM_REGISTRY}/-/v1/search?${params}`) + return { + ...response, + isStale: false, + } satisfies NpmSearchResponse + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: ERROR_NPM_FETCH_FAILED, + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_MINUTE, + swr: true, + getKey: event => { + const query = getQuery(event) + return `npm-search:v1:${String(query.q ?? '')}:${String(query.size ?? '')}:${String(query.from ?? '')}` + }, + }, +) diff --git a/tui/src/cli.ts b/tui/src/cli.ts index 70c9ab0c86..248fe7845a 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -8,18 +8,21 @@ const VERSION = '0.0.1' const { values } = parseArgs({ options: { - help: { + 'help': { type: 'boolean', short: 'h', }, - version: { + 'version': { type: 'boolean', short: 'v', }, - theme: { + 'theme': { type: 'string', short: 't', }, + 'api-base-url': { + type: 'string', + }, }, }) @@ -32,7 +35,8 @@ Usage: Options: -h, --help Show help -v, --version Show version - -t, --theme Theme preference: system, dark, light`) + -t, --theme Theme preference: system, dark, light + --api-base-url npmx backend base URL`) process.exit(0) } @@ -50,7 +54,11 @@ Expected one of: system, dark, light`) process.exit(1) } -runTui({ version: VERSION, themePreference }).catch(error => { +runTui({ + version: VERSION, + themePreference, + apiBaseUrl: values['api-base-url'], +}).catch(error => { const message = error instanceof Error ? error.message : String(error) console.error(message) process.exit(1) diff --git a/tui/src/index.ts b/tui/src/index.ts index b3cf0446f2..3ece135389 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,39 +1,74 @@ import { Box, CliRenderEvents, + Input, + InputRenderableEvents, RGBA, - TabSelect, - TabSelectRenderableEvents, + StyledText, Text, - TextAttributes, + bg, + bold, createCliRenderer, + fg, instantiate, type BoxRenderable, type CliRenderer, + type InputRenderable, type KeyEvent, - type TabSelectOption, - type TabSelectRenderable, + type TextChunk, type TextRenderable, } from '@opentui/core' +import { + getDefaultApiBaseUrl, + getPackageDetails, + searchPackages, + type PackageDetails, + type PackageSearchResult, +} from './search.ts' import { createThemeManager, type Theme, type ThemePreference } from './theme/index.ts' export interface RunTuiOptions { version?: string themePreference?: ThemePreference + apiBaseUrl?: string } -const buttons: TabSelectOption[] = [ - { - name: 'Button A', - description: 'Switch to the first action', - value: 'Button A', - }, - { - name: 'Button B', - description: 'Switch to the second action', - value: 'Button B', - }, -] +const SEARCH_DEBOUNCE_MS = 500 +const SEARCH_RESULT_LIMIT = 25 +const LIST_SCROLLBAR_WIDTH = 2 +const SPLIT_LAYOUT_MIN_WIDTH = 100 +const SPINNER_FRAME_MS = 90 +const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const + +type AppMode = 'normal' | 'insert' +type LayoutMode = 'single' | 'split' +type WorkspaceView = 'collection' | 'inspector' +type FocusTarget = 'search' | 'collection' | 'inspector' +type SearchStatus = 'idle' | 'debouncing' | 'searching' | 'success' | 'empty' | 'error' +type DetailStatus = 'idle' | 'loading' | 'success' | 'error' +type StatusKind = 'info' | 'success' | 'warning' | 'danger' + +interface AppState { + mode: AppMode + focus: FocusTarget + layout: LayoutMode + view: WorkspaceView + query: string + searchStatus: SearchStatus + results: PackageSearchResult[] + total: number + pageOffset: number + selectedIndex: number + inspectorScrollOffset: number + statusKind: StatusKind + statusMessage: string + errorMessage?: string +} + +interface InspectorLine { + text: string + tone?: 'title' | 'section' | 'muted' | 'command' | 'warning' | 'danger' +} function getRuntimeHint(error: unknown): string { const message = error instanceof Error ? error.message : String(error) @@ -48,19 +83,597 @@ Run this TUI with a compatible runtime, for example: Current Node.js: ${process.version}` } -function shouldQuit(key: KeyEvent): boolean { +function isPlainKey(key: KeyEvent, name: string): boolean { return ( key.eventType === 'press' && - key.name.toLowerCase() === 'q' && + key.name.toLowerCase() === name && !key.ctrl && !key.meta && !key.option ) } +function isCtrlKey(key: KeyEvent, name: string): boolean { + return ( + key.eventType === 'press' && + key.name.toLowerCase() === name && + key.ctrl && + !key.meta && + !key.option + ) +} + +function shouldQuit(key: KeyEvent, mode: AppMode): boolean { + return mode === 'normal' && isPlainKey(key, 'q') +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +function formatDownloads(downloads: number | undefined): string { + if (downloads === undefined) { + return 'downloads n/a' + } + + if (downloads >= 1_000_000) { + return `${(downloads / 1_000_000).toFixed(1)}m/w` + } + + if (downloads >= 1_000) { + return `${Math.round(downloads / 1_000)}k/w` + } + + return `${downloads}/w` +} + +function formatBytes(bytes: number | undefined): string | undefined { + if (bytes === undefined) { + return undefined + } + + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MiB` + } + + if (bytes >= 1024) { + return `${Math.round(bytes / 1024)} KiB` + } + + return `${bytes} B` +} + +function formatDate(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return undefined + } + + return date.toISOString().slice(0, 10) +} + +function truncateText(text: string, maxLength: number): string { + if (maxLength <= 0) { + return '' + } + + if (text.length <= maxLength) { + return text + } + + if (maxLength <= 3) { + return '.'.repeat(maxLength) + } + + return `${text.slice(0, Math.max(0, maxLength - 3))}...` +} + +function selectedPackage(state: AppState): PackageSearchResult | undefined { + return state.results[state.selectedIndex] +} + +function compactList(items: string[] | undefined, limit: number): string { + if (!items) { + return '' + } + + const visible = items.slice(0, limit) + const remaining = items.length - visible.length + + if (remaining <= 0) { + return visible.join(', ') + } + + return `${visible.join(', ')} +${remaining} more` +} + +function isDefinedString(value: string | undefined): value is string { + return value !== undefined +} + +function createInlineMeta(items: Array): string { + return items.filter(isDefinedString).join(' ') +} + +function formatRecord( + record: Record | undefined, + limit: number, +): string | undefined { + if (!record) { + return undefined + } + + const entries = Object.entries(record).map(([key, value]) => `${key}:${value}`) + return entries.length > 0 ? compactList(entries, limit) : undefined +} + +function createBracketSection(title: string, lines: string[]): InspectorLine[] { + const body = lines.filter(Boolean) + + if (body.length === 0) { + return [] + } + + return [{ text: `[${title}]`, tone: 'section' }, ...body.map(line => ({ text: ` ${line}` }))] +} + +function getCollectionEmptyLines(state: AppState): Array<{ title: string; detail: string }> { + if (!state.query.trim()) { + return [ + { + title: 'Search npm packages', + detail: 'Type a package name to begin.', + }, + ] + } + + if (state.searchStatus === 'searching' && state.results.length === 0) { + return [ + { + title: 'Searching packages...', + detail: `Waiting for "${truncateText(state.query, 48)}"`, + }, + ] + } + + if (state.searchStatus === 'error' && state.results.length === 0) { + return [ + { + title: 'Failed to search packages', + detail: state.errorMessage ?? 'Network request failed.', + }, + ] + } + + if (state.searchStatus === 'empty') { + return [ + { + title: `No packages found for "${truncateText(state.query, 42)}"`, + detail: 'Try a different package name.', + }, + ] + } + + return [] +} + +function getCollectionWindow(state: AppState, height: number): { start: number; end: number } { + const visibleRows = Math.max(1, height) + + if (state.results.length <= visibleRows) { + return { start: 0, end: state.results.length } + } + + const half = Math.floor(visibleRows / 2) + const start = Math.max( + 0, + Math.min(state.selectedIndex - half, state.results.length - visibleRows), + ) + return { + start, + end: Math.min(state.results.length, start + visibleRows), + } +} + +function padCell(value: string, width: number, align: 'left' | 'right' = 'left'): string { + const truncated = truncateText(value, width) + return align === 'right' ? truncated.padStart(width, ' ') : truncated.padEnd(width, ' ') +} + +function createSelectedChunk(text: string, theme: Theme): TextChunk { + return bg(theme.accent)(fg(theme.bg.base)(text)) +} + +function createCollectionListText( + state: AppState, + theme: Theme, + width = 80, + height = 12, +): StyledText { + const chunks: TextChunk[] = [] + const rowWidth = Math.max(24, width) + const emptyLines = getCollectionEmptyLines(state) + + if (emptyLines.length > 0) { + emptyLines.forEach((line, index) => { + chunks.push(fg(theme.fg.primary)(bold(truncateText(line.title, rowWidth)))) + chunks.push(fg(theme.fg.muted)(`\n${truncateText(line.detail, rowWidth)}`)) + if (index < emptyLines.length - 1) { + chunks.push(fg(theme.fg.muted)('\n')) + } + }) + + return new StyledText(chunks) + } + + const showScroll = state.results.length > height + const contentWidth = Math.max(22, rowWidth - (showScroll ? LIST_SCROLLBAR_WIDTH : 0)) + const compact = contentWidth < 56 + const versionWidth = compact ? 0 : 11 + const downloadsWidth = compact ? 8 : 9 + const nameWidth = compact + ? Math.max(8, contentWidth - 2 - downloadsWidth - 1 - 2) + : Math.max(16, Math.min(34, Math.floor(contentWidth * 0.34))) + const fixedWidth = + 2 + nameWidth + 1 + (versionWidth > 0 ? versionWidth + 1 : 0) + downloadsWidth + 2 + const descriptionWidth = Math.max(0, contentWidth - fixedWidth) + const window = getCollectionWindow(state, height) + const visibleCount = Math.max(1, window.end - window.start) + const maxIndicatorY = Math.max(0, visibleCount - 1) + const indicatorY = showScroll + ? Math.round((state.selectedIndex / Math.max(1, state.results.length - 1)) * maxIndicatorY) + : -1 + + state.results.slice(window.start, window.end).forEach((result, visibleIndex) => { + const actualIndex = window.start + visibleIndex + const selected = actualIndex === state.selectedIndex + const prefix = selected ? '> ' : ' ' + const version = versionWidth > 0 ? `${padCell(`v${result.version}`, versionWidth)} ` : '' + const line = + `${prefix}${padCell(result.name, nameWidth)} ` + + version + + `${padCell(formatDownloads(result.weeklyDownloads), downloadsWidth, 'right')} ` + + `${truncateText(result.description, descriptionWidth)}` + const paddedLine = line.padEnd(contentWidth, ' ') + const scrollbar = showScroll ? (visibleIndex === indicatorY ? '█' : '│') : '' + const isLast = visibleIndex === visibleCount - 1 + + if (selected) { + chunks.push(createSelectedChunk(paddedLine, theme)) + } else { + chunks.push(fg(theme.fg.secondary)(prefix)) + chunks.push(fg(theme.fg.primary)(bold(padCell(result.name, nameWidth)))) + chunks.push(fg(theme.fg.muted)(' ')) + if (versionWidth > 0) { + chunks.push(fg(theme.fg.secondary)(padCell(`v${result.version}`, versionWidth))) + chunks.push(fg(theme.fg.muted)(' ')) + } + chunks.push( + fg(theme.fg.secondary)( + padCell(formatDownloads(result.weeklyDownloads), downloadsWidth, 'right'), + ), + ) + chunks.push( + fg(theme.fg.muted)( + ` ${truncateText(result.description, descriptionWidth)}`.padEnd( + Math.max(0, contentWidth - fixedWidth + 2), + ' ', + ), + ), + ) + } + + if (showScroll) { + chunks.push( + fg(visibleIndex === indicatorY ? theme.accent : theme.border.subtle)(` ${scrollbar}`), + ) + } + + if (!isLast) { + chunks.push(fg(theme.fg.muted)('\n')) + } + }) + + return new StyledText(chunks) +} + +function createInstallBlock(packageName: string): InspectorLine[] { + return createBracketSection('install', [ + `npm install ${packageName}`, + `pnpm add ${packageName}`, + `yarn add ${packageName}`, + `bun add ${packageName}`, + ]).map(line => + line.tone === 'section' ? line : ({ ...line, tone: 'command' } satisfies InspectorLine), + ) +} + +function createInspectorLines( + pkg: PackageSearchResult | undefined, + state: AppState, + detail?: PackageDetails, + detailStatus: DetailStatus = 'idle', + detailError?: string, +): InspectorLine[] { + if (!pkg) { + return [ + { text: 'Package preview', tone: 'title' }, + { text: '' }, + { + text: 'Select a package from the collection to inspect the details available from search.', + tone: 'muted', + }, + ] + } + + const data = detail ?? pkg + const linkRows: Array<[string, string]> = [] + if (data.links?.npm) { + linkRows.push(['npm', data.links.npm]) + } + if (data.links?.repository) { + linkRows.push(['repo', data.links.repository]) + } + if (data.links?.homepage) { + linkRows.push(['home', data.links.homepage]) + } + if (data.links?.bugs) { + linkRows.push(['bugs', data.links.bugs]) + } + + const primaryMeta = createInlineMeta([ + `latest ${data.version}`, + formatDownloads(data.weeklyDownloads), + data.license ? `license ${data.license}` : undefined, + detail?.unpackedSize ? `size ${formatBytes(detail.unpackedSize)}` : undefined, + ]) + const secondaryMeta = createInlineMeta([ + detail?.date ? `published ${formatDate(detail.date)}` : undefined, + detail?.created ? `created ${formatDate(detail.created)}` : undefined, + detail?.modified ? `modified ${formatDate(detail.modified)}` : undefined, + ]) + const resultMeta = createInlineMeta([ + `result ${state.selectedIndex + 1}/${Math.max(state.results.length, 1)}`, + state.total > 0 ? `${state.total} total matches` : undefined, + ]) + const links = linkRows.map( + ([label, value]) => `${label.padEnd(4, ' ')} ${truncateText(value, 72)}`, + ) + const keywords = compactList(data.keywords, 10) + const maintainers = data.maintainers + .map(maintainer => maintainer.username ?? maintainer.name) + .filter(isDefinedString) + const author = detail?.author?.name ?? detail?.author?.username + const distTags = formatRecord(detail?.distTags, 5) + const engineInfo = formatRecord(detail?.entryPoints?.engines, 3) + const binNames = detail?.entryPoints?.binNames ?? [] + const detailStatusLine = + detailStatus === 'loading' + ? ({ text: ' Loading registry metadata...', tone: 'muted' } satisfies InspectorLine) + : detailStatus === 'error' + ? ({ + text: ` Detail metadata unavailable: ${truncateText(detailError ?? 'request failed', 72)}`, + tone: 'warning', + } satisfies InspectorLine) + : undefined + const headerBlock: InspectorLine[] = [ + { text: `${data.name}@${data.version}`, tone: 'title' }, + { text: data.description }, + ] + if (detail?.deprecated) { + headerBlock.push({ text: `deprecated: ${detail.deprecated}`, tone: 'warning' }) + } + if (detailStatusLine) { + headerBlock.push(detailStatusLine) + } + + const qualityRows = [ + `weekly downloads ${formatDownloads(data.weeklyDownloads)}`, + detail?.versionCount ? `versions ${detail.versionCount}` : undefined, + `maintainers ${maintainers.length}`, + detail?.entryPoints?.dependenciesCount !== undefined + ? `dependencies ${detail.entryPoints.dependenciesCount}` + : undefined, + detail?.entryPoints?.peerDependenciesCount !== undefined + ? `peer dependencies ${detail.entryPoints.peerDependenciesCount}` + : undefined, + distTags ? `dist-tags ${distTags}` : undefined, + ].filter(isDefinedString) + + const runtimeRows = [ + detail?.entryPoints?.type ? `type ${detail.entryPoints.type}` : undefined, + detail?.entryPoints?.main ? `main ${detail.entryPoints.main}` : undefined, + detail?.entryPoints?.module ? `module ${detail.entryPoints.module}` : undefined, + detail?.entryPoints?.types ? `types ${detail.entryPoints.types}` : undefined, + detail?.entryPoints?.hasExports !== undefined + ? `exports ${detail.entryPoints.hasExports ? 'yes' : 'no'}` + : undefined, + binNames.length > 0 ? `bin ${compactList(binNames, 5)}` : undefined, + engineInfo ? `engines ${engineInfo}` : undefined, + ].filter(isDefinedString) + + const blocks: InspectorLine[][] = [ + headerBlock, + createBracketSection('metadata', [primaryMeta, secondaryMeta, resultMeta]), + createInstallBlock(data.name), + createBracketSection('quality', qualityRows), + createBracketSection('runtime', runtimeRows), + createBracketSection('keywords', keywords ? [keywords] : []), + createBracketSection('links', links), + createBracketSection( + 'maintainers', + [ + author ? `author ${author}` : undefined, + maintainers.length > 0 ? `team ${compactList(maintainers, 8)}` : undefined, + ].filter(isDefinedString), + ), + ].filter(block => block.length > 0) + + return blocks.flatMap((block, index) => (index === 0 ? block : [{ text: '' }, ...block])) +} + +function createScrollableLines( + lines: InspectorLine[], + offset: number, + viewportHeight: number, +): InspectorLine[] { + const visibleHeight = Math.max(1, viewportHeight) + + if (lines.length <= visibleHeight) { + return lines + } + + const bodyHeight = Math.max(1, visibleHeight - 1) + const start = Math.min(offset, Math.max(0, lines.length - bodyHeight)) + const end = Math.min(lines.length, start + bodyHeight) + const indicator = `-- ${start + 1}-${end}/${lines.length} --` + + return [...lines.slice(start, end), { text: indicator, tone: 'muted' }] +} + +function getMaxInspectorScrollOffset(lines: InspectorLine[], viewportHeight: number): number { + const bodyHeight = + lines.length > viewportHeight ? Math.max(1, viewportHeight - 1) : viewportHeight + + return Math.max(0, lines.length - bodyHeight) +} + +function createStyledInspectorText(lines: InspectorLine[], theme: Theme): StyledText { + const chunks: TextChunk[] = [] + + lines.forEach((line, index) => { + const text = index === lines.length - 1 ? line.text : `${line.text}\n` + + if (line.tone === 'title') { + chunks.push(fg(theme.fg.primary)(bold(text))) + return + } + + if (line.tone === 'section') { + chunks.push(fg(theme.accent)(bold(text))) + return + } + + if (line.tone === 'muted') { + chunks.push(fg(theme.fg.muted)(text)) + return + } + + if (line.tone === 'command') { + chunks.push(fg(theme.status.success)(text)) + return + } + + if (line.tone === 'warning') { + chunks.push(fg(theme.status.warning)(text)) + return + } + + if (line.tone === 'danger') { + chunks.push(fg(theme.status.danger)(text)) + return + } + + chunks.push({ + __isChunk: true, + text, + }) + }) + + return new StyledText(chunks) +} + +interface ShortcutAction { + key: string + label: string +} + +function contextActions(state: AppState): ShortcutAction[] { + if (state.mode === 'insert') { + return [ + { key: 'esc', label: 'Normal' }, + { key: '^c', label: 'Quit' }, + ] + } + + if (state.layout === 'single' && state.view === 'inspector') { + return [ + { key: 'h/esc', label: 'Results' }, + { key: 'j/k', label: 'Scroll' }, + { key: '/', label: 'Search' }, + { key: 'q', label: 'Quit' }, + ] + } + + if (state.focus === 'inspector') { + return [ + { key: 'h', label: 'Results' }, + { key: 'j/k', label: 'Scroll' }, + { key: '/', label: 'Search' }, + { key: 'q', label: 'Quit' }, + ] + } + + return [ + { key: 'j/k', label: 'Navigate' }, + { key: '[/]', label: 'Page' }, + { key: 'l', label: 'Details' }, + { key: 'enter', label: 'Preview' }, + { key: '/', label: 'Search' }, + { key: 'q', label: 'Quit' }, + ] +} + +function createShortcutBarText(state: AppState, theme: Theme): StyledText { + const chunks: TextChunk[] = [] + + contextActions(state).forEach((action, index) => { + if (index > 0) { + chunks.push(fg(theme.fg.muted)(' ')) + } + + chunks.push(fg(theme.accent)(bold(action.key))) + chunks.push(fg(theme.fg.secondary)(` ${action.label}`)) + }) + + chunks.push(fg(theme.fg.muted)(' ')) + chunks.push(fg(theme.fg.primary)(bold('./npmx'))) + + return new StyledText(chunks) +} + +function hasNextResultsPage(state: AppState): boolean { + return state.query.trim().length > 0 && state.pageOffset + state.results.length < state.total +} + +function hasPreviousResultsPage(state: AppState): boolean { + return state.query.trim().length > 0 && state.pageOffset > 0 +} + +function formatResultsRange(state: AppState): string { + if (!state.query.trim() || state.total === 0 || state.results.length === 0) { + return '0 / 0' + } + + const start = state.pageOffset + 1 + const end = state.pageOffset + state.results.length + return `${start}-${end} / ${state.total}` +} + +function createResultsFooterText(state: AppState, theme: Theme): StyledText { + const complete = state.query.trim().length > 0 && state.total > 0 && !hasNextResultsPage(state) + const rangeColor = complete ? theme.fg.primary : theme.fg.muted + + return new StyledText([fg(theme.fg.muted)('\n'), fg(rangeColor)(formatResultsRange(state))]) +} + export async function runTui(options: RunTuiOptions = {}): Promise { - const version = options.version ?? '0.0.1' const themePreference = options.themePreference ?? 'system' + const apiBaseUrl = options.apiBaseUrl ?? getDefaultApiBaseUrl() let renderer: CliRenderer try { @@ -79,135 +692,822 @@ export async function runTui(options: RunTuiOptions = {}): Promise { }) let theme = themeManager.theme - const quitHandler = (key: KeyEvent): void => { - if (!shouldQuit(key)) { - return - } - - key.preventDefault() - key.stopPropagation() - renderer.destroy() + const state: AppState = { + mode: 'normal', + focus: 'collection', + layout: renderer.terminalWidth >= SPLIT_LAYOUT_MIN_WIDTH ? 'split' : 'single', + view: 'collection', + query: '', + searchStatus: 'idle', + results: [], + total: 0, + pageOffset: 0, + selectedIndex: 0, + inspectorScrollOffset: 0, + statusKind: 'info', + statusMessage: 'Normal mode', } - renderer.keyInput.on('keypress', quitHandler) + let debounceTimer: ReturnType | undefined + let activeRequest: AbortController | undefined + let activeDetailRequest: AbortController | undefined + let spinnerTimer: ReturnType | undefined + let spinnerFrame = 0 + let requestId = 0 + let detailRequestId = 0 + let detailStatus: DetailStatus = 'idle' + let detailError: string | undefined + const detailCache = new Map() - const status = instantiate( + const prompt = instantiate( renderer, Text({ - content: '> Selected: Button A', - fg: theme.fg.muted, - bg: theme.bg.surface, + content: '>', + fg: theme.accent, + bg: theme.bg.base, + width: 2, height: 1, }), ) as TextRenderable - const title = instantiate( + const input = instantiate( + renderer, + Input({ + placeholder: 'Search npm packages', + width: 'auto', + flexGrow: 1, + flexShrink: 1, + maxLength: 100, + backgroundColor: theme.bg.base, + textColor: theme.fg.primary, + placeholderColor: theme.fg.muted, + focusedTextColor: theme.fg.primary, + focusedBackgroundColor: theme.bg.base, + cursorColor: theme.accent, + showCursor: false, + }), + ) as InputRenderable + + const spinner = instantiate( renderer, Text({ - content: `Ready: OpenTUI npmx-tui ${version}`, - fg: theme.status.success, - bg: theme.bg.surface, - attributes: TextAttributes.BOLD, + content: '', + fg: theme.accent, + bg: theme.bg.base, + width: 1, height: 1, }), ) as TextRenderable - const hint = instantiate( + const inputRow = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + flexDirection: 'row', + gap: 1, + width: '100%', + height: 1, + }, + prompt, + input, + spinner, + ), + ) as BoxRenderable + + const searchPanel = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + border: true, + borderStyle: 'single', + borderColor: theme.border.normal, + focusedBorderColor: theme.border.focused, + title: ' packages ', + titleColor: theme.fg.secondary, + bottomTitle: 'npm registry', + bottomTitleAlignment: 'right', + flexDirection: 'column', + paddingX: 1, + width: '100%', + height: 3, + }, + inputRow, + ), + ) as BoxRenderable + + const collectionList = instantiate( renderer, Text({ - content: 'Use left/right arrows to switch, Enter to activate.', + content: createCollectionListText(state, theme), fg: theme.fg.secondary, - bg: theme.bg.surface, - height: 1, + bg: theme.bg.base, + width: '100%', + height: 'auto', + flexGrow: 1, + wrapMode: 'none', + truncate: true, }), ) as TextRenderable - const tabSelect = instantiate( + const resultsFooter = instantiate( renderer, - TabSelect({ - options: buttons, - tabWidth: 16, - width: 36, - height: 3, - wrapSelection: true, - showDescription: false, - showUnderline: true, - backgroundColor: theme.bg.surface, - textColor: theme.fg.secondary, - selectedTextColor: theme.fg.primary, - selectedBackgroundColor: theme.bg.selected, - focusedTextColor: theme.fg.primary, - focusedBackgroundColor: theme.bg.selected, - selectedDescriptionColor: theme.fg.muted, + Text({ + content: createResultsFooterText(state, theme), + fg: theme.fg.muted, + bg: theme.bg.base, + height: 2, + truncate: true, }), - ) as TabSelectRenderable + ) as TextRenderable - tabSelect.on(TabSelectRenderableEvents.SELECTION_CHANGED, (_index, selected) => { - status.content = `> Selected: ${selected?.name ?? 'none'}` - }) + const collectionPane = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + border: true, + borderStyle: 'single', + borderColor: theme.border.normal, + focusedBorderColor: theme.border.focused, + title: ' Results ', + titleColor: theme.fg.secondary, + flexDirection: 'column', + paddingX: 1, + paddingY: 0, + width: '100%', + height: 'auto', + flexGrow: 1, + }, + collectionList, + resultsFooter, + ), + ) as BoxRenderable - tabSelect.on(TabSelectRenderableEvents.ITEM_SELECTED, (_index, selected) => { - status.content = `> Activated: ${selected?.name ?? 'none'}` - }) + const inspector = instantiate( + renderer, + Text({ + content: createStyledInspectorText(createInspectorLines(undefined, state), theme), + fg: theme.fg.secondary, + bg: theme.bg.base, + height: 'auto', + flexGrow: 1, + wrapMode: 'word', + truncate: false, + }), + ) as TextRenderable - const panel = instantiate( + const inspectorPane = instantiate( renderer, Box( { - borderStyle: 'rounded', + backgroundColor: theme.bg.base, + border: true, + borderStyle: 'single', borderColor: theme.border.normal, focusedBorderColor: theme.border.focused, - backgroundColor: theme.bg.surface, - title: 'npMx', + title: ' Preview ', titleColor: theme.fg.secondary, - padding: 1, flexDirection: 'column', + paddingX: 1, + paddingY: 1, + width: '57%', + height: '100%', + }, + inspector, + ), + ) as BoxRenderable + + const leftPane = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + flexDirection: 'column', + gap: 1, + width: '43%', + height: '100%', + }, + searchPanel, + collectionPane, + ), + ) as BoxRenderable + + const workspace = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + flexDirection: 'row', gap: 1, - width: 44, - height: 10, + width: '100%', + height: 'auto', + flexGrow: 1, }, - title, - hint, - tabSelect, - status, + leftPane, + inspectorPane, ), ) as BoxRenderable + const statusBar = instantiate( + renderer, + Text({ + content: createShortcutBarText(state, theme), + fg: theme.fg.muted, + bg: theme.bg.elevated, + height: 1, + truncate: true, + }), + ) as TextRenderable + + const shell = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + flexDirection: 'column', + width: '100%', + height: '100%', + }, + workspace, + statusBar, + ), + ) as BoxRenderable + + function setStatus(message: string, kind: StatusKind = 'info'): void { + state.statusMessage = message + state.statusKind = kind + statusBar.content = createShortcutBarText(state, theme) + statusBar.fg = theme.status[kind] + } + + function updateStatusBar(): void { + statusBar.content = createShortcutBarText(state, theme) + statusBar.fg = theme.status[state.statusKind] + } + + function updateCollectionTitle(): void { + collectionPane.title = ' Results ' + searchPanel.bottomTitle = state.query.trim() + ? `${state.results.length}/${state.total}` + : 'npm registry' + resultsFooter.content = createResultsFooterText(state, theme) + } + + function updateFocusStyles(): void { + searchPanel.titleColor = state.focus === 'search' ? theme.accent : theme.fg.secondary + searchPanel.borderColor = state.focus === 'search' ? theme.accent : theme.border.normal + collectionPane.titleColor = state.focus === 'collection' ? theme.accent : theme.fg.secondary + collectionPane.borderColor = state.focus === 'collection' ? theme.accent : theme.border.normal + inspectorPane.titleColor = state.focus === 'inspector' ? theme.accent : theme.fg.secondary + inspectorPane.borderColor = state.focus === 'inspector' ? theme.accent : theme.border.normal + + updateStatusBar() + } + + function updateInspector(): void { + const pkg = selectedPackage(state) + const detail = pkg ? detailCache.get(pkg.name) : undefined + const content = createInspectorLines(pkg, state, detail, detailStatus, detailError) + const viewportHeight = Math.max(1, inspector.height || 1) + + state.inspectorScrollOffset = Math.min( + state.inspectorScrollOffset, + getMaxInspectorScrollOffset(content, viewportHeight), + ) + + inspectorPane.title = pkg ? ` ${truncateText(pkg.name, 64)} ` : ' Preview ' + inspector.content = createStyledInspectorText( + createScrollableLines(content, state.inspectorScrollOffset, viewportHeight), + theme, + ) + updateFocusStyles() + } + + function updateCollection(): void { + const collectionWidth = Math.max(1, Number(collectionList.width) || 80) + const collectionHeight = Math.max(1, Number(collectionList.height) || 12) + state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, state.results.length - 1)) + collectionList.content = createCollectionListText( + state, + theme, + collectionWidth, + collectionHeight, + ) + updateCollectionTitle() + updateInspector() + } + + async function loadSelectedPackageDetails(): Promise { + const pkg = selectedPackage(state) + activeDetailRequest?.abort() + detailRequestId += 1 + + if (!pkg) { + detailStatus = 'idle' + detailError = undefined + updateInspector() + return + } + + if (detailCache.has(pkg.name)) { + detailStatus = 'success' + detailError = undefined + updateInspector() + return + } + + const currentDetailRequestId = detailRequestId + const controller = new AbortController() + activeDetailRequest = controller + detailStatus = 'loading' + detailError = undefined + updateInspector() + + try { + const detail = await getPackageDetails({ + baseUrl: apiBaseUrl, + name: pkg.name, + signal: controller.signal, + }) + + if (currentDetailRequestId !== detailRequestId) { + return + } + + detailCache.set(pkg.name, detail) + detailStatus = 'success' + detailError = undefined + updateInspector() + } catch (error) { + if ( + controller.signal.aborted || + isAbortError(error) || + currentDetailRequestId !== detailRequestId + ) { + return + } + + detailStatus = 'error' + detailError = error instanceof Error ? error.message : String(error) + updateInspector() + } + } + + function applyLayout(): void { + state.layout = renderer.terminalWidth >= SPLIT_LAYOUT_MIN_WIDTH ? 'split' : 'single' + + if (state.layout === 'split') { + state.view = 'collection' + workspace.flexDirection = 'row' + leftPane.visible = true + inspectorPane.visible = true + leftPane.width = '43%' + inspectorPane.width = '57%' + leftPane.flexGrow = 0 + inspectorPane.flexGrow = 0 + } else { + workspace.flexDirection = 'column' + const showingInspector = state.view === 'inspector' + leftPane.visible = !showingInspector + inspectorPane.visible = showingInspector + leftPane.width = '100%' + inspectorPane.width = '100%' + leftPane.flexGrow = showingInspector ? 0 : 1 + inspectorPane.flexGrow = showingInspector ? 1 : 0 + } + + updateCollection() + updateStatusBar() + } + + function stopSpinner(): void { + if (spinnerTimer) { + clearInterval(spinnerTimer) + spinnerTimer = undefined + } + + spinner.content = '' + } + + function startSpinner(): void { + stopSpinner() + spinnerFrame = 0 + spinner.content = BRAILLE_SPINNER_FRAMES[spinnerFrame] ?? '' + + spinnerTimer = setInterval(() => { + spinnerFrame += 1 + spinner.content = BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length] ?? '' + }, SPINNER_FRAME_MS) + } + + function enterInsertMode(): void { + state.mode = 'insert' + state.focus = 'search' + if (state.layout === 'single') { + state.view = 'collection' + } + input.showCursor = true + input.focus() + setStatus('Insert mode') + applyLayout() + } + + function enterNormalMode(): void { + state.mode = 'normal' + state.focus = 'collection' + input.showCursor = false + input.blur() + updateFocusStyles() + setStatus('Normal mode') + } + + function focusCollection(): void { + state.mode = 'normal' + state.focus = 'collection' + input.showCursor = false + input.blur() + + if (state.layout === 'single') { + state.view = 'collection' + applyLayout() + } else { + updateFocusStyles() + } + + setStatus('Results focused') + } + + function focusInspector(): boolean { + const pkg = selectedPackage(state) + if (!pkg) { + return false + } + + state.mode = 'normal' + state.focus = 'inspector' + input.showCursor = false + input.blur() + + if (state.layout === 'single') { + state.view = 'inspector' + applyLayout() + } else { + updateFocusStyles() + } + + setStatus(`Details focused: ${pkg.name}@${pkg.version}`) + return true + } + + function moveSelection(direction: 'up' | 'down'): void { + if (state.results.length === 0) { + return + } + + if (direction === 'up') { + state.selectedIndex = Math.max(0, state.selectedIndex - 1) + } else { + state.selectedIndex = Math.min(state.results.length - 1, state.selectedIndex + 1) + } + + state.inspectorScrollOffset = 0 + updateCollection() + void loadSelectedPackageDetails() + const pkg = selectedPackage(state) + if (pkg) { + setStatus(`${pkg.name}@${pkg.version}`) + } + } + + function scrollInspector(direction: 'up' | 'down', amount = 1): void { + const pkg = selectedPackage(state) + if (!pkg) { + return + } + + const detail = detailCache.get(pkg.name) + const content = createInspectorLines(pkg, state, detail, detailStatus, detailError) + const viewportHeight = Math.max(1, inspector.height || 1) + const maxOffset = getMaxInspectorScrollOffset(content, viewportHeight) + const nextOffset = + direction === 'up' + ? Math.max(0, state.inspectorScrollOffset - amount) + : Math.min(maxOffset, state.inspectorScrollOffset + amount) + + if (nextOffset === state.inspectorScrollOffset) { + setStatus(direction === 'up' ? 'Top of details' : 'End of details') + return + } + + state.inspectorScrollOffset = nextOffset + updateInspector() + setStatus(`Details ${state.inspectorScrollOffset + 1}/${maxOffset + 1}`) + } + + function openSelection(): void { + const pkg = selectedPackage(state) + if (!pkg) { + return + } + + state.inspectorScrollOffset = 0 + focusInspector() + setStatus(`Previewing ${pkg.name}@${pkg.version}`) + } + + function showCollection(): boolean { + if (state.focus === 'inspector' || (state.layout === 'single' && state.view === 'inspector')) { + focusCollection() + return true + } + + return false + } + + function pageResults(direction: 'previous' | 'next'): boolean { + if ( + !state.query.trim() || + state.searchStatus === 'searching' || + state.searchStatus === 'debouncing' + ) { + return false + } + + const nextOffset = + direction === 'previous' + ? Math.max(0, state.pageOffset - SEARCH_RESULT_LIMIT) + : state.pageOffset + SEARCH_RESULT_LIMIT + + if (direction === 'previous' && !hasPreviousResultsPage(state)) { + setStatus('First results page') + return true + } + + if (direction === 'next' && !hasNextResultsPage(state)) { + setStatus('Last results page') + return true + } + + state.inspectorScrollOffset = 0 + void runSearch(state.query, nextOffset) + return true + } + + async function runSearch(query: string, pageOffset = state.pageOffset): Promise { + const trimmed = query.trim() + const currentRequestId = ++requestId + const nextPageOffset = Math.max(0, pageOffset) + activeRequest?.abort() + activeDetailRequest?.abort() + detailRequestId += 1 + detailStatus = 'idle' + detailError = undefined + + if (!trimmed) { + stopSpinner() + state.query = '' + state.searchStatus = 'idle' + state.results = [] + state.total = 0 + state.pageOffset = 0 + state.selectedIndex = 0 + state.inspectorScrollOffset = 0 + state.errorMessage = undefined + updateCollection() + setStatus(state.mode === 'insert' ? 'Insert mode' : 'Normal mode') + return + } + + const controller = new AbortController() + activeRequest = controller + state.searchStatus = 'searching' + state.errorMessage = undefined + startSpinner() + updateCollection() + setStatus(`Searching "${truncateText(trimmed, 48)}"`) + + try { + const response = await searchPackages({ + baseUrl: apiBaseUrl, + query: trimmed, + size: SEARCH_RESULT_LIMIT, + from: nextPageOffset, + signal: controller.signal, + }) + + if (currentRequestId !== requestId) { + return + } + + stopSpinner() + state.results = response.results + state.total = response.total + state.pageOffset = nextPageOffset + state.selectedIndex = 0 + state.inspectorScrollOffset = 0 + state.searchStatus = response.results.length > 0 ? 'success' : 'empty' + state.errorMessage = undefined + updateCollection() + void loadSelectedPackageDetails() + setStatus( + response.results.length > 0 + ? `${response.results.length} packages found` + : `No packages found for "${truncateText(trimmed, 48)}"`, + response.results.length > 0 ? 'success' : 'warning', + ) + } catch (error) { + if (controller.signal.aborted || isAbortError(error) || currentRequestId !== requestId) { + return + } + + stopSpinner() + state.searchStatus = 'error' + state.errorMessage = error instanceof Error ? error.message : String(error) + updateCollection() + setStatus('Package search failed', 'danger') + } + } + + function scheduleSearch(): void { + state.query = input.value + state.pageOffset = 0 + + if (debounceTimer) { + clearTimeout(debounceTimer) + } + + const trimmed = state.query.trim() + if (!trimmed) { + void runSearch(state.query, 0) + return + } + + stopSpinner() + state.searchStatus = 'debouncing' + updateCollectionTitle() + updateStatusBar() + + debounceTimer = setTimeout(() => { + debounceTimer = undefined + void runSearch(state.query, 0) + }, SEARCH_DEBOUNCE_MS) + } + + input.on(InputRenderableEvents.INPUT, scheduleSearch) + + function handleNormalKey(key: KeyEvent): boolean { + if (isPlainKey(key, '/') || isPlainKey(key, 'i')) { + enterInsertMode() + return true + } + + if (isPlainKey(key, 'escape')) { + return showCollection() + } + + if (isPlainKey(key, 'h') || isPlainKey(key, 'left')) { + focusCollection() + return true + } + + if (isPlainKey(key, 'l') || isPlainKey(key, 'right')) { + return focusInspector() + } + + if (isPlainKey(key, 'return')) { + openSelection() + return true + } + + if (isPlainKey(key, '[') || isCtrlKey(key, 'u')) { + return pageResults('previous') + } + + if (isPlainKey(key, ']') || isCtrlKey(key, 'd')) { + return pageResults('next') + } + + if (isPlainKey(key, 'j') || isPlainKey(key, 'down')) { + if (state.focus === 'inspector') { + scrollInspector('down') + } else { + moveSelection('down') + } + return true + } + + if (isPlainKey(key, 'k') || isPlainKey(key, 'up')) { + if (state.focus === 'inspector') { + scrollInspector('up') + } else { + moveSelection('up') + } + return true + } + + return false + } + + const modeHandler = (key: KeyEvent): void => { + if (state.mode === 'insert') { + if (!isPlainKey(key, 'escape')) { + return + } + + key.preventDefault() + key.stopPropagation() + enterNormalMode() + return + } + + if (!handleNormalKey(key)) { + return + } + + key.preventDefault() + key.stopPropagation() + } + + const quitHandler = (key: KeyEvent): void => { + if (!shouldQuit(key, state.mode)) { + return + } + + key.preventDefault() + key.stopPropagation() + renderer.destroy() + } + + renderer.keyInput.on('keypress', quitHandler) + renderer.keyInput.on('keypress', modeHandler) + renderer.on(CliRenderEvents.RESIZE, applyLayout) + function applyTheme(nextTheme: Theme): void { theme = nextTheme renderer.setBackgroundColor(theme.bg.base) - panel.backgroundColor = theme.bg.surface - panel.borderColor = theme.border.normal - panel.focusedBorderColor = theme.border.focused - panel.titleColor = theme.fg.secondary + shell.backgroundColor = theme.bg.base + searchPanel.backgroundColor = theme.bg.base + searchPanel.focusedBorderColor = theme.border.focused + inputRow.backgroundColor = theme.bg.base + workspace.backgroundColor = theme.bg.base + leftPane.backgroundColor = theme.bg.base + collectionPane.backgroundColor = theme.bg.base + collectionPane.focusedBorderColor = theme.border.focused + inspectorPane.backgroundColor = theme.bg.base + inspectorPane.focusedBorderColor = theme.border.focused + + prompt.fg = theme.accent + prompt.bg = theme.bg.base + input.backgroundColor = theme.bg.base + input.textColor = theme.fg.primary + input.placeholderColor = theme.fg.muted + input.focusedTextColor = theme.fg.primary + input.focusedBackgroundColor = theme.bg.base + input.cursorColor = theme.accent + spinner.fg = theme.accent + spinner.bg = theme.bg.base - title.fg = theme.status.success - title.bg = theme.bg.surface - hint.fg = theme.fg.secondary - hint.bg = theme.bg.surface - status.fg = theme.fg.muted - status.bg = theme.bg.surface + collectionList.fg = theme.fg.secondary + collectionList.bg = theme.bg.base + collectionList.content = createCollectionListText( + state, + theme, + Math.max(1, Number(collectionList.width) || 80), + Math.max(1, Number(collectionList.height) || 12), + ) - tabSelect.backgroundColor = theme.bg.surface - tabSelect.textColor = theme.fg.secondary - tabSelect.selectedTextColor = theme.fg.primary - tabSelect.selectedBackgroundColor = theme.bg.selected - tabSelect.focusedTextColor = theme.fg.primary - tabSelect.focusedBackgroundColor = theme.bg.selected - tabSelect.selectedDescriptionColor = theme.fg.muted + inspector.fg = theme.fg.secondary + inspector.bg = theme.bg.base + resultsFooter.bg = theme.bg.base + resultsFooter.content = createResultsFooterText(state, theme) + statusBar.bg = theme.bg.elevated + statusBar.fg = theme.status[state.statusKind] + updateInspector() } applyTheme(theme) themeManager.subscribe(applyTheme) renderer.on(CliRenderEvents.DESTROY, () => { + if (debounceTimer) { + clearTimeout(debounceTimer) + } + + stopSpinner() + activeRequest?.abort() + input.off(InputRenderableEvents.INPUT, scheduleSearch) + renderer.keyInput.off('keypress', modeHandler) renderer.keyInput.off('keypress', quitHandler) + renderer.off(CliRenderEvents.RESIZE, applyLayout) themeManager.dispose() }) - renderer.root.add(panel) - - tabSelect.focus() + renderer.root.add(shell) + applyLayout() + updateCollection() + enterNormalMode() } export { createThemeManager } diff --git a/tui/src/search.ts b/tui/src/search.ts new file mode 100644 index 0000000000..523d8d5b45 --- /dev/null +++ b/tui/src/search.ts @@ -0,0 +1,179 @@ +export interface PackageLinks { + npm?: string + homepage?: string + repository?: string + bugs?: string +} + +export interface PackagePerson { + name?: string + username?: string + email?: string + url?: string +} + +export interface PackageSearchResult { + name: string + version: string + description: string + weeklyDownloads?: number + keywords: string[] + license?: string + links?: PackageLinks + maintainers: PackagePerson[] +} + +export interface PackageDetails extends PackageSearchResult { + date?: string + modified?: string + created?: string + distTags?: Record + versionCount?: number + deprecated?: string + unpackedSize?: number + author?: PackagePerson + entryPoints?: { + type?: string + main?: string + module?: string + types?: string + hasExports?: boolean + binNames?: string[] + engines?: Record + dependenciesCount?: number + peerDependenciesCount?: number + } +} + +interface NpmSearchPackage { + name: string + version: string + description?: string + keywords?: string[] + license?: string + links?: PackageLinks + maintainers?: PackagePerson[] +} + +interface NpmSearchResult { + package: NpmSearchPackage + downloads?: { + weekly?: number + } +} + +interface NpmSearchResponse { + objects: NpmSearchResult[] + total: number +} + +type PackageDetailsResponse = Partial & Pick + +export interface PackageSearchResponse { + results: PackageSearchResult[] + total: number +} + +export interface SearchPackagesOptions { + baseUrl: string + query: string + size?: number + from?: number + signal?: AbortSignal +} + +export interface GetPackageDetailsOptions { + baseUrl: string + name: string + signal?: AbortSignal +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` +} + +function encodePackagePath(name: string): string { + return name.split('/').map(encodeURIComponent).join('/') +} + +export function getDefaultApiBaseUrl(): string { + return process.env.NPMX_API_BASE_URL ?? 'http://127.0.0.1:3000' +} + +function normalizePackageDetails(data: PackageDetailsResponse): PackageDetails { + return { + name: data.name, + version: data.version, + description: data.description ?? 'No description provided.', + weeklyDownloads: data.weeklyDownloads, + keywords: data.keywords ?? [], + license: data.license, + links: data.links, + maintainers: data.maintainers ?? [], + date: data.date, + modified: data.modified, + created: data.created, + distTags: data.distTags, + versionCount: data.versionCount, + deprecated: data.deprecated, + unpackedSize: data.unpackedSize, + author: data.author, + entryPoints: data.entryPoints, + } +} + +export async function searchPackages({ + baseUrl, + query, + size = 12, + from = 0, + signal, +}: SearchPackagesOptions): Promise { + const trimmed = query.trim() + if (!trimmed) { + return { results: [], total: 0 } + } + + const url = new URL('api/registry/search', normalizeBaseUrl(baseUrl)) + url.searchParams.set('q', trimmed) + url.searchParams.set('size', String(size)) + url.searchParams.set('from', String(Math.max(0, from))) + + const response = await fetch(url, { signal }) + if (!response.ok) { + throw new Error(`Package search failed: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as NpmSearchResponse + return { + total: data.total, + results: data.objects.map(result => ({ + name: result.package.name, + version: result.package.version, + description: result.package.description ?? 'No description provided.', + weeklyDownloads: result.downloads?.weekly, + keywords: result.package.keywords ?? [], + license: result.package.license, + links: result.package.links, + maintainers: result.package.maintainers ?? [], + })), + } +} + +export async function getPackageDetails({ + baseUrl, + name, + signal, +}: GetPackageDetailsOptions): Promise { + const url = new URL( + `api/registry/package-meta/${encodePackagePath(name)}`, + normalizeBaseUrl(baseUrl), + ) + + const response = await fetch(url, { signal }) + if (!response.ok) { + throw new Error(`Package details failed: ${response.status} ${response.statusText}`) + } + + return normalizePackageDetails((await response.json()) as PackageDetailsResponse) +} From f29e21063591978c295adbac603cd1af391dfbbf Mon Sep 17 00:00:00 2001 From: Atriiy Date: Thu, 20 Aug 2026 22:45:52 +0800 Subject: [PATCH 07/11] feat: adjust background color --- tui/src/index.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tui/src/index.ts b/tui/src/index.ts index 3ece135389..0a8cefeb02 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -628,10 +628,17 @@ function contextActions(state: AppState): ShortcutAction[] { ] } -function createShortcutBarText(state: AppState, theme: Theme): StyledText { +function createShortcutBarText(state: AppState, theme: Theme, width = 0): StyledText { const chunks: TextChunk[] = [] + const actions = contextActions(state) + const brand = './npmx' + const shortcutsLength = actions.reduce( + (length, action, index) => + length + (index > 0 ? 3 : 0) + action.key.length + 1 + action.label.length, + 0, + ) - contextActions(state).forEach((action, index) => { + actions.forEach((action, index) => { if (index > 0) { chunks.push(fg(theme.fg.muted)(' ')) } @@ -640,8 +647,9 @@ function createShortcutBarText(state: AppState, theme: Theme): StyledText { chunks.push(fg(theme.fg.secondary)(` ${action.label}`)) }) - chunks.push(fg(theme.fg.muted)(' ')) - chunks.push(fg(theme.fg.primary)(bold('./npmx'))) + const spacerWidth = width - shortcutsLength - brand.length + chunks.push(fg(theme.fg.muted)(spacerWidth > 0 ? ' '.repeat(spacerWidth) : ' ')) + chunks.push(fg(theme.fg.primary)(bold(brand))) return new StyledText(chunks) } @@ -913,9 +921,9 @@ export async function runTui(options: RunTuiOptions = {}): Promise { const statusBar = instantiate( renderer, Text({ - content: createShortcutBarText(state, theme), + content: createShortcutBarText(state, theme, renderer.terminalWidth), fg: theme.fg.muted, - bg: theme.bg.elevated, + bg: theme.bg.base, height: 1, truncate: true, }), @@ -935,15 +943,19 @@ export async function runTui(options: RunTuiOptions = {}): Promise { ), ) as BoxRenderable + function getStatusBarWidth(): number { + return Math.max(1, Number(statusBar.width) || renderer.terminalWidth) + } + function setStatus(message: string, kind: StatusKind = 'info'): void { state.statusMessage = message state.statusKind = kind - statusBar.content = createShortcutBarText(state, theme) + statusBar.content = createShortcutBarText(state, theme, getStatusBarWidth()) statusBar.fg = theme.status[kind] } function updateStatusBar(): void { - statusBar.content = createShortcutBarText(state, theme) + statusBar.content = createShortcutBarText(state, theme, getStatusBarWidth()) statusBar.fg = theme.status[state.statusKind] } @@ -1483,7 +1495,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { inspector.bg = theme.bg.base resultsFooter.bg = theme.bg.base resultsFooter.content = createResultsFooterText(state, theme) - statusBar.bg = theme.bg.elevated + statusBar.bg = theme.bg.base statusBar.fg = theme.status[state.statusKind] updateInspector() } From c5f4e24f2fb2232338f553604d80d4d52ce681e5 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Thu, 20 Aug 2026 23:11:45 +0800 Subject: [PATCH 08/11] feat: optimize shortcut keys --- tui/src/index.ts | 124 +++++++++++++++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 43 deletions(-) diff --git a/tui/src/index.ts b/tui/src/index.ts index 0a8cefeb02..db1d143e11 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -103,8 +103,8 @@ function isCtrlKey(key: KeyEvent, name: string): boolean { ) } -function shouldQuit(key: KeyEvent, mode: AppMode): boolean { - return mode === 'normal' && isPlainKey(key, 'q') +function shouldQuit(key: KeyEvent, state: AppState): boolean { + return state.focus !== 'search' && isPlainKey(key, 'q') } function isAbortError(error: unknown): boolean { @@ -593,10 +593,12 @@ interface ShortcutAction { } function contextActions(state: AppState): ShortcutAction[] { - if (state.mode === 'insert') { + if (state.focus === 'search') { return [ - { key: 'esc', label: 'Normal' }, - { key: '^c', label: 'Quit' }, + { key: 'type', label: 'Search' }, + { key: 'enter', label: 'Results' }, + { key: '↑/↓', label: 'Results' }, + { key: 'esc', label: 'Cancel' }, ] } @@ -701,8 +703,8 @@ export async function runTui(options: RunTuiOptions = {}): Promise { let theme = themeManager.theme const state: AppState = { - mode: 'normal', - focus: 'collection', + mode: 'insert', + focus: 'search', layout: renderer.terminalWidth >= SPLIT_LAYOUT_MIN_WIDTH ? 'split' : 'single', view: 'collection', query: '', @@ -713,7 +715,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { selectedIndex: 0, inspectorScrollOffset: 0, statusKind: 'info', - statusMessage: 'Normal mode', + statusMessage: 'Search focused', } let debounceTimer: ReturnType | undefined @@ -752,7 +754,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { focusedTextColor: theme.fg.primary, focusedBackgroundColor: theme.bg.base, cursorColor: theme.accent, - showCursor: false, + showCursor: true, }), ) as InputRenderable @@ -1114,7 +1116,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { }, SPINNER_FRAME_MS) } - function enterInsertMode(): void { + function focusSearch(): void { state.mode = 'insert' state.focus = 'search' if (state.layout === 'single') { @@ -1122,19 +1124,10 @@ export async function runTui(options: RunTuiOptions = {}): Promise { } input.showCursor = true input.focus() - setStatus('Insert mode') + setStatus('Search focused') applyLayout() } - function enterNormalMode(): void { - state.mode = 'normal' - state.focus = 'collection' - input.showCursor = false - input.blur() - updateFocusStyles() - setStatus('Normal mode') - } - function focusCollection(): void { state.mode = 'normal' state.focus = 'collection' @@ -1238,6 +1231,25 @@ export async function runTui(options: RunTuiOptions = {}): Promise { return false } + function focusResultsFromSearch(position: 'current' | 'first' | 'last' = 'current'): boolean { + if (state.results.length === 0) { + setStatus('No results to focus') + return true + } + + if (position === 'first') { + state.selectedIndex = 0 + } else if (position === 'last') { + state.selectedIndex = state.results.length - 1 + } + + state.inspectorScrollOffset = 0 + focusCollection() + updateCollection() + void loadSelectedPackageDetails() + return true + } + function pageResults(direction: 'previous' | 'next'): boolean { if ( !state.query.trim() || @@ -1288,7 +1300,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { state.inspectorScrollOffset = 0 state.errorMessage = undefined updateCollection() - setStatus(state.mode === 'insert' ? 'Insert mode' : 'Normal mode') + setStatus(state.focus === 'search' ? 'Search focused' : 'Results focused') return } @@ -1369,23 +1381,56 @@ export async function runTui(options: RunTuiOptions = {}): Promise { input.on(InputRenderableEvents.INPUT, scheduleSearch) - function handleNormalKey(key: KeyEvent): boolean { - if (isPlainKey(key, '/') || isPlainKey(key, 'i')) { - enterInsertMode() + function handleAppKey(key: KeyEvent): boolean { + if (state.focus !== 'search' && isPlainKey(key, '/')) { + focusSearch() return true } if (isPlainKey(key, 'escape')) { + if (state.focus === 'search') { + return focusResultsFromSearch() + } + + if (state.focus === 'collection') { + focusSearch() + return true + } + return showCollection() } - if (isPlainKey(key, 'h') || isPlainKey(key, 'left')) { - focusCollection() - return true + if (state.focus === 'search') { + if (isPlainKey(key, 'return')) { + return focusResultsFromSearch('first') + } + + if (isPlainKey(key, 'down')) { + return focusResultsFromSearch('first') + } + + if (isPlainKey(key, 'up')) { + return focusResultsFromSearch('last') + } + + if (isPlainKey(key, '[') || isCtrlKey(key, 'u')) { + return pageResults('previous') + } + + if (isPlainKey(key, ']') || isCtrlKey(key, 'd')) { + return pageResults('next') + } + + return false } - if (isPlainKey(key, 'l') || isPlainKey(key, 'right')) { - return focusInspector() + if (isPlainKey(key, 'h') || isPlainKey(key, 'left')) { + if (state.focus === 'inspector') { + focusCollection() + } else { + focusSearch() + } + return true } if (isPlainKey(key, 'return')) { @@ -1393,6 +1438,10 @@ export async function runTui(options: RunTuiOptions = {}): Promise { return true } + if (isPlainKey(key, 'l') || isPlainKey(key, 'right')) { + return focusInspector() + } + if (isPlainKey(key, '[') || isCtrlKey(key, 'u')) { return pageResults('previous') } @@ -1423,18 +1472,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { } const modeHandler = (key: KeyEvent): void => { - if (state.mode === 'insert') { - if (!isPlainKey(key, 'escape')) { - return - } - - key.preventDefault() - key.stopPropagation() - enterNormalMode() - return - } - - if (!handleNormalKey(key)) { + if (!handleAppKey(key)) { return } @@ -1443,7 +1481,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { } const quitHandler = (key: KeyEvent): void => { - if (!shouldQuit(key, state.mode)) { + if (!shouldQuit(key, state)) { return } @@ -1519,7 +1557,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { renderer.root.add(shell) applyLayout() updateCollection() - enterNormalMode() + focusSearch() } export { createThemeManager } From 4e8ed7609244bd400f2566ebac58c8d795771898 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Thu, 20 Aug 2026 23:31:56 +0800 Subject: [PATCH 09/11] feat: optimize ui layout --- tui/src/index.ts | 111 ++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/tui/src/index.ts b/tui/src/index.ts index db1d143e11..f23ab5da49 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -36,6 +36,7 @@ export interface RunTuiOptions { const SEARCH_DEBOUNCE_MS = 500 const SEARCH_RESULT_LIMIT = 25 const LIST_SCROLLBAR_WIDTH = 2 +const STATUS_BAR_PADDING_X = 1 const SPLIT_LAYOUT_MIN_WIDTH = 100 const SPINNER_FRAME_MS = 90 const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const @@ -199,6 +200,14 @@ function createInlineMeta(items: Array): string { return items.filter(isDefinedString).join(' ') } +function createField(label: string, value: string | number | undefined): string | undefined { + if (value === undefined || value === '') { + return undefined + } + + return `${label.padEnd(13, ' ')} ${value}` +} + function formatRecord( record: Record | undefined, limit: number, @@ -218,7 +227,7 @@ function createBracketSection(title: string, lines: string[]): InspectorLine[] { return [] } - return [{ text: `[${title}]`, tone: 'section' }, ...body.map(line => ({ text: ` ${line}` }))] + return [{ text: title, tone: 'section' }, ...body.map(line => ({ text: ` ${line}` }))] } function getCollectionEmptyLines(state: AppState): Array<{ title: string; detail: string }> { @@ -394,7 +403,6 @@ function createInstallBlock(packageName: string): InspectorLine[] { function createInspectorLines( pkg: PackageSearchResult | undefined, - state: AppState, detail?: PackageDetails, detailStatus: DetailStatus = 'idle', detailError?: string, @@ -425,24 +433,9 @@ function createInspectorLines( linkRows.push(['bugs', data.links.bugs]) } - const primaryMeta = createInlineMeta([ - `latest ${data.version}`, - formatDownloads(data.weeklyDownloads), - data.license ? `license ${data.license}` : undefined, - detail?.unpackedSize ? `size ${formatBytes(detail.unpackedSize)}` : undefined, - ]) - const secondaryMeta = createInlineMeta([ - detail?.date ? `published ${formatDate(detail.date)}` : undefined, - detail?.created ? `created ${formatDate(detail.created)}` : undefined, - detail?.modified ? `modified ${formatDate(detail.modified)}` : undefined, - ]) - const resultMeta = createInlineMeta([ - `result ${state.selectedIndex + 1}/${Math.max(state.results.length, 1)}`, - state.total > 0 ? `${state.total} total matches` : undefined, - ]) - const links = linkRows.map( - ([label, value]) => `${label.padEnd(4, ' ')} ${truncateText(value, 72)}`, - ) + const links = linkRows + .map(([label, value]) => createField(label, truncateText(value, 72))) + .filter(isDefinedString) const keywords = compactList(data.keywords, 10) const maintainers = data.maintainers .map(maintainer => maintainer.username ?? maintainer.name) @@ -460,55 +453,64 @@ function createInspectorLines( tone: 'warning', } satisfies InspectorLine) : undefined - const headerBlock: InspectorLine[] = [ - { text: `${data.name}@${data.version}`, tone: 'title' }, - { text: data.description }, - ] + const summaryMeta = createInlineMeta([ + `latest v${data.version}`, + formatDownloads(data.weeklyDownloads), + data.license, + detail?.unpackedSize ? formatBytes(detail.unpackedSize) : undefined, + ]) + const headerBlock: InspectorLine[] = [{ text: data.name, tone: 'title' }, { text: '' }] if (detail?.deprecated) { headerBlock.push({ text: `deprecated: ${detail.deprecated}`, tone: 'warning' }) } + if (summaryMeta) { + headerBlock.push({ text: summaryMeta, tone: 'muted' }) + } + headerBlock.push({ text: data.description }) if (detailStatusLine) { headerBlock.push(detailStatusLine) } - const qualityRows = [ - `weekly downloads ${formatDownloads(data.weeklyDownloads)}`, - detail?.versionCount ? `versions ${detail.versionCount}` : undefined, - `maintainers ${maintainers.length}`, + const healthRows = [ + createField('downloads', formatDownloads(data.weeklyDownloads)), + createField('versions', detail?.versionCount), + createField('maintainers', maintainers.length), detail?.entryPoints?.dependenciesCount !== undefined - ? `dependencies ${detail.entryPoints.dependenciesCount}` + ? createField('dependencies', detail.entryPoints.dependenciesCount) : undefined, detail?.entryPoints?.peerDependenciesCount !== undefined - ? `peer dependencies ${detail.entryPoints.peerDependenciesCount}` + ? createField('peer deps', detail.entryPoints.peerDependenciesCount) : undefined, - distTags ? `dist-tags ${distTags}` : undefined, + createField('published', detail?.date ? formatDate(detail.date) : undefined), + createField('created', detail?.created ? formatDate(detail.created) : undefined), + createField('modified', detail?.modified ? formatDate(detail.modified) : undefined), + createField('dist-tags', distTags), ].filter(isDefinedString) const runtimeRows = [ - detail?.entryPoints?.type ? `type ${detail.entryPoints.type}` : undefined, - detail?.entryPoints?.main ? `main ${detail.entryPoints.main}` : undefined, - detail?.entryPoints?.module ? `module ${detail.entryPoints.module}` : undefined, - detail?.entryPoints?.types ? `types ${detail.entryPoints.types}` : undefined, + createField('type', detail?.entryPoints?.type), + createField('main', detail?.entryPoints?.main), + createField('module', detail?.entryPoints?.module), + createField('types', detail?.entryPoints?.types), detail?.entryPoints?.hasExports !== undefined - ? `exports ${detail.entryPoints.hasExports ? 'yes' : 'no'}` + ? createField('exports', detail.entryPoints.hasExports ? 'yes' : 'no') : undefined, - binNames.length > 0 ? `bin ${compactList(binNames, 5)}` : undefined, - engineInfo ? `engines ${engineInfo}` : undefined, + createField('bin', binNames.length > 0 ? compactList(binNames, 5) : undefined), + createField('engines', engineInfo), ].filter(isDefinedString) const blocks: InspectorLine[][] = [ headerBlock, - createBracketSection('metadata', [primaryMeta, secondaryMeta, resultMeta]), createInstallBlock(data.name), - createBracketSection('quality', qualityRows), + createBracketSection('health', healthRows), createBracketSection('runtime', runtimeRows), - createBracketSection('keywords', keywords ? [keywords] : []), createBracketSection('links', links), + createBracketSection('keywords', keywords ? [keywords] : []), createBracketSection( 'maintainers', [ - author ? `author ${author}` : undefined, - maintainers.length > 0 ? `team ${compactList(maintainers, 8)}` : undefined, + createField('author', author), + createField('team', maintainers.length > 0 ? compactList(maintainers, 8) : undefined), ].filter(isDefinedString), ), ].filter(block => block.length > 0) @@ -554,7 +556,13 @@ function createStyledInspectorText(lines: InspectorLine[], theme: Theme): Styled } if (line.tone === 'section') { - chunks.push(fg(theme.accent)(bold(text))) + const newline = index === lines.length - 1 ? '' : '\n' + chunks.push(fg(theme.accent)(bold('['))) + chunks.push(fg(theme.fg.primary)(bold(line.text))) + chunks.push(fg(theme.accent)(bold(']'))) + if (newline) { + chunks.push(fg(theme.fg.muted)(newline)) + } return } @@ -634,12 +642,16 @@ function createShortcutBarText(state: AppState, theme: Theme, width = 0): Styled const chunks: TextChunk[] = [] const actions = contextActions(state) const brand = './npmx' + const padding = ' '.repeat(STATUS_BAR_PADDING_X) + const availableWidth = Math.max(0, width - STATUS_BAR_PADDING_X * 2) const shortcutsLength = actions.reduce( (length, action, index) => length + (index > 0 ? 3 : 0) + action.key.length + 1 + action.label.length, 0, ) + chunks.push(fg(theme.fg.muted)(padding)) + actions.forEach((action, index) => { if (index > 0) { chunks.push(fg(theme.fg.muted)(' ')) @@ -649,9 +661,10 @@ function createShortcutBarText(state: AppState, theme: Theme, width = 0): Styled chunks.push(fg(theme.fg.secondary)(` ${action.label}`)) }) - const spacerWidth = width - shortcutsLength - brand.length + const spacerWidth = availableWidth - shortcutsLength - brand.length chunks.push(fg(theme.fg.muted)(spacerWidth > 0 ? ' '.repeat(spacerWidth) : ' ')) chunks.push(fg(theme.fg.primary)(bold(brand))) + chunks.push(fg(theme.fg.muted)(padding)) return new StyledText(chunks) } @@ -858,7 +871,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { const inspector = instantiate( renderer, Text({ - content: createStyledInspectorText(createInspectorLines(undefined, state), theme), + content: createStyledInspectorText(createInspectorLines(undefined), theme), fg: theme.fg.secondary, bg: theme.bg.base, height: 'auto', @@ -983,7 +996,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { function updateInspector(): void { const pkg = selectedPackage(state) const detail = pkg ? detailCache.get(pkg.name) : undefined - const content = createInspectorLines(pkg, state, detail, detailStatus, detailError) + const content = createInspectorLines(pkg, detail, detailStatus, detailError) const viewportHeight = Math.max(1, inspector.height || 1) state.inspectorScrollOffset = Math.min( @@ -991,7 +1004,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { getMaxInspectorScrollOffset(content, viewportHeight), ) - inspectorPane.title = pkg ? ` ${truncateText(pkg.name, 64)} ` : ' Preview ' + inspectorPane.title = ' Preview ' inspector.content = createStyledInspectorText( createScrollableLines(content, state.inspectorScrollOffset, viewportHeight), theme, @@ -1193,7 +1206,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { } const detail = detailCache.get(pkg.name) - const content = createInspectorLines(pkg, state, detail, detailStatus, detailError) + const content = createInspectorLines(pkg, detail, detailStatus, detailError) const viewportHeight = Math.max(1, inspector.height || 1) const maxOffset = getMaxInspectorScrollOffset(content, viewportHeight) const nextOffset = From 4eb5d274dd55edb02091107011d17ab202814268 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Sat, 22 Aug 2026 15:38:48 +0800 Subject: [PATCH 10/11] chore: optimize local dev of tui --- package.json | 4 +- tui/README.md | 13 +- tui/package.json | 7 +- tui/scripts/dev-local.ts | 298 --------------------------------------- tui/scripts/dev.ts | 62 -------- tui/src/cli.ts | 79 ++++++++++- 6 files changed, 86 insertions(+), 377 deletions(-) delete mode 100644 tui/scripts/dev-local.ts delete mode 100644 tui/scripts/dev.ts diff --git a/package.json b/package.json index 2da381547b..56d9e844ad 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ "build:test": "TEST=1 vp run build", "dev": "nuxt dev", "dev:docs": "vp run --filter npmx-docs dev --port=3001", - "npmx-tui": "vp run --filter npmx-tui dev:local", - "npmx-tui:solo": "vp run --filter npmx-tui dev:ffi", - "npmx-tui:watch": "vp run --filter npmx-tui dev:watch", + "npmx-tui": "vp run --filter npmx-tui dev", "i18n:check:fix": "node scripts/compare-translations.ts --fix", "i18n:report:fix": "node scripts/remove-unused-translations.ts", "knip:fix": "knip --fix", diff --git a/tui/README.md b/tui/README.md index a65ad99f18..574ffaa7b5 100644 --- a/tui/README.md +++ b/tui/README.md @@ -11,20 +11,19 @@ OpenTUI's native renderer requires Node.js 26.4.0+ with experimental FFI enabled From the repository root: ```bash -pnpm npmx-tui +pnpm dev ``` -Or from this package: +Then, in another terminal: ```bash -cd tui -pnpm dev +pnpm npmx-tui ``` -`pnpm dev` starts the TUI in watch mode. Use the left and right arrow keys to switch between the two demo buttons, press Enter to activate the current button, and press Ctrl+C to exit. +`pnpm dev` starts the local npmx.dev backend. `pnpm npmx-tui` starts the TUI, which connects to `http://127.0.0.1:3000` by default. Use Ctrl+C to exit. -For a single run without watch: +For TUI watch mode: ```bash -pnpm --filter npmx-tui dev:ffi +pnpm npmx-tui:watch ``` diff --git a/tui/package.json b/tui/package.json index 84bc33410a..ce8d8ec3aa 100644 --- a/tui/package.json +++ b/tui/package.json @@ -24,11 +24,8 @@ }, "scripts": { "build": "vp pack", - "dev": "node scripts/dev-local.ts", - "dev:local": "node scripts/dev-local.ts", - "dev:ffi": "node --experimental-ffi src/cli.ts", - "dev:once": "node src/cli.ts", - "dev:watch": "node scripts/dev.ts", + "dev": "node src/cli.ts", + "dev:watch": "node --watch src/cli.ts", "test:types": "tsc --noEmit" }, "dependencies": { diff --git a/tui/scripts/dev-local.ts b/tui/scripts/dev-local.ts deleted file mode 100644 index a0ee936613..0000000000 --- a/tui/scripts/dev-local.ts +++ /dev/null @@ -1,298 +0,0 @@ -import process from 'node:process' -import { spawn, type ChildProcess } from 'node:child_process' -import { Socket } from 'node:net' -import { parseArgs } from 'node:util' -import { fileURLToPath } from 'node:url' - -const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] -const DEFAULT_PORT = 3000 -const DEFAULT_READY_TIMEOUT_MS = 45_000 -const READY_CHECK_INTERVAL_MS = 500 -const READY_CHECK_TIMEOUT_MS = 1000 - -interface ManagedServer { - process: ChildProcess | null - started: boolean - logs: string[] -} - -interface TcpEndpoint { - host: string - port: number -} - -function parseNodeVersion(version: string): [number, number, number] { - const [major = 0, minor = 0, patch = 0] = version - .replace(/^v/, '') - .split('.') - .map(part => Number.parseInt(part, 10) || 0) - - return [major, minor, patch] -} - -function isAtLeastVersion( - actual: [number, number, number], - minimum: [number, number, number], -): boolean { - for (let index = 0; index < minimum.length; index += 1) { - if (actual[index] > minimum[index]) { - return true - } - - if (actual[index] < minimum[index]) { - return false - } - } - - return true -} - -function assertCompatibleNodeVersion(): void { - const nodeVersion = parseNodeVersion(process.version) - - if (isAtLeastVersion(nodeVersion, MIN_NODE_VERSION)) { - return - } - - console.error(`OpenTUI local dev mode requires Node.js 26.4.0+ with experimental FFI. - -Current Node.js: ${process.version} - -Use a compatible runtime, then run: - - pnpm npmx-tui`) - process.exit(1) -} - -function getPnpmCommand(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' -} - -function getApiBaseUrl(port: number, explicitBaseUrl?: string): string { - return explicitBaseUrl ?? process.env.NPMX_API_BASE_URL ?? `http://127.0.0.1:${port}` -} - -function getTcpEndpoint(apiBaseUrl: string): TcpEndpoint { - const url = new URL(apiBaseUrl) - const port = Number.parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10) - - return { - host: url.hostname, - port, - } -} - -function isLocalEndpoint(endpoint: TcpEndpoint): boolean { - return endpoint.host === 'localhost' || endpoint.host === '127.0.0.1' || endpoint.host === '::1' -} - -function wait(ms: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, ms) - }) -} - -function isServerReachable(apiBaseUrl: string): Promise { - const { host, port } = getTcpEndpoint(apiBaseUrl) - - return new Promise(resolve => { - const socket = new Socket() - const finish = (reachable: boolean): void => { - socket.removeAllListeners() - socket.destroy() - resolve(reachable) - } - - socket.setTimeout(READY_CHECK_TIMEOUT_MS) - socket.once('connect', () => { - finish(true) - }) - socket.once('timeout', () => { - finish(false) - }) - socket.once('error', () => { - finish(false) - }) - socket.connect(port, host) - }) -} - -function captureLogs(child: ChildProcess, logs: string[]): void { - const append = (chunk: Buffer): void => { - const lines = chunk.toString('utf8').split(/\r?\n/).filter(Boolean) - logs.push(...lines) - - if (logs.length > 80) { - logs.splice(0, logs.length - 80) - } - } - - child.stdout?.on('data', append) - child.stderr?.on('data', append) -} - -function formatServerLogs(logs: string[]): string { - return logs.length > 0 ? `\n\nRecent server logs:\n${logs.join('\n')}` : '' -} - -async function waitForReachableServer( - apiBaseUrl: string, - server: ManagedServer, - timeoutMs: number, -): Promise { - const startedAt = Date.now() - let serverExitCode: number | null = null - - server.process?.once('exit', code => { - serverExitCode = code ?? 0 - }) - - while (Date.now() - startedAt < timeoutMs) { - if (await isServerReachable(apiBaseUrl)) { - return - } - - if (serverExitCode !== null) { - throw new Error( - `npmx dev server exited before opening ${apiBaseUrl} with code ${serverExitCode}.${formatServerLogs(server.logs)}`, - ) - } - - await wait(READY_CHECK_INTERVAL_MS) - } - - throw new Error( - `Timed out waiting for npmx dev server at ${apiBaseUrl}.${formatServerLogs(server.logs)}`, - ) -} - -async function ensureLocalServer( - repoRoot: string, - apiBaseUrl: string, - endpoint: TcpEndpoint, - timeoutMs: number, -): Promise { - if (await isServerReachable(apiBaseUrl)) { - return { - process: null, - started: false, - logs: [], - } - } - - if (!isLocalEndpoint(endpoint)) { - throw new Error( - `API server at ${apiBaseUrl} is not reachable. Local dev mode only starts npmx automatically for localhost or 127.0.0.1 URLs.`, - ) - } - - const child = spawn(getPnpmCommand(), ['dev', '--port', String(endpoint.port)], { - cwd: repoRoot, - detached: process.platform !== 'win32', - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }) - const server: ManagedServer = { - process: child, - started: true, - logs: [], - } - - captureLogs(child, server.logs) - await waitForReachableServer(apiBaseUrl, server, timeoutMs) - - return server -} - -function killProcess(child: ChildProcess | null): void { - if (!child?.pid || child.killed) { - return - } - - try { - if (process.platform !== 'win32') { - process.kill(-child.pid, 'SIGTERM') - return - } - } catch { - // Fall through to killing the direct child process. - } - - child.kill('SIGTERM') -} - -function runTui(tuiRoot: string, apiBaseUrl: string): Promise { - const child = spawn(process.execPath, ['--experimental-ffi', 'src/cli.ts'], { - cwd: tuiRoot, - env: { - ...process.env, - NPMX_API_BASE_URL: apiBaseUrl, - }, - stdio: 'inherit', - }) - - return new Promise((resolve, reject) => { - child.once('exit', code => { - resolve(code ?? 0) - }) - child.once('error', reject) - }) -} - -async function main(): Promise { - assertCompatibleNodeVersion() - - const { values } = parseArgs({ - options: { - 'port': { - type: 'string', - short: 'p', - }, - 'api-base-url': { - type: 'string', - }, - 'ready-timeout': { - type: 'string', - }, - }, - }) - - const port = Number.parseInt(values.port ?? String(DEFAULT_PORT), 10) || DEFAULT_PORT - const readyTimeoutMs = - Number.parseInt(values['ready-timeout'] ?? String(DEFAULT_READY_TIMEOUT_MS), 10) || - DEFAULT_READY_TIMEOUT_MS - const apiBaseUrl = getApiBaseUrl(port, values['api-base-url']) - const endpoint = getTcpEndpoint(apiBaseUrl) - const tuiRoot = fileURLToPath(new URL('..', import.meta.url)) - const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) - let server: ManagedServer | null = null - - const cleanup = (): void => { - if (server?.started) { - killProcess(server.process) - } - } - - process.once('SIGINT', () => { - cleanup() - process.exit(130) - }) - process.once('SIGTERM', () => { - cleanup() - process.exit(143) - }) - - try { - server = await ensureLocalServer(repoRoot, apiBaseUrl, endpoint, readyTimeoutMs) - const exitCode = await runTui(tuiRoot, apiBaseUrl) - - cleanup() - process.exit(exitCode) - } catch (error) { - cleanup() - console.error(error instanceof Error ? error.message : String(error)) - process.exit(1) - } -} - -await main() diff --git a/tui/scripts/dev.ts b/tui/scripts/dev.ts deleted file mode 100644 index eb3a351036..0000000000 --- a/tui/scripts/dev.ts +++ /dev/null @@ -1,62 +0,0 @@ -import process from 'node:process' -import { spawn } from 'node:child_process' -import { fileURLToPath } from 'node:url' - -const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] - -function parseNodeVersion(version: string): [number, number, number] { - const [major = 0, minor = 0, patch = 0] = version - .replace(/^v/, '') - .split('.') - .map(part => Number.parseInt(part, 10) || 0) - - return [major, minor, patch] -} - -function isAtLeastVersion( - actual: [number, number, number], - minimum: [number, number, number], -): boolean { - for (let index = 0; index < minimum.length; index += 1) { - if (actual[index] > minimum[index]) { - return true - } - - if (actual[index] < minimum[index]) { - return false - } - } - - return true -} - -const nodeVersion = parseNodeVersion(process.version) - -if (!isAtLeastVersion(nodeVersion, MIN_NODE_VERSION)) { - console.error(`OpenTUI dev mode requires Node.js 26.4.0+ with experimental FFI. - -Current Node.js: ${process.version} - -Use a compatible runtime, then run: - - pnpm npmx-tui - -For a single run without watch: - - pnpm --filter npmx-tui dev:ffi`) - process.exit(1) -} - -const child = spawn(process.execPath, ['--experimental-ffi', '--watch', 'src/cli.ts'], { - cwd: fileURLToPath(new URL('..', import.meta.url)), - stdio: 'inherit', -}) - -child.on('exit', code => { - process.exit(code ?? 0) -}) - -child.on('error', error => { - console.error(error.message) - process.exit(1) -}) diff --git a/tui/src/cli.ts b/tui/src/cli.ts index 248fe7845a..0668904c52 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -1,10 +1,80 @@ #!/usr/bin/env node import process from 'node:process' +import { spawn } from 'node:child_process' import { parseArgs } from 'node:util' -import { runTui } from './index.ts' -import { isThemePreference } from './theme/index.ts' +import { isThemePreference } from './theme/types.ts' const VERSION = '0.0.1' +const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] +const FFI_FLAG = '--experimental-ffi' +const RESET = '\x1B[0m' +const BOLD = '\x1B[1m' +const RED = '\x1B[31m' +const CYAN = '\x1B[36m' +const DIM = '\x1B[2m' + +function parseNodeVersion(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, '') + .split('.') + .map(part => Number.parseInt(part, 10) || 0) + + return [major, minor, patch] +} + +function isAtLeastVersion( + actual: [number, number, number], + minimum: [number, number, number], +): boolean { + for (let index = 0; index < minimum.length; index += 1) { + if (actual[index] > minimum[index]) { + return true + } + + if (actual[index] < minimum[index]) { + return false + } + } + + return true +} + +function assertCompatibleNodeVersion(): void { + if (isAtLeastVersion(parseNodeVersion(process.version), MIN_NODE_VERSION)) { + return + } + + console.error(` +${RED}${BOLD}[npmx-tui] ERROR: Unsupported Node.js runtime${RESET} + +${BOLD}Expected:${RESET} Node.js 26.4.0+ with experimental FFI support +${BOLD}Current:${RESET} ${process.version} + +${BOLD}Action:${RESET} Switch to Node.js 26.4.0+ for the TUI, then rerun this command. +${DIM}Hint:${RESET} The main npmx.dev app can still use Node.js 24; only npmx-tui needs Node.js 26. +${CYAN}${'='.repeat(64)}${RESET}`) + process.exit(1) +} + +async function ensureExperimentalFfi(): Promise { + if (process.execArgv.includes(FFI_FLAG)) { + return + } + + const child = spawn(process.execPath, [FFI_FLAG, ...process.execArgv, ...process.argv.slice(1)], { + env: process.env, + stdio: 'inherit', + }) + + const exitCode = await new Promise((resolve, reject) => { + child.once('exit', code => { + resolve(code ?? 0) + }) + child.once('error', reject) + }) + + process.exit(exitCode) +} const { values } = parseArgs({ options: { @@ -54,6 +124,11 @@ Expected one of: system, dark, light`) process.exit(1) } +assertCompatibleNodeVersion() +await ensureExperimentalFfi() + +const { runTui } = await import('./index.ts') + runTui({ version: VERSION, themePreference, From feab78ddfaea7640eff42d4b8f708998e4a232f2 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Sat, 22 Aug 2026 15:45:58 +0800 Subject: [PATCH 11/11] chore: fix hardcoded version --- tui/package.json | 2 +- tui/src/cli.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tui/package.json b/tui/package.json index ce8d8ec3aa..d9b3d8e5b6 100644 --- a/tui/package.json +++ b/tui/package.json @@ -32,6 +32,6 @@ "@opentui/core": "0.5.4" }, "engines": { - "node": ">=24.4.0" + "node": ">=26.4.0" } } diff --git a/tui/src/cli.ts b/tui/src/cli.ts index 0668904c52..e3c5eee445 100644 --- a/tui/src/cli.ts +++ b/tui/src/cli.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node import process from 'node:process' import { spawn } from 'node:child_process' +import { readFileSync } from 'node:fs' import { parseArgs } from 'node:util' import { isThemePreference } from './theme/types.ts' -const VERSION = '0.0.1' const MIN_NODE_VERSION: [number, number, number] = [26, 4, 0] const FFI_FLAG = '--experimental-ffi' const RESET = '\x1B[0m' @@ -13,6 +13,17 @@ const RED = '\x1B[31m' const CYAN = '\x1B[36m' const DIM = '\x1B[2m' +function readPackageVersion(): string { + const packageJsonUrl = new URL('../package.json', import.meta.url) + const packageJson = JSON.parse(readFileSync(packageJsonUrl, 'utf8')) as { version?: unknown } + + if (typeof packageJson.version !== 'string' || !packageJson.version) { + throw new Error('Unable to read npmx-tui version from package.json') + } + + return packageJson.version +} + function parseNodeVersion(version: string): [number, number, number] { const [major = 0, minor = 0, patch = 0] = version .replace(/^v/, '') @@ -111,7 +122,7 @@ Options: } if (values.version) { - console.log(VERSION) + console.log(readPackageVersion()) process.exit(0) } @@ -130,7 +141,7 @@ await ensureExperimentalFfi() const { runTui } = await import('./index.ts') runTui({ - version: VERSION, + version: readPackageVersion(), themePreference, apiBaseUrl: values['api-base-url'], }).catch(error => {