dbn-cli 0.4.0 → 0.5.3

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.
package/src/ui/screen.ts CHANGED
@@ -26,13 +26,13 @@ export class Screen extends EventEmitter {
26
26
 
27
27
  // Enter alternate screen buffer
28
28
  stdout.write('\x1b[?1049h');
29
-
29
+
30
30
  // Hide cursor
31
31
  stdout.write('\x1b[?25l');
32
-
32
+
33
33
  // Clear screen
34
34
  stdout.write('\x1b[2J\x1b[H');
35
-
35
+
36
36
  // Listen for terminal resize
37
37
  this.resizeHandler = () => {
38
38
  this.width = stdout.columns || 80;
@@ -40,7 +40,7 @@ export class Screen extends EventEmitter {
40
40
  this.emit('resize', { width: this.width, height: this.height } as ScreenDimensions);
41
41
  };
42
42
  process.on('SIGWINCH', this.resizeHandler);
43
-
43
+
44
44
  this.isActive = true;
45
45
  }
46
46
 
@@ -52,10 +52,10 @@ export class Screen extends EventEmitter {
52
52
 
53
53
  // Show cursor
54
54
  stdout.write('\x1b[?25h');
55
-
55
+
56
56
  // Exit alternate screen buffer
57
57
  stdout.write('\x1b[?1049l');
58
-
58
+
59
59
  // Remove resize listener
60
60
  if (this.resizeHandler) {
61
61
  process.off('SIGWINCH', this.resizeHandler);
@@ -0,0 +1,30 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { THEME, UI } from '../ui/theme.ts';
4
+
5
+ describe('Theme', () => {
6
+ describe('THEME', () => {
7
+ it('should have background colors', () => {
8
+ assert.ok(THEME.background);
9
+ assert.ok(THEME.surface);
10
+ });
11
+
12
+ it('should have brand colors', () => {
13
+ assert.ok(THEME.primary);
14
+ assert.ok(THEME.secondary);
15
+ assert.ok(THEME.accent);
16
+ });
17
+
18
+ it('should have semantic colors', () => {
19
+ assert.ok(THEME.success);
20
+ assert.ok(THEME.warning);
21
+ assert.ok(THEME.error);
22
+ });
23
+ });
24
+
25
+ describe('UI', () => {
26
+ it('should have ellipsis', () => {
27
+ assert.strictEqual(UI.ellipsis, '...');
28
+ });
29
+ });
30
+ });
package/src/ui/theme.ts CHANGED
@@ -1,58 +1,32 @@
1
+ import { ANSI as GRIT_ANSI } from './grit/index.ts';
2
+
1
3
  /**
2
- * ANSI color codes and styling
4
+ * ANSI Escape Codes for Styling
3
5
  */
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;
6
+ export const ANSI = GRIT_ANSI;
33
7
 
34
8
  /**
35
- * Unicode box drawing characters
9
+ * Modern color palette (OpenCode style)
36
10
  */
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;
11
+ export const THEME = {
12
+ background: '#0D0D0D',
13
+ surface: '#1A1A1A',
14
+ primary: '#00A0FF', // Brighter Blue
15
+ secondary: '#A259FF', // Brighter Purple
16
+ accent: '#FF3B30', // Red
17
+ text: '#FFFFFF',
18
+ textDim: '#8E8E93',
19
+ headerBg: '#242426',
20
+ footerBg: '',
21
+ selectionBg: '#3A3A3C',
22
+ success: '#34C759',
23
+ warning: '#FF9500',
24
+ error: '#FF3B30',
25
+ };
50
26
 
51
27
  /**
52
28
  * Common UI elements
53
29
  */
54
30
  export const UI = {
55
- cursor: '>',
56
- empty: ' ',
57
31
  ellipsis: '...',
58
32
  } as const;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Debounce a function
3
+ */
4
+ export function debounce<T extends (...args: any[]) => any>(
5
+ fn: T,
6
+ ms: number
7
+ ): (...args: Parameters<T>) => void {
8
+ let timeoutId: NodeJS.Timeout | null = null;
9
+ return (...args: Parameters<T>) => {
10
+ if (timeoutId) clearTimeout(timeoutId);
11
+ timeoutId = setTimeout(() => {
12
+ fn(...args);
13
+ timeoutId = null;
14
+ }, ms);
15
+ };
16
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { formatNumber, truncate, pad, formatValue, getVisibleWidth, wrapText } from '../utils/format.ts';
4
+
5
+ describe('Format Utils', () => {
6
+ describe('formatNumber', () => {
7
+ it('should format numbers with thousand separators', () => {
8
+ assert.strictEqual(formatNumber(1234), '1,234');
9
+ assert.strictEqual(formatNumber(1234567), '1,234,567');
10
+ assert.strictEqual(formatNumber(123), '123');
11
+ assert.strictEqual(formatNumber(0), '0');
12
+ });
13
+ });
14
+
15
+ describe('truncate', () => {
16
+ it('should truncate long strings', () => {
17
+ assert.strictEqual(truncate('Hello World', 8), 'Hello...');
18
+ assert.strictEqual(truncate('Hello World', 15), 'Hello World');
19
+ assert.strictEqual(truncate('Hello', 10), 'Hello');
20
+ });
21
+
22
+ it('should handle null and undefined', () => {
23
+ assert.strictEqual(truncate(null as any, 10), '');
24
+ assert.strictEqual(truncate(undefined as any, 10), '');
25
+ });
26
+
27
+ it('should handle empty strings', () => {
28
+ assert.strictEqual(truncate('', 10), '');
29
+ });
30
+
31
+ it('should convert non-strings', () => {
32
+ assert.strictEqual(truncate(123 as any, 5), '123');
33
+ assert.strictEqual(truncate(12345678 as any, 5), '12...');
34
+ });
35
+
36
+ it('should truncate CJK strings correctly by visible width', () => {
37
+ // 你好世界 = 4 chars, 8 visible width
38
+ assert.strictEqual(getVisibleWidth(truncate('你好世界', 5)), 5); // Should fit '你...' (2+3=5)
39
+ assert.strictEqual(getVisibleWidth(truncate('你好世界', 10)), 8); // Should not truncate
40
+ assert.strictEqual(getVisibleWidth(truncate('Hello你好', 8)), 8); // 'Hello...' = 8 width
41
+ });
42
+
43
+ it('should truncate emoji strings correctly by visible width', () => {
44
+ // 👋 = 2 visible width
45
+ assert.strictEqual(getVisibleWidth(truncate('Hello 👋', 6)), 6); // Should fit 'Hel...' = 6 width
46
+ assert.strictEqual(getVisibleWidth(truncate('👋👋👋', 5)), 5); // Should fit '👋...' (2+3=5)
47
+ });
48
+ });
49
+
50
+ describe('pad', () => {
51
+ it('should pad strings to the left by default', () => {
52
+ assert.strictEqual(pad('test', 10), 'test ');
53
+ assert.strictEqual(pad('hello', 8), 'hello ');
54
+ });
55
+
56
+ it('should pad strings to the right', () => {
57
+ assert.strictEqual(pad('test', 10, 'right'), ' test');
58
+ assert.strictEqual(pad('hello', 8, 'right'), ' hello');
59
+ });
60
+
61
+ it('should pad strings to the center', () => {
62
+ assert.strictEqual(pad('test', 10, 'center'), ' test ');
63
+ assert.strictEqual(pad('hello', 9, 'center'), ' hello ');
64
+ });
65
+
66
+ it('should truncate if string is longer than length', () => {
67
+ assert.strictEqual(getVisibleWidth(pad('hello world', 5)), 5);
68
+ });
69
+
70
+ it('should handle null and undefined', () => {
71
+ assert.strictEqual(pad(null as any, 5), ' ');
72
+ assert.strictEqual(pad(undefined as any, 5), ' ');
73
+ });
74
+
75
+ it('should pad CJK strings correctly by visible width', () => {
76
+ // 你好 = 2 chars, 4 visible width
77
+ const padded = pad('你好', 10);
78
+ assert.strictEqual(getVisibleWidth(padded), 10); // Should be 4 + 6 spaces = 10
79
+
80
+ const rightPadded = pad('你好', 10, 'right');
81
+ assert.strictEqual(getVisibleWidth(rightPadded), 10); // Should be 6 spaces + 4 = 10
82
+ });
83
+
84
+ it('should truncate CJK strings correctly by visible width', () => {
85
+ // 你好世界 = 4 chars, 8 visible width
86
+ const truncated = pad('你好世界', 5);
87
+ assert.strictEqual(getVisibleWidth(truncated), 5); // Should fit up to 5 width
88
+ });
89
+
90
+ it('should pad emoji strings correctly by visible width', () => {
91
+ // 👋 = 2 visible width
92
+ const padded = pad('👋', 10);
93
+ assert.strictEqual(getVisibleWidth(padded), 10); // Should be 2 + 8 spaces = 10
94
+ });
95
+
96
+ it('should truncate emoji strings correctly by visible width', () => {
97
+ // 👋👋👋 = 6 visible width
98
+ const truncated = pad('👋👋👋', 5);
99
+ assert.strictEqual(getVisibleWidth(truncated), 5); // Should fit 2 emoji + 1 space = 5
100
+ });
101
+ });
102
+
103
+ describe('formatValue', () => {
104
+ it('should format null as NULL', () => {
105
+ assert.strictEqual(formatValue(null), 'NULL');
106
+ });
107
+
108
+ it('should format undefined as empty string', () => {
109
+ assert.strictEqual(formatValue(undefined), '');
110
+ });
111
+
112
+ it('should format booleans', () => {
113
+ assert.strictEqual(formatValue(true), 'true');
114
+ assert.strictEqual(formatValue(false), 'false');
115
+ });
116
+
117
+ it('should format objects as JSON', () => {
118
+ assert.strictEqual(formatValue({ key: 'value' }), '{"key":"value"}');
119
+ assert.strictEqual(formatValue([1, 2, 3]), '[1,2,3]');
120
+ });
121
+
122
+ it('should format strings as-is', () => {
123
+ assert.strictEqual(formatValue('hello'), 'hello');
124
+ });
125
+
126
+ it('should format numbers as strings', () => {
127
+ assert.strictEqual(formatValue(123), '123');
128
+ assert.strictEqual(formatValue(45.67), '45.67');
129
+ });
130
+
131
+ it('should pretty print objects when requested', () => {
132
+ const obj = { a: 1, b: [2, 3] };
133
+ const formatted = formatValue(obj, undefined, true);
134
+ assert.ok(formatted.includes('\n'));
135
+ assert.ok(formatted.includes(' "a": 1'));
136
+ });
137
+
138
+ it('should preserve newlines when pretty is requested', () => {
139
+ const str = 'line1\nline2';
140
+ assert.strictEqual(formatValue(str, undefined, true), str);
141
+ });
142
+ });
143
+
144
+ describe('wrapText', () => {
145
+ it('should wrap simple text', () => {
146
+ const text = 'hello world';
147
+ const lines = wrapText(text, 5);
148
+ assert.deepStrictEqual(lines, ['hello', ' worl', 'd']);
149
+ });
150
+
151
+ it('should handle CJK characters', () => {
152
+ const text = '你好世界';
153
+ const lines = wrapText(text, 4);
154
+ assert.deepStrictEqual(lines, ['你好', '世界']);
155
+ });
156
+
157
+ it('should handle existing newlines', () => {
158
+ const text = 'line1\nline2';
159
+ const lines = wrapText(text, 10);
160
+ assert.deepStrictEqual(lines, ['line1', 'line2']);
161
+ });
162
+
163
+ it('should handle empty lines', () => {
164
+ const text = 'line1\n\nline2';
165
+ const lines = wrapText(text, 10);
166
+ assert.deepStrictEqual(lines, ['line1', '', 'line2']);
167
+ });
168
+ });
169
+
170
+ describe('getVisibleWidth', () => {
171
+ it('should get width of plain strings', () => {
172
+ assert.strictEqual(getVisibleWidth('hello'), 5);
173
+ assert.strictEqual(getVisibleWidth('test string'), 11);
174
+ });
175
+
176
+ it('should ignore ANSI escape codes', () => {
177
+ assert.strictEqual(getVisibleWidth('\x1b[1mhello\x1b[0m'), 5);
178
+ assert.strictEqual(getVisibleWidth('\x1b[31mred text\x1b[0m'), 8);
179
+ assert.strictEqual(getVisibleWidth('\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m'), 8);
180
+ });
181
+
182
+ it('should handle empty strings', () => {
183
+ assert.strictEqual(getVisibleWidth(''), 0);
184
+ });
185
+
186
+ it('should count CJK characters as double-width', () => {
187
+ assert.strictEqual(getVisibleWidth('你好'), 4); // 2 Chinese chars = 4 width
188
+ assert.strictEqual(getVisibleWidth('Hello世界'), 9); // 5 + 2*2 = 9
189
+ assert.strictEqual(getVisibleWidth('こんにちは'), 10); // 5 Japanese chars = 10 width
190
+ assert.strictEqual(getVisibleWidth('안녕하세요'), 10); // 5 Korean chars = 10 width
191
+ });
192
+
193
+ it('should count emoji as double-width', () => {
194
+ assert.strictEqual(getVisibleWidth('👋'), 2); // Wave emoji = 2 width
195
+ assert.strictEqual(getVisibleWidth('Hello! 👋'), 9); // 7 + 2 = 9
196
+ assert.strictEqual(getVisibleWidth('😀😁😂'), 6); // 3 emoji = 6 width
197
+ assert.strictEqual(getVisibleWidth('Test 🎉 OK'), 10); // 4 + 1 + 2 + 1 + 2 = 10
198
+ });
199
+
200
+ it('should handle mixed content with CJK and ANSI codes', () => {
201
+ assert.strictEqual(getVisibleWidth('\x1b[1m你好\x1b[0m'), 4);
202
+ assert.strictEqual(getVisibleWidth('Test测试'), 8); // 4 + 2*2 = 8
203
+ });
204
+
205
+ it('should handle mixed emoji, CJK, and ASCII', () => {
206
+ assert.strictEqual(getVisibleWidth('Hello 你好 👋'), 13); // 5 + 1 + 4 + 1 + 2 = 13
207
+ });
208
+ });
209
+ });
@@ -21,25 +21,41 @@ import stringWidth from 'string-width';
21
21
 
22
22
  export function truncate(str: string, maxWidth: number): string {
23
23
  if (!str) return '';
24
- const s = String(str);
24
+ let s = String(str);
25
25
 
26
+ // Optimization: handle extremely large strings by pre-truncating
27
+ // A single visible character can't be more than 4 code points (e.g., complex emoji)
28
+ // and double-width characters count as 2.
29
+ // So taking maxWidth * 4 characters is a very safe upper bound.
30
+ if (s.length > maxWidth * 4) {
31
+ s = s.slice(0, maxWidth * 4);
32
+ }
33
+
26
34
  const currentWidth = getVisibleWidth(s);
27
35
  if (currentWidth <= maxWidth) return s;
28
36
 
29
- // Need to truncate - build string character by character
30
- let result = '';
31
- let width = 0;
32
37
  const ellipsis = '...';
33
- const ellipsisWidth = 3; // '...' is 3 single-width chars
38
+ const ellipsisWidth = 3;
34
39
 
35
- // Use grapheme-aware widths using string-width
36
- for (const ch of Array.from(s)) {
37
- const charWidth = stringWidth(ch);
40
+ if (maxWidth <= ellipsisWidth) {
41
+ return ellipsis.slice(0, maxWidth);
42
+ }
38
43
 
39
- if (width + charWidth + ellipsisWidth > maxWidth) break;
44
+ // Optimization: for plain ASCII strings, we can use slice directly
45
+ const isPlainASCII = /^[\x20-\x7E]*$/.test(s);
46
+ if (isPlainASCII) {
47
+ return s.slice(0, maxWidth - ellipsisWidth) + ellipsis;
48
+ }
40
49
 
41
- result += ch;
42
- width += charWidth;
50
+ // For complex strings, we still need to be careful about double-width characters
51
+ // but we can optimize by taking a slice that is definitely not too long
52
+ let result = s.slice(0, maxWidth);
53
+ while (getVisibleWidth(result) + ellipsisWidth > maxWidth && result.length > 0) {
54
+ // Remove one character (potentially a multi-byte character or emoji)
55
+ // Using Array.from to correctly handle surrogate pairs
56
+ const chars = Array.from(result);
57
+ chars.pop();
58
+ result = chars.join('');
43
59
  }
44
60
 
45
61
  return result + ellipsis;
@@ -95,19 +111,80 @@ export function pad(str: string, targetWidth: number, align: 'left' | 'right' |
95
111
  /**
96
112
  * Format a value for display (handle null, undefined, etc.)
97
113
  * @param value - Value to format
114
+ * @param maxLen - Maximum length hint to prevent processing large strings
115
+ * @param pretty - Whether to use pretty printing for objects and preserve newlines
98
116
  * @returns Formatted string
99
117
  */
100
- export function formatValue(value: any): string {
118
+ export function formatValue(value: any, maxLen?: number, pretty?: boolean): string {
101
119
  if (value === null) return 'NULL';
102
120
  if (value === undefined) return '';
103
121
  if (typeof value === 'boolean') return value ? 'true' : 'false';
104
- if (typeof value === 'object') return JSON.stringify(value);
122
+ if (typeof value === 'object') return JSON.stringify(value, null, pretty ? 2 : undefined);
105
123
 
106
- // Convert to string and remove control characters (newlines, tabs, etc.)
107
- const str = String(value);
124
+ // Convert to string
125
+ let str = String(value);
126
+
127
+ if (pretty) return str;
128
+
129
+ // Pre-truncate if it's way too long
130
+ if (maxLen !== undefined && str.length > maxLen * 4) {
131
+ str = str.slice(0, maxLen * 4);
132
+ }
133
+
134
+ // Remove control characters (newlines, tabs, etc.)
108
135
  return str.replace(/[\n\r\t\v\f]/g, ' ').replace(/\s+/g, ' ');
109
136
  }
110
137
 
138
+ /**
139
+ * Wrap text into multiple lines based on visible width
140
+ * @param text - String to wrap
141
+ * @param maxWidth - Maximum visible width per line
142
+ * @returns Array of wrapped lines
143
+ */
144
+ export function wrapText(text: string, maxWidth: number): string[] {
145
+ if (!text) return [''];
146
+ if (maxWidth <= 0) return [text];
147
+
148
+ const lines: string[] = [];
149
+ const sourceLines = text.split(/\r?\n/);
150
+
151
+ for (const sourceLine of sourceLines) {
152
+ if (!sourceLine) {
153
+ lines.push('');
154
+ continue;
155
+ }
156
+
157
+ let currentLine = '';
158
+ let currentWidth = 0;
159
+
160
+ for (const char of Array.from(sourceLine)) {
161
+ const charWidth = getVisibleWidth(char);
162
+
163
+ if (currentWidth + charWidth > maxWidth) {
164
+ if (currentLine) {
165
+ lines.push(currentLine);
166
+ currentLine = char;
167
+ currentWidth = charWidth;
168
+ } else {
169
+ // Single character is wider than maxWidth, forced break
170
+ lines.push(char);
171
+ currentLine = '';
172
+ currentWidth = 0;
173
+ }
174
+ } else {
175
+ currentLine += char;
176
+ currentWidth += charWidth;
177
+ }
178
+ }
179
+
180
+ if (currentLine) {
181
+ lines.push(currentLine);
182
+ }
183
+ }
184
+
185
+ return lines;
186
+ }
187
+
111
188
  /**
112
189
  * Check if a character is double-width (CJK, emoji, etc.)
113
190
  * @param char - Single character or code point
@@ -125,13 +202,9 @@ function isDoubleWidth(_char: string): boolean {
125
202
  * @returns Visible width
126
203
  */
127
204
  export function getVisibleWidth(str: string): number {
205
+ if (!str) return 0;
128
206
  // Remove ANSI escape codes
129
207
  const cleanStr = str.replace(/\x1b\[[0-9;]*m/g, '');
130
208
 
131
- let width = 0;
132
- for (const ch of Array.from(cleanStr)) {
133
- width += stringWidth(ch);
134
- }
135
-
136
- return width;
209
+ return stringWidth(cleanStr);
137
210
  }