dbn-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ import { COLORS, BORDERS, UI } from './theme.ts';
2
+ import { formatNumber, truncate, pad, formatValue, getVisibleWidth } from '../utils/format.ts';
3
+ import type { Screen } from './screen.ts';
4
+ import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
5
+
6
+ /**
7
+ * Renderer for ncdu-style TUI
8
+ */
9
+ export class Renderer {
10
+ private screen: Screen;
11
+
12
+ constructor(screen: Screen) {
13
+ this.screen = screen;
14
+ }
15
+
16
+ /**
17
+ * Render the current state to screen
18
+ * @param state - Current navigation state
19
+ * @param dbPath - Database file path
20
+ */
21
+ render(state: ViewState, dbPath: string): void {
22
+ const { width, height } = this.screen;
23
+ const lines: string[] = [];
24
+
25
+ // Build all lines
26
+ lines.push(this.buildTitleBar(state, dbPath, width));
27
+ lines.push(this.buildSeparator(width));
28
+
29
+ const contentLines = this.buildContent(state, height - 3, width);
30
+ lines.push(...contentLines);
31
+
32
+ lines.push(this.buildHelpBar(state, width));
33
+
34
+ // Clear and render
35
+ this.screen.clear();
36
+ this.screen.write(lines.join('\n'));
37
+ }
38
+
39
+ /**
40
+ * Build title bar (top line)
41
+ */
42
+ private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
43
+ const fileName = dbPath.split('/').pop() || dbPath;
44
+ let title = ` ${fileName}`;
45
+
46
+ if (state.type === 'table-detail') {
47
+ title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName}`;
48
+ } else if (state.type === 'schema-view') {
49
+ title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> schema${COLORS.reset}`;
50
+ } else if (state.type === 'row-detail') {
51
+ title += ` ${COLORS.dim}>${COLORS.reset} ${state.tableName} ${COLORS.dim}> row ${state.rowIndex + 1}${COLORS.reset}`;
52
+ }
53
+
54
+ title = `${COLORS.bold}${title}${COLORS.reset}`;
55
+
56
+ // Build right info
57
+ let rightInfo = '';
58
+ if (state.type === 'tables') {
59
+ const totalTables = state.tables.length;
60
+ const currentPos = state.cursor + 1;
61
+ rightInfo = `${COLORS.dim}${currentPos}/${totalTables} tables${COLORS.reset}`;
62
+ } else if (state.type === 'table-detail') {
63
+ const totalRows = formatNumber(state.totalRows);
64
+ const currentRow = state.dataOffset + state.dataCursor + 1;
65
+ rightInfo = `${COLORS.dim}row ${formatNumber(currentRow)}/${totalRows}${COLORS.reset}`;
66
+ } else if (state.type === 'schema-view') {
67
+ const totalCols = state.schema.length;
68
+ const currentPos = state.cursor + 1;
69
+ rightInfo = `${COLORS.dim}${currentPos}/${totalCols} columns${COLORS.reset}`;
70
+ } else if (state.type === 'row-detail') {
71
+ const colCount = state.schema.length;
72
+ rightInfo = `${COLORS.dim}${colCount} fields${COLORS.reset}`;
73
+ }
74
+
75
+ // Calculate spacing using visible width (accounts for CJK double-width)
76
+ const titleWidth = getVisibleWidth(title);
77
+ const rightInfoWidth = getVisibleWidth(rightInfo);
78
+ const availableSpace = width - titleWidth - rightInfoWidth;
79
+
80
+ // Adjust width to prevent wrapping issues with ambiguous width characters
81
+ const safeWidth = width - 1;
82
+
83
+ if (availableSpace > 0) {
84
+ // Ensure we don't exceed safeWidth
85
+ let result = title + ' '.repeat(availableSpace) + rightInfo;
86
+ const resultWidth = getVisibleWidth(result);
87
+ if (resultWidth > safeWidth) {
88
+ return truncate(result, safeWidth);
89
+ }
90
+ return result;
91
+ } else {
92
+ // Not enough space, truncate title
93
+ const maxTitleWidth = safeWidth - rightInfoWidth - 3; // Reserve space for "..."
94
+ if (maxTitleWidth > 10) {
95
+ const truncatedTitle = truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), maxTitleWidth);
96
+ const truncatedWidth = getVisibleWidth(truncatedTitle);
97
+ const padding = safeWidth - truncatedWidth - rightInfoWidth;
98
+ return `${COLORS.bold}${truncatedTitle}${COLORS.reset}` + ' '.repeat(Math.max(0, padding)) + rightInfo;
99
+ } else {
100
+ // Very narrow screen, just show title
101
+ return truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), safeWidth);
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Build separator line
108
+ */
109
+ private buildSeparator(width: number): string {
110
+ return `${COLORS.dim}${BORDERS.horizontal.repeat(width)}${COLORS.reset}`;
111
+ }
112
+
113
+ /**
114
+ * Build main content area
115
+ */
116
+ private buildContent(state: ViewState, height: number, width: number): string[] {
117
+ if (state.type === 'tables') {
118
+ return this.buildTablesList(state, height, width);
119
+ } else if (state.type === 'table-detail') {
120
+ return this.buildTableDetail(state, height, width);
121
+ } else if (state.type === 'schema-view') {
122
+ return this.buildSchemaView(state, height, width);
123
+ } else if (state.type === 'row-detail') {
124
+ return this.buildRowDetail(state, height, width);
125
+ }
126
+ return [];
127
+ }
128
+
129
+ /**
130
+ * Build tables list view
131
+ */
132
+ private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
133
+ const lines: string[] = [];
134
+ const { tables, cursor } = state;
135
+
136
+ if (tables.length === 0) {
137
+ lines.push('');
138
+ lines.push(pad('No tables found', width, 'center'));
139
+ while (lines.length < height) {
140
+ lines.push('');
141
+ }
142
+ return lines;
143
+ }
144
+
145
+ // Calculate visible window
146
+ const halfHeight = Math.floor(height / 2);
147
+ let startIdx = Math.max(0, cursor - halfHeight);
148
+ let endIdx = Math.min(tables.length, startIdx + height);
149
+
150
+ // Adjust if at the end
151
+ if (endIdx - startIdx < height) {
152
+ startIdx = Math.max(0, endIdx - height);
153
+ }
154
+
155
+ for (let i = startIdx; i < endIdx; i++) {
156
+ const table = tables[i];
157
+ const isSelected = i === cursor;
158
+
159
+ // Build line without color codes first
160
+ const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
161
+ const maxNameWidth = width - 20; // Reserve space for row count
162
+ const name = truncate(table.name, maxNameWidth - 5);
163
+ const namePadded = pad(name, maxNameWidth - 3);
164
+ const count = formatNumber(table.row_count) + ' rows';
165
+ const countPadded = pad(count, 15, 'right');
166
+
167
+ let content = `${cursorChar} ${namePadded} ${countPadded}`;
168
+
169
+ // Ensure exact width before adding color codes
170
+ const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
171
+ const contentWidth = getVisibleWidth(content);
172
+ if (contentWidth > safeWidth) {
173
+ content = truncate(content, safeWidth);
174
+ } else if (contentWidth < safeWidth) {
175
+ content += ' '.repeat(safeWidth - contentWidth);
176
+ }
177
+
178
+ // Apply color codes to properly sized line
179
+ let line: string;
180
+ if (isSelected) {
181
+ line = COLORS.inverse + content + COLORS.reset;
182
+ } else {
183
+ line = content;
184
+ }
185
+
186
+ lines.push(line);
187
+ }
188
+
189
+ // Fill remaining lines
190
+ while (lines.length < height) {
191
+ lines.push(' '.repeat(width));
192
+ }
193
+
194
+ return lines;
195
+ }
196
+
197
+ /**
198
+ * Build table detail view
199
+ */
200
+ private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
201
+ const lines: string[] = [];
202
+ const { data, totalRows, dataOffset, dataCursor } = state;
203
+
204
+ if (data.length === 0) {
205
+ lines.push(pad('No data', width, 'center'));
206
+ } else {
207
+ // Get column names
208
+ const columns = Object.keys(data[0]);
209
+
210
+ // Calculate optimal column widths based on content
211
+ const minColWidth = 8;
212
+ const maxColWidth = 50; // Maximum width for any single column
213
+ const maxVisibleCols = 8;
214
+ const visibleColumns = columns.slice(0, maxVisibleCols);
215
+ const availableWidth = width - 4; // Reserve space for padding and cursor
216
+ const spacingWidth = visibleColumns.length - 1; // Space between columns
217
+ const usableWidth = availableWidth - spacingWidth;
218
+
219
+ // Calculate ideal width for each column based on content
220
+ const idealWidths = visibleColumns.map(col => {
221
+ // Check column name length
222
+ let maxWidth = col.length;
223
+
224
+ // Check data values length (sample first few rows for performance)
225
+ const sampleSize = Math.min(data.length, 20);
226
+ for (let i = 0; i < sampleSize; i++) {
227
+ const value = formatValue(data[i][col]);
228
+ maxWidth = Math.max(maxWidth, value.length);
229
+ }
230
+
231
+ // Apply constraints: add 2 for padding, cap at maxColWidth
232
+ return Math.max(minColWidth, Math.min(maxWidth + 2, maxColWidth));
233
+ });
234
+
235
+ // Calculate total ideal width
236
+ let totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0);
237
+
238
+ // Allocate widths
239
+ const colWidths: number[] = [];
240
+
241
+ if (totalIdealWidth <= usableWidth) {
242
+ // We have extra space - distribute it intelligently
243
+ const extraSpace = usableWidth - totalIdealWidth;
244
+
245
+ // Find columns that could use more space (those at maxColWidth)
246
+ const expandableIndices = idealWidths
247
+ .map((w, i) => ({ width: w, index: i }))
248
+ .filter(item => item.width === maxColWidth)
249
+ .map(item => item.index);
250
+
251
+ // Distribute extra space only to expandable columns
252
+ if (expandableIndices.length > 0) {
253
+ const extraPerCol = Math.floor(extraSpace / expandableIndices.length);
254
+ for (let i = 0; i < visibleColumns.length; i++) {
255
+ if (expandableIndices.includes(i)) {
256
+ colWidths[i] = idealWidths[i] + extraPerCol;
257
+ } else {
258
+ colWidths[i] = idealWidths[i];
259
+ }
260
+ }
261
+ // Add remainder to last expandable column
262
+ const remainder = extraSpace - (extraPerCol * expandableIndices.length);
263
+ colWidths[expandableIndices[expandableIndices.length - 1]] += remainder;
264
+ } else {
265
+ // No expandable columns, distribute evenly to all
266
+ const extraPerCol = Math.floor(extraSpace / visibleColumns.length);
267
+ for (let i = 0; i < visibleColumns.length; i++) {
268
+ colWidths[i] = idealWidths[i] + extraPerCol;
269
+ }
270
+ const remainder = extraSpace - (extraPerCol * visibleColumns.length);
271
+ colWidths[colWidths.length - 1] += remainder;
272
+ }
273
+ } else {
274
+ // Need to scale down - use proportional scaling
275
+ const scale = usableWidth / totalIdealWidth;
276
+ for (let i = 0; i < visibleColumns.length; i++) {
277
+ colWidths[i] = Math.max(minColWidth, Math.floor(idealWidths[i] * scale));
278
+ }
279
+
280
+ // Adjust to fill exact width
281
+ const totalWidth = colWidths.reduce((sum, w) => sum + w, 0);
282
+ const diff = usableWidth - totalWidth;
283
+ if (diff !== 0) {
284
+ colWidths[colWidths.length - 1] += diff;
285
+ }
286
+ }
287
+
288
+ // Render table header
289
+ const headerCells = visibleColumns.map((col, idx) => {
290
+ const truncatedCol = truncate(col, colWidths[idx] - 1);
291
+ return pad(truncatedCol, colWidths[idx] - 1);
292
+ });
293
+
294
+ let headerLine = ` ${COLORS.dim} ${headerCells.join(' ')}${COLORS.reset}`;
295
+ const headerWidth = getVisibleWidth(headerLine);
296
+
297
+ if (headerWidth < width) {
298
+ headerLine += ' '.repeat(width - headerWidth);
299
+ } else if (headerWidth > width) {
300
+ // Truncate if somehow too wide
301
+ const plainHeader = ` ${headerCells.map(c => c.replace(/\x1b\[[0-9;]*m/g, '')).join(' ')}`;
302
+ headerLine = truncate(plainHeader, width);
303
+ }
304
+
305
+ lines.push(headerLine);
306
+
307
+ // Update visible rows for navigator (subtract 1 for header)
308
+ state.visibleRows = height - 1;
309
+
310
+ // Render data rows
311
+ const maxRows = Math.min(data.length, height - 1);
312
+
313
+ for (let i = 0; i < maxRows; i++) {
314
+ const row = data[i];
315
+ const isSelected = i === dataCursor;
316
+
317
+ const cells = visibleColumns.map((col, idx) => {
318
+ const value = formatValue(row[col]);
319
+ return truncate(value, colWidths[idx] - 1);
320
+ });
321
+
322
+ // Build content without color codes first
323
+ const prefix = isSelected ? ` ${UI.cursor}` : ' ';
324
+ const contentWithoutPrefix = cells.map((cell, idx) => pad(cell, colWidths[idx] - 1)).join(' ');
325
+
326
+ // Ensure exact width before adding color codes (using visible width for CJK)
327
+ const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
328
+ let content = prefix + ' ' + contentWithoutPrefix;
329
+ const contentWidth = getVisibleWidth(content);
330
+
331
+ if (contentWidth > safeWidth) {
332
+ content = truncate(content, safeWidth);
333
+ } else if (contentWidth < safeWidth) {
334
+ content += ' '.repeat(safeWidth - contentWidth);
335
+ }
336
+
337
+ // Apply color codes to properly sized line
338
+ let line: string;
339
+ if (isSelected) {
340
+ line = COLORS.inverse + content + COLORS.reset;
341
+ } else {
342
+ line = content;
343
+ }
344
+
345
+ lines.push(line);
346
+ }
347
+ }
348
+
349
+ // Fill remaining lines
350
+ while (lines.length < height) {
351
+ lines.push(' '.repeat(width));
352
+ }
353
+
354
+ return lines;
355
+ }
356
+
357
+ /**
358
+ * Build schema view (full screen)
359
+ */
360
+ private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
361
+ const lines: string[] = [];
362
+ const { schema, cursor, scrollOffset } = state;
363
+
364
+ if (schema.length === 0) {
365
+ lines.push(pad('No schema information', width, 'center'));
366
+ while (lines.length < height) {
367
+ lines.push(' '.repeat(width));
368
+ }
369
+ return lines;
370
+ }
371
+
372
+ // Calculate visible window
373
+ const contentHeight = height; // Use full height
374
+ const halfHeight = Math.floor(contentHeight / 2);
375
+ let startIdx = Math.max(0, cursor - halfHeight);
376
+ let endIdx = Math.min(schema.length, startIdx + contentHeight);
377
+
378
+ // Adjust if at the end
379
+ if (endIdx - startIdx < contentHeight) {
380
+ startIdx = Math.max(0, endIdx - contentHeight);
381
+ }
382
+
383
+ // Calculate column widths
384
+ const nameWidth = Math.min(30, Math.max(...schema.map(col => col.name.length)) + 2);
385
+ const typeWidth = Math.min(20, Math.max(...schema.map(col => col.type.length)) + 2);
386
+ const attrWidth = width - nameWidth - typeWidth - 10;
387
+
388
+ // Render schema rows
389
+ for (let i = startIdx; i < endIdx; i++) {
390
+ const col = schema[i];
391
+ const isSelected = i === cursor;
392
+
393
+ const name = pad(truncate(col.name, nameWidth - 1), nameWidth);
394
+ const type = pad(truncate(col.type, typeWidth - 1), typeWidth);
395
+
396
+ // Build attributes string
397
+ const attrs: string[] = [];
398
+ if (col.pk) attrs.push('PK');
399
+ if (col.notnull) attrs.push('NOT NULL');
400
+ if (col.dflt_value !== null) attrs.push(`DEFAULT ${col.dflt_value}`);
401
+ const attrStr = attrs.length > 0 ? truncate(attrs.join(', '), attrWidth) : '';
402
+
403
+ // Build content without selection color first
404
+ const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
405
+ const content = `${cursorChar} ${name} ${COLORS.cyan}${type}${COLORS.reset} ${COLORS.dim}${attrStr}${COLORS.reset}`;
406
+
407
+ // Calculate visible width (accounting for CJK and ANSI codes)
408
+ const contentWidth = getVisibleWidth(content);
409
+
410
+ // Build line with exact width
411
+ const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
412
+ let line: string;
413
+ if (contentWidth < safeWidth) {
414
+ line = content + ' '.repeat(safeWidth - contentWidth);
415
+ } else if (contentWidth > safeWidth) {
416
+ // Need to truncate - build without colors first
417
+ const basicContent = `${cursorChar} ${name} ${type} ${attrStr}`;
418
+ const truncatedBasic = truncate(basicContent, safeWidth);
419
+ line = truncatedBasic;
420
+ } else {
421
+ line = content;
422
+ }
423
+
424
+ // Apply inverse color for selected row (wraps entire line)
425
+ if (isSelected) {
426
+ line = COLORS.inverse + line + COLORS.reset;
427
+ }
428
+
429
+ lines.push(line);
430
+ }
431
+
432
+ // Fill remaining lines
433
+ while (lines.length < height) {
434
+ lines.push(' '.repeat(width));
435
+ }
436
+
437
+ return lines;
438
+ }
439
+
440
+ /**
441
+ * Build row detail view
442
+ */
443
+ private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
444
+ const lines: string[] = [];
445
+ const { row, schema, tableName, rowIndex } = state;
446
+
447
+ // Get max column name length for alignment
448
+ const maxColNameLength = Math.max(...schema.map(col => col.name.length), 10);
449
+ const rightMargin = 2; // Right side margin
450
+ const valueWidth = width - maxColNameLength - 5 - rightMargin;
451
+
452
+ // Display each field (one line per field)
453
+ let lineCount = 0;
454
+ for (const col of schema) {
455
+ if (lineCount >= height) break;
456
+
457
+ const colName = `${COLORS.cyan}${pad(col.name, maxColNameLength)}${COLORS.reset}`;
458
+ const value = formatValue(row[col.name]);
459
+
460
+ // Truncate value to fit in single line
461
+ const truncatedValue = truncate(value, valueWidth);
462
+
463
+ let line = ` ${colName} ${COLORS.dim}:${COLORS.reset} ${truncatedValue}`;
464
+ const lineWidth = getVisibleWidth(line);
465
+ if (lineWidth < width - rightMargin) {
466
+ line += ' '.repeat(width - rightMargin - lineWidth);
467
+ }
468
+ lines.push(line);
469
+ lineCount++;
470
+ }
471
+
472
+ // Fill remaining lines
473
+ while (lines.length < height) {
474
+ lines.push(' '.repeat(width));
475
+ }
476
+
477
+ return lines;
478
+ }
479
+
480
+ /**
481
+ * Build help bar (bottom line)
482
+ */
483
+ private buildHelpBar(state: ViewState, width: number): string {
484
+ let help = '';
485
+
486
+ if (state.type === 'tables') {
487
+ help = ' [j/k] select [Enter/l] open [g/G] top/bottom [q] quit';
488
+ } else if (state.type === 'table-detail') {
489
+ help = ' [j/k] scroll [Enter/l] view row [s] toggle schema [h/Esc] back [q] quit';
490
+ } else if (state.type === 'schema-view') {
491
+ help = ' [j/k] scroll [g/G] top/bottom [s/h/Esc] back [q] quit';
492
+ } else if (state.type === 'row-detail') {
493
+ help = ' [h/Esc] back [q] quit';
494
+ }
495
+
496
+ const paddedHelp = pad(help, width);
497
+ return `${COLORS.dim}${paddedHelp}${COLORS.reset}`;
498
+ }
499
+ }
@@ -0,0 +1,101 @@
1
+ import { stdin, stdout } from 'node:process';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { ScreenDimensions } from '../types.ts';
4
+
5
+ /**
6
+ * Screen manager for full-screen TUI applications
7
+ * Handles alternate screen buffer and terminal state
8
+ */
9
+ export class Screen extends EventEmitter {
10
+ width: number;
11
+ height: number;
12
+ private isActive: boolean = false;
13
+ private resizeHandler?: () => void;
14
+
15
+ constructor() {
16
+ super();
17
+ this.width = stdout.columns || 80;
18
+ this.height = stdout.rows || 24;
19
+ }
20
+
21
+ /**
22
+ * Enter alternate screen buffer and set up terminal
23
+ */
24
+ enter(): void {
25
+ if (this.isActive) return;
26
+
27
+ // Enter alternate screen buffer
28
+ stdout.write('\x1b[?1049h');
29
+
30
+ // Hide cursor
31
+ stdout.write('\x1b[?25l');
32
+
33
+ // Clear screen
34
+ stdout.write('\x1b[2J\x1b[H');
35
+
36
+ // Listen for terminal resize
37
+ this.resizeHandler = () => {
38
+ this.width = stdout.columns || 80;
39
+ this.height = stdout.rows || 24;
40
+ this.emit('resize', { width: this.width, height: this.height } as ScreenDimensions);
41
+ };
42
+ process.on('SIGWINCH', this.resizeHandler);
43
+
44
+ this.isActive = true;
45
+ }
46
+
47
+ /**
48
+ * Exit alternate screen buffer and restore terminal
49
+ */
50
+ exit(): void {
51
+ if (!this.isActive) return;
52
+
53
+ // Show cursor
54
+ stdout.write('\x1b[?25h');
55
+
56
+ // Exit alternate screen buffer
57
+ stdout.write('\x1b[?1049l');
58
+
59
+ // Remove resize listener
60
+ if (this.resizeHandler) {
61
+ process.off('SIGWINCH', this.resizeHandler);
62
+ }
63
+
64
+ this.isActive = false;
65
+ }
66
+
67
+ /**
68
+ * Clear the screen
69
+ */
70
+ clear(): void {
71
+ stdout.write('\x1b[2J\x1b[H');
72
+ }
73
+
74
+ /**
75
+ * Move cursor to specific position
76
+ * @param row - Row (1-indexed)
77
+ * @param col - Column (1-indexed)
78
+ */
79
+ moveCursor(row: number, col: number): void {
80
+ stdout.write(`\x1b[${row};${col}H`);
81
+ }
82
+
83
+ /**
84
+ * Write text to the screen
85
+ * @param text - Text to write
86
+ */
87
+ write(text: string): void {
88
+ stdout.write(text);
89
+ }
90
+
91
+ /**
92
+ * Write text at specific position
93
+ * @param row - Row (1-indexed)
94
+ * @param col - Column (1-indexed)
95
+ * @param text - Text to write
96
+ */
97
+ writeAt(row: number, col: number, text: string): void {
98
+ this.moveCursor(row, col);
99
+ this.write(text);
100
+ }
101
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ANSI color codes and styling
3
+ */
4
+ export const COLORS = {
5
+ reset: '\x1b[0m',
6
+ bold: '\x1b[1m',
7
+ dim: '\x1b[2m',
8
+ italic: '\x1b[3m',
9
+ underline: '\x1b[4m',
10
+ inverse: '\x1b[7m',
11
+
12
+ // Foreground colors
13
+ black: '\x1b[30m',
14
+ red: '\x1b[31m',
15
+ green: '\x1b[32m',
16
+ yellow: '\x1b[33m',
17
+ blue: '\x1b[34m',
18
+ magenta: '\x1b[35m',
19
+ cyan: '\x1b[36m',
20
+ white: '\x1b[37m',
21
+ gray: '\x1b[90m',
22
+
23
+ // Background colors
24
+ bgBlack: '\x1b[40m',
25
+ bgRed: '\x1b[41m',
26
+ bgGreen: '\x1b[42m',
27
+ bgYellow: '\x1b[43m',
28
+ bgBlue: '\x1b[44m',
29
+ bgMagenta: '\x1b[45m',
30
+ bgCyan: '\x1b[46m',
31
+ bgWhite: '\x1b[47m',
32
+ } as const;
33
+
34
+ /**
35
+ * Unicode box drawing characters
36
+ */
37
+ export const BORDERS = {
38
+ horizontal: '─',
39
+ vertical: '│',
40
+ topLeft: '┌',
41
+ topRight: '┐',
42
+ bottomLeft: '└',
43
+ bottomRight: '┘',
44
+ leftJoin: '├',
45
+ rightJoin: '┤',
46
+ topJoin: '┬',
47
+ bottomJoin: '┴',
48
+ cross: '┼',
49
+ } as const;
50
+
51
+ /**
52
+ * Common UI elements
53
+ */
54
+ export const UI = {
55
+ cursor: '>',
56
+ empty: ' ',
57
+ ellipsis: '...',
58
+ } as const;