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..bfafb423b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,12 @@ importers: specifier: 4.3.3 version: 4.3.3 + 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: '@adobe/css-tools@4.5.0': @@ -2819,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} @@ -5880,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'} @@ -8001,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'} @@ -9737,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'} @@ -10700,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==} @@ -14264,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 @@ -17003,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 @@ -19526,6 +19649,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + marked@17.0.6: {} marked@18.0.9: {} @@ -22114,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 @@ -23161,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/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/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/.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..574ffaa7b5 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,29 @@ +# 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 dev +``` + +Then, in another terminal: + +```bash +pnpm npmx-tui +``` + +`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 TUI watch mode: + +```bash +pnpm npmx-tui:watch +``` diff --git a/tui/package.json b/tui/package.json new file mode 100644 index 0000000000..d9b3d8e5b6 --- /dev/null +++ b/tui/package.json @@ -0,0 +1,37 @@ +{ + "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", + "dev:watch": "node --watch src/cli.ts", + "test:types": "tsc --noEmit" + }, + "dependencies": { + "@opentui/core": "0.5.4" + }, + "engines": { + "node": ">=26.4.0" + } +} diff --git a/tui/src/cli.ts b/tui/src/cli.ts new file mode 100644 index 0000000000..e3c5eee445 --- /dev/null +++ b/tui/src/cli.ts @@ -0,0 +1,151 @@ +#!/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 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 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/, '') + .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: { + 'help': { + type: 'boolean', + short: 'h', + }, + 'version': { + type: 'boolean', + short: 'v', + }, + 'theme': { + type: 'string', + short: 't', + }, + 'api-base-url': { + type: 'string', + }, + }, +}) + +if (values.help) { + console.log(`npmx-tui + +Usage: + npmx-tui [options] + +Options: + -h, --help Show help + -v, --version Show version + -t, --theme Theme preference: system, dark, light + --api-base-url npmx backend base URL`) + process.exit(0) +} + +if (values.version) { + console.log(readPackageVersion()) + process.exit(0) +} + +const themePreference = values.theme ?? 'system' + +if (!isThemePreference(themePreference)) { + console.error(`Invalid theme preference: ${themePreference} + +Expected one of: system, dark, light`) + process.exit(1) +} + +assertCompatibleNodeVersion() +await ensureExperimentalFfi() + +const { runTui } = await import('./index.ts') + +runTui({ + version: readPackageVersion(), + 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 new file mode 100644 index 0000000000..f23ab5da49 --- /dev/null +++ b/tui/src/index.ts @@ -0,0 +1,1577 @@ +import { + Box, + CliRenderEvents, + Input, + InputRenderableEvents, + RGBA, + StyledText, + Text, + bg, + bold, + createCliRenderer, + fg, + instantiate, + type BoxRenderable, + type CliRenderer, + type InputRenderable, + type KeyEvent, + 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 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 + +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) + + 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}` +} + +function isPlainKey(key: KeyEvent, name: string): boolean { + return ( + key.eventType === 'press' && + 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, state: AppState): boolean { + return state.focus !== 'search' && 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 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, +): 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, + 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 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) + .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 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 healthRows = [ + createField('downloads', formatDownloads(data.weeklyDownloads)), + createField('versions', detail?.versionCount), + createField('maintainers', maintainers.length), + detail?.entryPoints?.dependenciesCount !== undefined + ? createField('dependencies', detail.entryPoints.dependenciesCount) + : undefined, + detail?.entryPoints?.peerDependenciesCount !== undefined + ? createField('peer deps', detail.entryPoints.peerDependenciesCount) + : 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 = [ + createField('type', detail?.entryPoints?.type), + createField('main', detail?.entryPoints?.main), + createField('module', detail?.entryPoints?.module), + createField('types', detail?.entryPoints?.types), + detail?.entryPoints?.hasExports !== undefined + ? createField('exports', detail.entryPoints.hasExports ? 'yes' : 'no') + : undefined, + createField('bin', binNames.length > 0 ? compactList(binNames, 5) : undefined), + createField('engines', engineInfo), + ].filter(isDefinedString) + + const blocks: InspectorLine[][] = [ + headerBlock, + createInstallBlock(data.name), + createBracketSection('health', healthRows), + createBracketSection('runtime', runtimeRows), + createBracketSection('links', links), + createBracketSection('keywords', keywords ? [keywords] : []), + createBracketSection( + 'maintainers', + [ + createField('author', author), + createField('team', maintainers.length > 0 ? 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') { + 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 + } + + 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.focus === 'search') { + return [ + { key: 'type', label: 'Search' }, + { key: 'enter', label: 'Results' }, + { key: '↑/↓', label: 'Results' }, + { key: 'esc', label: 'Cancel' }, + ] + } + + 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, width = 0): StyledText { + 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)(' ')) + } + + chunks.push(fg(theme.accent)(bold(action.key))) + chunks.push(fg(theme.fg.secondary)(` ${action.label}`)) + }) + + 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) +} + +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 themePreference = options.themePreference ?? 'system' + const apiBaseUrl = options.apiBaseUrl ?? getDefaultApiBaseUrl() + 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 state: AppState = { + mode: 'insert', + focus: 'search', + 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: 'Search focused', + } + + 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 prompt = instantiate( + renderer, + Text({ + content: '>', + fg: theme.accent, + bg: theme.bg.base, + width: 2, + height: 1, + }), + ) as TextRenderable + + 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: true, + }), + ) as InputRenderable + + const spinner = instantiate( + renderer, + Text({ + content: '', + fg: theme.accent, + bg: theme.bg.base, + width: 1, + height: 1, + }), + ) as TextRenderable + + 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: createCollectionListText(state, theme), + fg: theme.fg.secondary, + bg: theme.bg.base, + width: '100%', + height: 'auto', + flexGrow: 1, + wrapMode: 'none', + truncate: true, + }), + ) as TextRenderable + + const resultsFooter = instantiate( + renderer, + Text({ + content: createResultsFooterText(state, theme), + fg: theme.fg.muted, + bg: theme.bg.base, + height: 2, + truncate: true, + }), + ) as TextRenderable + + 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 + + const inspector = instantiate( + renderer, + Text({ + content: createStyledInspectorText(createInspectorLines(undefined), theme), + fg: theme.fg.secondary, + bg: theme.bg.base, + height: 'auto', + flexGrow: 1, + wrapMode: 'word', + truncate: false, + }), + ) as TextRenderable + + const inspectorPane = instantiate( + renderer, + Box( + { + backgroundColor: theme.bg.base, + border: true, + borderStyle: 'single', + borderColor: theme.border.normal, + focusedBorderColor: theme.border.focused, + title: ' Preview ', + titleColor: theme.fg.secondary, + 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: '100%', + height: 'auto', + flexGrow: 1, + }, + leftPane, + inspectorPane, + ), + ) as BoxRenderable + + const statusBar = instantiate( + renderer, + Text({ + content: createShortcutBarText(state, theme, renderer.terminalWidth), + fg: theme.fg.muted, + bg: theme.bg.base, + 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 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, getStatusBarWidth()) + statusBar.fg = theme.status[kind] + } + + function updateStatusBar(): void { + statusBar.content = createShortcutBarText(state, theme, getStatusBarWidth()) + 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, detail, detailStatus, detailError) + const viewportHeight = Math.max(1, inspector.height || 1) + + state.inspectorScrollOffset = Math.min( + state.inspectorScrollOffset, + getMaxInspectorScrollOffset(content, viewportHeight), + ) + + inspectorPane.title = ' 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 focusSearch(): void { + state.mode = 'insert' + state.focus = 'search' + if (state.layout === 'single') { + state.view = 'collection' + } + input.showCursor = true + input.focus() + setStatus('Search focused') + applyLayout() + } + + 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, 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 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() || + 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.focus === 'search' ? 'Search focused' : 'Results focused') + 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 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 (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, 'h') || isPlainKey(key, 'left')) { + if (state.focus === 'inspector') { + focusCollection() + } else { + focusSearch() + } + return true + } + + if (isPlainKey(key, 'return')) { + openSelection() + return true + } + + if (isPlainKey(key, 'l') || isPlainKey(key, 'right')) { + return focusInspector() + } + + 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 (!handleAppKey(key)) { + return + } + + key.preventDefault() + key.stopPropagation() + } + + const quitHandler = (key: KeyEvent): void => { + if (!shouldQuit(key, state)) { + 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) + + 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 + + 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), + ) + + inspector.fg = theme.fg.secondary + inspector.bg = theme.bg.base + resultsFooter.bg = theme.bg.base + resultsFooter.content = createResultsFooterText(state, theme) + statusBar.bg = theme.bg.base + 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(shell) + applyLayout() + updateCollection() + focusSearch() +} + +export { createThemeManager } +export type { Theme, ThemeManager, ThemeMode, ThemeName, ThemePreference } from './theme/index.ts' 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) +} 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) +} diff --git a/tui/tsconfig.json b/tui/tsconfig.json new file mode 100644 index 0000000000..3a7744b419 --- /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", "scripts/**/*.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', + }, +})