mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
refactor(utils): modularize ui.ts into ui/ directory
- split 647-line ui.ts into 10 focused modules (<115 lines each) - extract: colors, indicators, boxes, tables, text, spinner, tasks - add init.ts for shared state and types.ts for interfaces - ui.ts now re-exports from ui/index.ts for backward compat
This commit is contained in:
+21
-605
@@ -12,605 +12,10 @@
|
||||
* @module utils/ui
|
||||
*/
|
||||
|
||||
import type {
|
||||
BoxOptions,
|
||||
TableOptions,
|
||||
SpinnerOptions,
|
||||
SemanticColor,
|
||||
SpinnerController,
|
||||
} from '../types/utils';
|
||||
|
||||
// =============================================================================
|
||||
// ESM MODULE TYPES & LAZY LOADING
|
||||
// =============================================================================
|
||||
|
||||
// Type definitions for dynamically imported modules (CJS-compatible versions)
|
||||
// chalk@4.x, boxen@5.x are CJS, ora@5.x is CJS, listr2@3.x is CJS, gradient-string@2.x is CJS
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ChalkInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type BoxenFunction = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GradientStringInstance = any;
|
||||
// ora v9 is ESM-only, imported dynamically at runtime
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type OraModule = any;
|
||||
// listr2 v9 is ESM-only, imported dynamically at runtime
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ListrClass = any;
|
||||
|
||||
// Module cache for lazy loading
|
||||
let chalkModule: ChalkInstance | null = null;
|
||||
let boxenModule: BoxenFunction | null = null;
|
||||
let gradientModule: GradientStringInstance | null = null;
|
||||
let oraModule: OraModule | null = null;
|
||||
let listrModule: ListrClass | null = null;
|
||||
|
||||
// Initialization state
|
||||
let initialized = false;
|
||||
|
||||
// =============================================================================
|
||||
// COLOR PALETTE (Professional cyan-to-blue)
|
||||
// =============================================================================
|
||||
|
||||
const COLORS = {
|
||||
primary: '#00ECFA', // Bright cyan
|
||||
secondary: '#0099FF', // Sky blue
|
||||
neutral: '#808080', // Gray
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// INITIALIZATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Initialize UI dependencies (call once at startup)
|
||||
* Uses dynamic imports for ESM packages in CommonJS project
|
||||
*/
|
||||
export async function initUI(): Promise<void> {
|
||||
if (initialized) return;
|
||||
|
||||
try {
|
||||
// Dynamic import for ESM-only packages
|
||||
const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([
|
||||
import('chalk'),
|
||||
import('boxen'),
|
||||
import('gradient-string'),
|
||||
import('ora'),
|
||||
import('listr2'),
|
||||
]);
|
||||
|
||||
// CJS modules: use .default if available (ESM interop), otherwise use module directly
|
||||
chalkModule = chalkImport.default || chalkImport;
|
||||
boxenModule = boxenImport.default || boxenImport;
|
||||
gradientModule = gradientImport.default || gradientImport;
|
||||
oraModule = oraImport.default || oraImport;
|
||||
listrModule = listrImport.Listr || listrImport.default?.Listr;
|
||||
initialized = true;
|
||||
} catch (_e) {
|
||||
// Fallback: UI works without colors if imports fail
|
||||
console.error('[!] UI initialization failed, using plain text mode');
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TTY & COLOR DETECTION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if colors should be used
|
||||
* Respects NO_COLOR and FORCE_COLOR environment variables
|
||||
*/
|
||||
function useColors(): boolean {
|
||||
// FORCE_COLOR overrides all checks
|
||||
if (process.env.FORCE_COLOR) return true;
|
||||
// NO_COLOR disables colors
|
||||
if (process.env.NO_COLOR) return false;
|
||||
// Otherwise, check if TTY
|
||||
return !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if interactive mode (TTY + not CI)
|
||||
*/
|
||||
export function isInteractive(): boolean {
|
||||
return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COLOR SYSTEM
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Apply semantic color to text
|
||||
*/
|
||||
export function color(text: string, semantic: SemanticColor): string {
|
||||
if (!chalkModule || !useColors()) return text;
|
||||
|
||||
switch (semantic) {
|
||||
case 'success':
|
||||
return chalkModule.green.bold(text);
|
||||
case 'error':
|
||||
return chalkModule.red.bold(text);
|
||||
case 'warning':
|
||||
return chalkModule.yellow(text);
|
||||
case 'info':
|
||||
return chalkModule.cyan(text);
|
||||
case 'dim':
|
||||
return chalkModule.gray(text);
|
||||
case 'primary':
|
||||
return chalkModule.hex(COLORS.primary).bold(text);
|
||||
case 'secondary':
|
||||
return chalkModule.hex(COLORS.secondary)(text);
|
||||
case 'command':
|
||||
return chalkModule.yellow.bold(text);
|
||||
case 'path':
|
||||
return chalkModule.cyan.underline(text);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply gradient to text (for headers)
|
||||
* Uses cyan-to-blue gradient for professional look
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
if (!gradientModule || !useColors()) return text;
|
||||
return gradientModule([COLORS.primary, COLORS.secondary])(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bold text
|
||||
*/
|
||||
export function bold(text: string): string {
|
||||
if (!chalkModule || !useColors()) return text;
|
||||
return chalkModule.bold(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dim text
|
||||
*/
|
||||
export function dim(text: string): string {
|
||||
if (!chalkModule || !useColors()) return text;
|
||||
return chalkModule.dim(text);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS INDICATORS (ASCII only - NO EMOJIS)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Success indicator: [OK]
|
||||
*/
|
||||
export function ok(message: string): string {
|
||||
return `${color('[OK]', 'success')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error indicator: [X]
|
||||
*/
|
||||
export function fail(message: string): string {
|
||||
return `${color('[X]', 'error')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning indicator: [!]
|
||||
*/
|
||||
export function warn(message: string): string {
|
||||
return `${color('[!]', 'warning')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info indicator: [i]
|
||||
*/
|
||||
export function info(message: string): string {
|
||||
return `${color('[i]', 'info')} ${message}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BOX RENDERING
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fallback ASCII box renderer (when boxen not available)
|
||||
*/
|
||||
function renderAsciiBox(content: string, options: BoxOptions): string {
|
||||
const lines = content.split('\n');
|
||||
const maxLen = Math.max(...lines.map((l) => l.length), (options.title?.length || 0) + 4);
|
||||
const width = maxLen + 4;
|
||||
|
||||
let result = '';
|
||||
const padding = options.padding ?? 1;
|
||||
|
||||
// Top border with optional title
|
||||
if (options.title) {
|
||||
const titlePad = Math.floor((width - options.title.length - 4) / 2);
|
||||
result +=
|
||||
'+' +
|
||||
'-'.repeat(titlePad) +
|
||||
' ' +
|
||||
options.title +
|
||||
' ' +
|
||||
'-'.repeat(width - titlePad - options.title.length - 4) +
|
||||
'+\n';
|
||||
} else {
|
||||
result += '+' + '-'.repeat(width - 2) + '+\n';
|
||||
}
|
||||
|
||||
// Padding top
|
||||
for (let i = 0; i < padding; i++) {
|
||||
result += '|' + ' '.repeat(width - 2) + '|\n';
|
||||
}
|
||||
|
||||
// Content
|
||||
for (const line of lines) {
|
||||
const pad = width - line.length - 4;
|
||||
result += '| ' + line + ' '.repeat(Math.max(0, pad)) + ' |\n';
|
||||
}
|
||||
|
||||
// Padding bottom
|
||||
for (let i = 0; i < padding; i++) {
|
||||
result += '|' + ' '.repeat(width - 2) + '|\n';
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
result += '+' + '-'.repeat(width - 2) + '+';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content in a styled box
|
||||
*/
|
||||
export function box(content: string, options: BoxOptions = {}): string {
|
||||
if (!boxenModule) {
|
||||
return renderAsciiBox(content, options);
|
||||
}
|
||||
|
||||
const borderColor = useColors() ? options.borderColor || COLORS.primary : undefined;
|
||||
|
||||
return boxenModule(content, {
|
||||
padding: options.padding ?? 1,
|
||||
margin: options.margin ?? 0,
|
||||
borderStyle: options.borderStyle || 'round',
|
||||
borderColor,
|
||||
title: options.title,
|
||||
titleAlignment: options.titleAlignment || 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error box (red border)
|
||||
*/
|
||||
export function errorBox(content: string, title = 'ERROR'): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: 'red',
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render info box (primary color border)
|
||||
*/
|
||||
export function infoBox(content: string, title?: string): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: COLORS.primary,
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render warning box (yellow border)
|
||||
*/
|
||||
export function warnBox(content: string, title = 'WARNING'): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABLE RENDERING
|
||||
// =============================================================================
|
||||
|
||||
// cli-table3 is CommonJS, use dynamic import pattern
|
||||
|
||||
const Table = require('cli-table3');
|
||||
|
||||
/**
|
||||
* Create styled table
|
||||
*/
|
||||
export function table(rows: string[][], options: TableOptions = {}): string {
|
||||
// Build table configuration
|
||||
const tableConfig: Record<string, unknown> = {
|
||||
wordWrap: options.wordWrap ?? true,
|
||||
chars:
|
||||
options.style === 'ascii'
|
||||
? {
|
||||
top: '-',
|
||||
'top-mid': '+',
|
||||
'top-left': '+',
|
||||
'top-right': '+',
|
||||
bottom: '-',
|
||||
'bottom-mid': '+',
|
||||
'bottom-left': '+',
|
||||
'bottom-right': '+',
|
||||
left: '|',
|
||||
'left-mid': '+',
|
||||
mid: '-',
|
||||
'mid-mid': '+',
|
||||
right: '|',
|
||||
'right-mid': '+',
|
||||
middle: '|',
|
||||
}
|
||||
: {
|
||||
top: '─',
|
||||
'top-mid': '┬',
|
||||
'top-left': '┌',
|
||||
'top-right': '┐',
|
||||
bottom: '─',
|
||||
'bottom-mid': '┴',
|
||||
'bottom-left': '└',
|
||||
'bottom-right': '┘',
|
||||
left: '│',
|
||||
'left-mid': '├',
|
||||
mid: '─',
|
||||
'mid-mid': '┼',
|
||||
right: '│',
|
||||
'right-mid': '┤',
|
||||
middle: '│',
|
||||
},
|
||||
};
|
||||
|
||||
// Only add head if provided (cli-table3 requires head length to match rows)
|
||||
if (options.head && options.head.length > 0) {
|
||||
tableConfig.head = options.head.map((h: string) => color(h, 'primary'));
|
||||
}
|
||||
|
||||
// Only add colWidths if provided
|
||||
if (options.colWidths) {
|
||||
tableConfig.colWidths = options.colWidths;
|
||||
}
|
||||
|
||||
const tableInstance = new Table(tableConfig);
|
||||
|
||||
rows.forEach((row) => tableInstance.push(row));
|
||||
return tableInstance.toString();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SPINNER / PROGRESS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create and start a spinner
|
||||
* Falls back to plain text output in non-TTY environments
|
||||
*/
|
||||
export async function spinner(options: SpinnerOptions | string): Promise<SpinnerController> {
|
||||
const opts = typeof options === 'string' ? { text: options } : options;
|
||||
const isEnabled = isInteractive();
|
||||
|
||||
// Lazy load ora if not already loaded
|
||||
if (!oraModule && isEnabled) {
|
||||
try {
|
||||
oraModule = (await import('ora')).default;
|
||||
} catch (_e) {
|
||||
// Fallback to plain text
|
||||
}
|
||||
}
|
||||
|
||||
if (oraModule && isEnabled) {
|
||||
const s = oraModule({
|
||||
text: opts.text,
|
||||
color: 'cyan',
|
||||
prefixText: opts.prefixText,
|
||||
isEnabled,
|
||||
}).start();
|
||||
|
||||
return {
|
||||
succeed: (msg?: string) => s.succeed(msg || `${color('[OK]', 'success')} ${opts.text}`),
|
||||
fail: (msg?: string) => s.fail(msg || `${color('[X]', 'error')} ${opts.text}`),
|
||||
warn: (msg?: string) => s.warn(msg || `${color('[!]', 'warning')} ${opts.text}`),
|
||||
info: (msg?: string) => s.info(msg || `${color('[i]', 'info')} ${opts.text}`),
|
||||
update: (text: string) => {
|
||||
s.text = text;
|
||||
},
|
||||
stop: () => s.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: plain text (non-TTY)
|
||||
console.log(`[i] ${opts.text}...`);
|
||||
return {
|
||||
succeed: (msg?: string) => console.log(ok(msg || opts.text)),
|
||||
fail: (msg?: string) => console.log(fail(msg || opts.text)),
|
||||
warn: (msg?: string) => console.log(warn(msg || opts.text)),
|
||||
info: (msg?: string) => console.log(info(msg || opts.text)),
|
||||
update: (_text: string) => {
|
||||
/* no-op in non-TTY */
|
||||
},
|
||||
stop: () => {
|
||||
/* no-op */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SECTION HEADERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Print section header with optional gradient
|
||||
*/
|
||||
export function header(text: string, useGradient = true): string {
|
||||
if (useGradient && useColors()) {
|
||||
return gradientText(text);
|
||||
}
|
||||
return bold(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print subsection header
|
||||
*/
|
||||
export function subheader(text: string): string {
|
||||
return color(text, 'primary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Print horizontal rule
|
||||
*/
|
||||
export function hr(char = '─', width = 60): string {
|
||||
if (!useColors()) {
|
||||
return '-'.repeat(width);
|
||||
}
|
||||
return dim(char.repeat(width));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print section header with ═══ borders
|
||||
* Format: ═══ Title ═══
|
||||
*/
|
||||
export function sectionHeader(title: string): string {
|
||||
const border = '═══';
|
||||
const headerText = `${border} ${title} ${border}`;
|
||||
// Use gradient + bold for visual appeal
|
||||
if (gradientModule && chalkModule && useColors()) {
|
||||
return chalkModule.bold(gradientModule([COLORS.primary, COLORS.secondary])(headerText));
|
||||
}
|
||||
// Fallback to bold primary color
|
||||
if (useColors() && chalkModule) {
|
||||
return chalkModule.hex(COLORS.primary).bold(headerText);
|
||||
}
|
||||
return headerText;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TASK LISTS (Listr2 Integration)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Detect if running inside Claude Code tool context
|
||||
*
|
||||
* Heuristics:
|
||||
* - No TTY (stdout captured)
|
||||
* - CI-like environment
|
||||
* - CLAUDE_CODE env var set
|
||||
*/
|
||||
export function isClaudeCodeContext(): boolean {
|
||||
return (
|
||||
!process.stdout.isTTY ||
|
||||
!!process.env.CI ||
|
||||
!!process.env.CLAUDE_CODE ||
|
||||
process.env.TERM === 'dumb'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list item interface
|
||||
*/
|
||||
export interface TaskItem<T> {
|
||||
title: string;
|
||||
task: (ctx: T) => Promise<void> | void;
|
||||
skip?: () => boolean | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list options
|
||||
*/
|
||||
export interface TaskListOptions {
|
||||
concurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task list for progress display
|
||||
* Uses Listr2 in TTY mode, falls back to spinners in non-TTY
|
||||
*/
|
||||
export async function taskList<T>(tasks: TaskItem<T>[], options: TaskListOptions = {}): Promise<T> {
|
||||
// Lazy load Listr2 if not already loaded
|
||||
if (!listrModule && isInteractive()) {
|
||||
try {
|
||||
const listr2 = await import('listr2');
|
||||
listrModule = listr2.Listr;
|
||||
} catch (_e) {
|
||||
// Fallback to sequential execution with spinners
|
||||
return runTasksFallback(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
if (listrModule && isInteractive()) {
|
||||
// Determine renderer based on context
|
||||
// Use 'simple' in non-TTY, CI, or Claude Code context
|
||||
const useSimple = isClaudeCodeContext();
|
||||
|
||||
const list = new listrModule(
|
||||
tasks.map((t) => ({
|
||||
title: t.title,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task: async (ctx: any) => t.task(ctx),
|
||||
skip: t.skip,
|
||||
})),
|
||||
{
|
||||
concurrent: options.concurrent ?? false,
|
||||
renderer: useSimple ? 'simple' : 'default',
|
||||
rendererOptions: {
|
||||
showSubtasks: true,
|
||||
collapseSubtasks: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return list.run({} as T);
|
||||
}
|
||||
|
||||
// Fallback: non-interactive or Listr2 not available
|
||||
return runTasksFallback(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback task runner (no Listr2)
|
||||
* Uses spinners for sequential task execution
|
||||
*/
|
||||
async function runTasksFallback<T>(tasks: TaskItem<T>[]): Promise<T> {
|
||||
const ctx = {} as T;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.skip) {
|
||||
const skipResult = task.skip();
|
||||
if (skipResult) {
|
||||
console.log(info(`${task.title} [skipped]`));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const spin = await spinner(task.title);
|
||||
try {
|
||||
await task.task(ctx);
|
||||
spin.succeed();
|
||||
} catch (e) {
|
||||
spin.fail(`${task.title}: ${(e as Error).message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UNIFIED EXPORT OBJECT
|
||||
// =============================================================================
|
||||
|
||||
export const ui = {
|
||||
// Re-export everything from modular files
|
||||
export {
|
||||
// Initialization
|
||||
init: initUI,
|
||||
initUI,
|
||||
isInteractive,
|
||||
isClaudeCodeContext,
|
||||
|
||||
@@ -620,28 +25,39 @@ export const ui = {
|
||||
bold,
|
||||
dim,
|
||||
|
||||
// Status indicators (ASCII only)
|
||||
// Status indicators
|
||||
ok,
|
||||
fail,
|
||||
warn,
|
||||
info,
|
||||
|
||||
// Containers
|
||||
// Boxes
|
||||
box,
|
||||
errorBox,
|
||||
infoBox,
|
||||
warnBox,
|
||||
|
||||
// Tables
|
||||
table,
|
||||
|
||||
// Progress
|
||||
// Spinner
|
||||
spinner,
|
||||
|
||||
// Tasks
|
||||
taskList,
|
||||
|
||||
// Headers
|
||||
// Text formatting
|
||||
header,
|
||||
subheader,
|
||||
sectionHeader,
|
||||
hr,
|
||||
} as const;
|
||||
sectionHeader,
|
||||
|
||||
export default ui;
|
||||
// Unified object
|
||||
ui,
|
||||
} from './ui/index';
|
||||
|
||||
// Re-export types
|
||||
export type { TaskItem, TaskListOptions } from './ui/index';
|
||||
|
||||
// Default export
|
||||
export { default } from './ui/index';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Box Rendering
|
||||
*
|
||||
* Styled box containers with fallback ASCII rendering
|
||||
* @module utils/ui/boxes
|
||||
*/
|
||||
|
||||
import type { BoxOptions } from '../../types/utils';
|
||||
import { moduleCache, COLORS } from './types';
|
||||
import { useColors } from './init';
|
||||
|
||||
/**
|
||||
* Fallback ASCII box renderer (when boxen not available)
|
||||
*/
|
||||
function renderAsciiBox(content: string, options: BoxOptions): string {
|
||||
const lines = content.split('\n');
|
||||
const maxLen = Math.max(...lines.map((l) => l.length), (options.title?.length || 0) + 4);
|
||||
const width = maxLen + 4;
|
||||
|
||||
let result = '';
|
||||
const padding = options.padding ?? 1;
|
||||
|
||||
// Top border with optional title
|
||||
if (options.title) {
|
||||
const titlePad = Math.floor((width - options.title.length - 4) / 2);
|
||||
result +=
|
||||
'+' +
|
||||
'-'.repeat(titlePad) +
|
||||
' ' +
|
||||
options.title +
|
||||
' ' +
|
||||
'-'.repeat(width - titlePad - options.title.length - 4) +
|
||||
'+\n';
|
||||
} else {
|
||||
result += '+' + '-'.repeat(width - 2) + '+\n';
|
||||
}
|
||||
|
||||
// Padding top
|
||||
for (let i = 0; i < padding; i++) {
|
||||
result += '|' + ' '.repeat(width - 2) + '|\n';
|
||||
}
|
||||
|
||||
// Content
|
||||
for (const line of lines) {
|
||||
const pad = width - line.length - 4;
|
||||
result += '| ' + line + ' '.repeat(Math.max(0, pad)) + ' |\n';
|
||||
}
|
||||
|
||||
// Padding bottom
|
||||
for (let i = 0; i < padding; i++) {
|
||||
result += '|' + ' '.repeat(width - 2) + '|\n';
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
result += '+' + '-'.repeat(width - 2) + '+';
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content in a styled box
|
||||
*/
|
||||
export function box(content: string, options: BoxOptions = {}): string {
|
||||
if (!moduleCache.boxen) {
|
||||
return renderAsciiBox(content, options);
|
||||
}
|
||||
|
||||
const borderColor = useColors() ? options.borderColor || COLORS.primary : undefined;
|
||||
|
||||
return moduleCache.boxen(content, {
|
||||
padding: options.padding ?? 1,
|
||||
margin: options.margin ?? 0,
|
||||
borderStyle: options.borderStyle || 'round',
|
||||
borderColor,
|
||||
title: options.title,
|
||||
titleAlignment: options.titleAlignment || 'center',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error box (red border)
|
||||
*/
|
||||
export function errorBox(content: string, title = 'ERROR'): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: 'red',
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render info box (primary color border)
|
||||
*/
|
||||
export function infoBox(content: string, title?: string): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: COLORS.primary,
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render warning box (yellow border)
|
||||
*/
|
||||
export function warnBox(content: string, title = 'WARNING'): string {
|
||||
return box(content, {
|
||||
title,
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round',
|
||||
padding: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Color System
|
||||
*
|
||||
* Semantic color functions with TTY awareness
|
||||
* @module utils/ui/colors
|
||||
*/
|
||||
|
||||
import type { SemanticColor } from '../../types/utils';
|
||||
import { moduleCache, COLORS } from './types';
|
||||
import { useColors } from './init';
|
||||
|
||||
/**
|
||||
* Apply semantic color to text
|
||||
*/
|
||||
export function color(text: string, semantic: SemanticColor): string {
|
||||
if (!moduleCache.chalk || !useColors()) return text;
|
||||
|
||||
switch (semantic) {
|
||||
case 'success':
|
||||
return moduleCache.chalk.green.bold(text);
|
||||
case 'error':
|
||||
return moduleCache.chalk.red.bold(text);
|
||||
case 'warning':
|
||||
return moduleCache.chalk.yellow(text);
|
||||
case 'info':
|
||||
return moduleCache.chalk.cyan(text);
|
||||
case 'dim':
|
||||
return moduleCache.chalk.gray(text);
|
||||
case 'primary':
|
||||
return moduleCache.chalk.hex(COLORS.primary).bold(text);
|
||||
case 'secondary':
|
||||
return moduleCache.chalk.hex(COLORS.secondary)(text);
|
||||
case 'command':
|
||||
return moduleCache.chalk.yellow.bold(text);
|
||||
case 'path':
|
||||
return moduleCache.chalk.cyan.underline(text);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply gradient to text (for headers)
|
||||
* Uses cyan-to-blue gradient for professional look
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
if (!moduleCache.gradient || !useColors()) return text;
|
||||
return moduleCache.gradient([COLORS.primary, COLORS.secondary])(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bold text
|
||||
*/
|
||||
export function bold(text: string): string {
|
||||
if (!moduleCache.chalk || !useColors()) return text;
|
||||
return moduleCache.chalk.bold(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dim text
|
||||
*/
|
||||
export function dim(text: string): string {
|
||||
if (!moduleCache.chalk || !useColors()) return text;
|
||||
return moduleCache.chalk.dim(text);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* UI Module Barrel Export
|
||||
*
|
||||
* Re-exports all UI components from modular files
|
||||
* @module utils/ui
|
||||
*/
|
||||
|
||||
// Types and constants
|
||||
export { COLORS, moduleCache, initialized, setInitialized } from './types';
|
||||
export type {
|
||||
ChalkInstance,
|
||||
BoxenFunction,
|
||||
GradientStringInstance,
|
||||
OraModule,
|
||||
ListrClass,
|
||||
} from './types';
|
||||
|
||||
// Initialization
|
||||
export { initUI, useColors, isInteractive, isClaudeCodeContext } from './init';
|
||||
|
||||
// Colors
|
||||
export { color, gradientText, bold, dim } from './colors';
|
||||
|
||||
// Status indicators
|
||||
export { ok, fail, warn, info } from './indicators';
|
||||
|
||||
// Boxes
|
||||
export { box, errorBox, infoBox, warnBox } from './boxes';
|
||||
|
||||
// Tables
|
||||
export { table } from './tables';
|
||||
|
||||
// Text formatting
|
||||
export { header, subheader, hr, sectionHeader } from './text';
|
||||
|
||||
// Spinner
|
||||
export { spinner } from './spinner';
|
||||
|
||||
// Tasks
|
||||
export { taskList } from './tasks';
|
||||
export type { TaskItem, TaskListOptions } from './tasks';
|
||||
|
||||
// Import all functions for the ui object
|
||||
import { initUI, isInteractive, isClaudeCodeContext } from './init';
|
||||
import { color, gradientText, bold, dim } from './colors';
|
||||
import { ok, fail, warn, info } from './indicators';
|
||||
import { box, errorBox, infoBox, warnBox } from './boxes';
|
||||
import { table } from './tables';
|
||||
import { header, subheader, hr, sectionHeader } from './text';
|
||||
import { spinner } from './spinner';
|
||||
import { taskList } from './tasks';
|
||||
|
||||
// Unified UI object for convenient access
|
||||
export const ui = {
|
||||
// Initialization
|
||||
init: initUI,
|
||||
isInteractive,
|
||||
isClaudeCodeContext,
|
||||
|
||||
// Colors
|
||||
color,
|
||||
gradientText,
|
||||
bold,
|
||||
dim,
|
||||
|
||||
// Status indicators (ASCII only)
|
||||
ok,
|
||||
fail,
|
||||
warn,
|
||||
info,
|
||||
|
||||
// Containers
|
||||
box,
|
||||
errorBox,
|
||||
infoBox,
|
||||
warnBox,
|
||||
table,
|
||||
|
||||
// Progress
|
||||
spinner,
|
||||
taskList,
|
||||
|
||||
// Headers
|
||||
header,
|
||||
subheader,
|
||||
sectionHeader,
|
||||
hr,
|
||||
} as const;
|
||||
|
||||
export default ui;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Status Indicators
|
||||
*
|
||||
* ASCII-only status markers (NO EMOJIS)
|
||||
* @module utils/ui/indicators
|
||||
*/
|
||||
|
||||
import { color } from './colors';
|
||||
|
||||
/**
|
||||
* Success indicator: [OK]
|
||||
*/
|
||||
export function ok(message: string): string {
|
||||
return `${color('[OK]', 'success')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error indicator: [X]
|
||||
*/
|
||||
export function fail(message: string): string {
|
||||
return `${color('[X]', 'error')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning indicator: [!]
|
||||
*/
|
||||
export function warn(message: string): string {
|
||||
return `${color('[!]', 'warning')} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info indicator: [i]
|
||||
*/
|
||||
export function info(message: string): string {
|
||||
return `${color('[i]', 'info')} ${message}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* UI Initialization
|
||||
*
|
||||
* Handles lazy loading of ESM dependencies
|
||||
* @module utils/ui/init
|
||||
*/
|
||||
|
||||
import { moduleCache, initialized, setInitialized } from './types';
|
||||
|
||||
/**
|
||||
* Initialize UI dependencies (call once at startup)
|
||||
* Uses dynamic imports for ESM packages in CommonJS project
|
||||
*/
|
||||
export async function initUI(): Promise<void> {
|
||||
if (initialized) return;
|
||||
|
||||
try {
|
||||
// Dynamic import for ESM-only packages
|
||||
const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([
|
||||
import('chalk'),
|
||||
import('boxen'),
|
||||
import('gradient-string'),
|
||||
import('ora'),
|
||||
import('listr2'),
|
||||
]);
|
||||
|
||||
// CJS modules: use .default if available (ESM interop), otherwise use module directly
|
||||
moduleCache.chalk = chalkImport.default || chalkImport;
|
||||
moduleCache.boxen = boxenImport.default || boxenImport;
|
||||
moduleCache.gradient = gradientImport.default || gradientImport;
|
||||
moduleCache.ora = oraImport.default || oraImport;
|
||||
moduleCache.listr = listrImport.Listr || listrImport.default?.Listr;
|
||||
setInitialized(true);
|
||||
} catch (_e) {
|
||||
// Fallback: UI works without colors if imports fail
|
||||
console.error('[!] UI initialization failed, using plain text mode');
|
||||
setInitialized(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if colors should be used
|
||||
* Respects NO_COLOR and FORCE_COLOR environment variables
|
||||
*/
|
||||
export function useColors(): boolean {
|
||||
// FORCE_COLOR overrides all checks
|
||||
if (process.env.FORCE_COLOR) return true;
|
||||
// NO_COLOR disables colors
|
||||
if (process.env.NO_COLOR) return false;
|
||||
// Otherwise, check if TTY
|
||||
return !!process.stdout.isTTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if interactive mode (TTY + not CI)
|
||||
*/
|
||||
export function isInteractive(): boolean {
|
||||
return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running inside Claude Code tool context
|
||||
*
|
||||
* Heuristics:
|
||||
* - No TTY (stdout captured)
|
||||
* - CI-like environment
|
||||
* - CLAUDE_CODE env var set
|
||||
*/
|
||||
export function isClaudeCodeContext(): boolean {
|
||||
return (
|
||||
!process.stdout.isTTY ||
|
||||
!!process.env.CI ||
|
||||
!!process.env.CLAUDE_CODE ||
|
||||
process.env.TERM === 'dumb'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Spinner / Progress
|
||||
*
|
||||
* Ora-based spinner with TTY fallback
|
||||
* @module utils/ui/spinner
|
||||
*/
|
||||
|
||||
import type { SpinnerOptions, SpinnerController } from '../../types/utils';
|
||||
import { moduleCache } from './types';
|
||||
import { isInteractive } from './init';
|
||||
import { color } from './colors';
|
||||
import { ok, fail, warn, info } from './indicators';
|
||||
|
||||
/**
|
||||
* Create and start a spinner
|
||||
* Falls back to plain text output in non-TTY environments
|
||||
*/
|
||||
export async function spinner(options: SpinnerOptions | string): Promise<SpinnerController> {
|
||||
const opts = typeof options === 'string' ? { text: options } : options;
|
||||
const isEnabled = isInteractive();
|
||||
|
||||
// Lazy load ora if not already loaded
|
||||
if (!moduleCache.ora && isEnabled) {
|
||||
try {
|
||||
moduleCache.ora = (await import('ora')).default;
|
||||
} catch (_e) {
|
||||
// Fallback to plain text
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleCache.ora && isEnabled) {
|
||||
const s = moduleCache
|
||||
.ora({
|
||||
text: opts.text,
|
||||
color: 'cyan',
|
||||
prefixText: opts.prefixText,
|
||||
isEnabled,
|
||||
})
|
||||
.start();
|
||||
|
||||
return {
|
||||
succeed: (msg?: string) => s.succeed(msg || `${color('[OK]', 'success')} ${opts.text}`),
|
||||
fail: (msg?: string) => s.fail(msg || `${color('[X]', 'error')} ${opts.text}`),
|
||||
warn: (msg?: string) => s.warn(msg || `${color('[!]', 'warning')} ${opts.text}`),
|
||||
info: (msg?: string) => s.info(msg || `${color('[i]', 'info')} ${opts.text}`),
|
||||
update: (text: string) => {
|
||||
s.text = text;
|
||||
},
|
||||
stop: () => s.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: plain text (non-TTY)
|
||||
console.log(`[i] ${opts.text}...`);
|
||||
return {
|
||||
succeed: (msg?: string) => console.log(ok(msg || opts.text)),
|
||||
fail: (msg?: string) => console.log(fail(msg || opts.text)),
|
||||
warn: (msg?: string) => console.log(warn(msg || opts.text)),
|
||||
info: (msg?: string) => console.log(info(msg || opts.text)),
|
||||
update: (_text: string) => {
|
||||
/* no-op in non-TTY */
|
||||
},
|
||||
stop: () => {
|
||||
/* no-op */
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Table Rendering
|
||||
*
|
||||
* Styled table output with cli-table3
|
||||
* @module utils/ui/tables
|
||||
*/
|
||||
|
||||
import type { TableOptions } from '../../types/utils';
|
||||
import { color } from './colors';
|
||||
|
||||
// cli-table3 is CommonJS
|
||||
const Table = require('cli-table3');
|
||||
|
||||
/**
|
||||
* Create styled table
|
||||
*/
|
||||
export function table(rows: string[][], options: TableOptions = {}): string {
|
||||
// Build table configuration
|
||||
const tableConfig: Record<string, unknown> = {
|
||||
wordWrap: options.wordWrap ?? true,
|
||||
chars:
|
||||
options.style === 'ascii'
|
||||
? {
|
||||
top: '-',
|
||||
'top-mid': '+',
|
||||
'top-left': '+',
|
||||
'top-right': '+',
|
||||
bottom: '-',
|
||||
'bottom-mid': '+',
|
||||
'bottom-left': '+',
|
||||
'bottom-right': '+',
|
||||
left: '|',
|
||||
'left-mid': '+',
|
||||
mid: '-',
|
||||
'mid-mid': '+',
|
||||
right: '|',
|
||||
'right-mid': '+',
|
||||
middle: '|',
|
||||
}
|
||||
: {
|
||||
top: '─',
|
||||
'top-mid': '┬',
|
||||
'top-left': '┌',
|
||||
'top-right': '┐',
|
||||
bottom: '─',
|
||||
'bottom-mid': '┴',
|
||||
'bottom-left': '└',
|
||||
'bottom-right': '┘',
|
||||
left: '│',
|
||||
'left-mid': '├',
|
||||
mid: '─',
|
||||
'mid-mid': '┼',
|
||||
right: '│',
|
||||
'right-mid': '┤',
|
||||
middle: '│',
|
||||
},
|
||||
};
|
||||
|
||||
// Only add head if provided (cli-table3 requires head length to match rows)
|
||||
if (options.head && options.head.length > 0) {
|
||||
tableConfig.head = options.head.map((h: string) => color(h, 'primary'));
|
||||
}
|
||||
|
||||
// Only add colWidths if provided
|
||||
if (options.colWidths) {
|
||||
tableConfig.colWidths = options.colWidths;
|
||||
}
|
||||
|
||||
const tableInstance = new Table(tableConfig);
|
||||
|
||||
rows.forEach((row) => tableInstance.push(row));
|
||||
return tableInstance.toString();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Task Lists
|
||||
*
|
||||
* Listr2 integration with fallback task runner
|
||||
* @module utils/ui/tasks
|
||||
*/
|
||||
|
||||
import { moduleCache } from './types';
|
||||
import { isInteractive, isClaudeCodeContext } from './init';
|
||||
import { info } from './indicators';
|
||||
import { spinner } from './spinner';
|
||||
|
||||
/**
|
||||
* Task list item interface
|
||||
*/
|
||||
export interface TaskItem<T> {
|
||||
title: string;
|
||||
task: (ctx: T) => Promise<void> | void;
|
||||
skip?: () => boolean | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task list options
|
||||
*/
|
||||
export interface TaskListOptions {
|
||||
concurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback task runner (no Listr2)
|
||||
* Uses spinners for sequential task execution
|
||||
*/
|
||||
async function runTasksFallback<T>(tasks: TaskItem<T>[]): Promise<T> {
|
||||
const ctx = {} as T;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.skip) {
|
||||
const skipResult = task.skip();
|
||||
if (skipResult) {
|
||||
console.log(info(`${task.title} [skipped]`));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const spin = await spinner(task.title);
|
||||
try {
|
||||
await task.task(ctx);
|
||||
spin.succeed();
|
||||
} catch (e) {
|
||||
spin.fail(`${task.title}: ${(e as Error).message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task list for progress display
|
||||
* Uses Listr2 in TTY mode, falls back to spinners in non-TTY
|
||||
*/
|
||||
export async function taskList<T>(tasks: TaskItem<T>[], options: TaskListOptions = {}): Promise<T> {
|
||||
// Lazy load Listr2 if not already loaded
|
||||
if (!moduleCache.listr && isInteractive()) {
|
||||
try {
|
||||
const listr2 = await import('listr2');
|
||||
moduleCache.listr = listr2.Listr;
|
||||
} catch (_e) {
|
||||
// Fallback to sequential execution with spinners
|
||||
return runTasksFallback(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleCache.listr && isInteractive()) {
|
||||
// Determine renderer based on context
|
||||
// Use 'simple' in non-TTY, CI, or Claude Code context
|
||||
const useSimple = isClaudeCodeContext();
|
||||
|
||||
const list = new moduleCache.listr(
|
||||
tasks.map((t) => ({
|
||||
title: t.title,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task: async (ctx: any) => t.task(ctx),
|
||||
skip: t.skip,
|
||||
})),
|
||||
{
|
||||
concurrent: options.concurrent ?? false,
|
||||
renderer: useSimple ? 'simple' : 'default',
|
||||
rendererOptions: {
|
||||
showSubtasks: true,
|
||||
collapseSubtasks: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return list.run({} as T);
|
||||
}
|
||||
|
||||
// Fallback: non-interactive or Listr2 not available
|
||||
return runTasksFallback(tasks);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Text Formatting
|
||||
*
|
||||
* Headers, subheaders, and text utilities
|
||||
* @module utils/ui/text
|
||||
*/
|
||||
|
||||
import { moduleCache, COLORS } from './types';
|
||||
import { useColors } from './init';
|
||||
import { gradientText, bold, dim } from './colors';
|
||||
|
||||
/**
|
||||
* Print section header with optional gradient
|
||||
*/
|
||||
export function header(text: string, useGradient = true): string {
|
||||
if (useGradient && useColors()) {
|
||||
return gradientText(text);
|
||||
}
|
||||
return bold(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print subsection header
|
||||
*/
|
||||
export function subheader(text: string): string {
|
||||
if (!moduleCache.chalk || !useColors()) return text;
|
||||
return moduleCache.chalk.hex(COLORS.primary)(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print horizontal rule
|
||||
*/
|
||||
export function hr(char = '─', width = 60): string {
|
||||
if (!useColors()) {
|
||||
return '-'.repeat(width);
|
||||
}
|
||||
return dim(char.repeat(width));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print section header with ═══ borders
|
||||
* Format: ═══ Title ═══
|
||||
*/
|
||||
export function sectionHeader(title: string): string {
|
||||
const border = '═══';
|
||||
const headerText = `${border} ${title} ${border}`;
|
||||
// Use gradient + bold for visual appeal
|
||||
if (moduleCache.gradient && moduleCache.chalk && useColors()) {
|
||||
return moduleCache.chalk.bold(
|
||||
moduleCache.gradient([COLORS.primary, COLORS.secondary])(headerText)
|
||||
);
|
||||
}
|
||||
// Fallback to bold primary color
|
||||
if (useColors() && moduleCache.chalk) {
|
||||
return moduleCache.chalk.hex(COLORS.primary).bold(headerText);
|
||||
}
|
||||
return headerText;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* UI Module Internal Types
|
||||
*
|
||||
* Type definitions for dynamically imported ESM modules
|
||||
* @module utils/ui/types
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ChalkInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type BoxenFunction = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type GradientStringInstance = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OraModule = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ListrClass = any;
|
||||
|
||||
/**
|
||||
* Color palette (Professional cyan-to-blue)
|
||||
*/
|
||||
export const COLORS = {
|
||||
primary: '#00ECFA', // Bright cyan
|
||||
secondary: '#0099FF', // Sky blue
|
||||
neutral: '#808080', // Gray
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Module cache for lazy loading
|
||||
*/
|
||||
export interface ModuleCache {
|
||||
chalk: ChalkInstance | null;
|
||||
boxen: BoxenFunction | null;
|
||||
gradient: GradientStringInstance | null;
|
||||
ora: OraModule | null;
|
||||
listr: ListrClass | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared module state
|
||||
*/
|
||||
export const moduleCache: ModuleCache = {
|
||||
chalk: null,
|
||||
boxen: null,
|
||||
gradient: null,
|
||||
ora: null,
|
||||
listr: null,
|
||||
};
|
||||
|
||||
export let initialized = false;
|
||||
|
||||
export function setInitialized(value: boolean): void {
|
||||
initialized = value;
|
||||
}
|
||||
Reference in New Issue
Block a user