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.
@@ -1,13 +1,18 @@
1
- import { COLORS, BORDERS, UI } from './theme.ts';
2
- import { formatNumber, truncate, pad, formatValue, getVisibleWidth } from '../utils/format.ts';
1
+ import { THEME, ANSI } from './theme.ts';
2
+ import { formatNumber, truncate, pad, formatValue, getVisibleWidth, wrapText } from '../utils/format.ts';
3
+ import { Box, Transition, Grid, type ColumnConfig } from './grit/index.ts';
3
4
  import type { Screen } from './screen.ts';
4
5
  import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState, HealthViewState } from '../types.ts';
5
6
 
6
7
  /**
7
- * Renderer for ncdu-style TUI
8
+ * Modern Renderer (Manual ANSI Implementation)
9
+ * Emulates OpenTUI/OpenCode design with color blocks and no lines.
8
10
  */
9
11
  export class Renderer {
10
12
  private screen: Screen;
13
+ private lastLines: string[] = [];
14
+ private lastWidth: number = 0;
15
+ private lastHeight: number = 0;
11
16
 
12
17
  constructor(screen: Screen) {
13
18
  this.screen = screen;
@@ -15,571 +20,341 @@ export class Renderer {
15
20
 
16
21
  /**
17
22
  * Render the current state to screen
18
- * @param state - Current navigation state
19
- * @param dbPath - Database file path
20
23
  */
21
24
  render(state: ViewState, dbPath: string): void {
22
25
  const { width, height } = this.screen;
23
26
  const lines: string[] = [];
24
27
 
25
- // Build all lines
28
+ // Header transition color can match table header if in table-detail
29
+ const contentTopBg = state.type === 'table-detail' ? THEME.surface : THEME.background;
30
+
31
+ // 1. Title Bar (Header Block)
26
32
  lines.push(this.buildTitleBar(state, dbPath, width));
27
- lines.push(this.buildSeparator(width));
28
-
29
- const contentLines = this.buildContent(state, height - 3, width);
33
+ lines.push(Transition.draw(width, THEME.headerBg, contentTopBg));
34
+
35
+ // 2. Main Content area
36
+ const contentHeight = height - 4; // 1 header, 1 footer, 2 transitions
37
+ const contentLines = this.buildContent(state, contentHeight, width);
30
38
  lines.push(...contentLines);
31
-
39
+
40
+ // 3. Help/Status Bar (Footer Block)
41
+ lines.push(Transition.draw(width, THEME.background, THEME.footerBg));
32
42
  lines.push(this.buildHelpBar(state, width));
33
43
 
34
- // Clear and render
35
- this.screen.clear();
36
- this.screen.write(lines.join('\n'));
44
+ // Incremental Render Logic
45
+ if (width !== this.lastWidth || height !== this.lastHeight) {
46
+ // Screen resized: Full redraw from top, then clear remainder of screen
47
+ this.screen.moveCursor(1, 1);
48
+ this.screen.write(lines.join('\n'));
49
+ this.screen.write('\x1b[J'); // Clear remaining lines if new height is smaller
50
+ } else {
51
+ // Incremental update: Only write changed lines
52
+ for (let i = 0; i < lines.length; i++) {
53
+ if (lines[i] !== this.lastLines[i]) {
54
+ this.screen.moveCursor(i + 1, 1);
55
+ // Write line and clear to end of line to prevent ghosting
56
+ this.screen.write(lines[i] + '\x1b[K');
57
+ }
58
+ }
59
+ }
60
+
61
+ this.lastLines = lines;
62
+ this.lastWidth = width;
63
+ this.lastHeight = height;
37
64
  }
38
65
 
39
- /**
40
- * Build title bar (top line)
41
- */
42
66
  private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
43
67
  const fileName = dbPath.split('/').pop() || dbPath;
44
- let title = ` ${fileName}`;
68
+ let breadcrumb = ` ${fileName}`;
45
69
 
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
- } else if (state.type === 'health') {
53
- title += ` ${COLORS.dim}>${COLORS.reset} health`;
70
+ if (state.type === 'table-detail' || state.type === 'schema-view' || state.type === 'row-detail') {
71
+ breadcrumb += ` > ${state.tableName}`;
54
72
  }
55
-
56
- title = `${COLORS.bold}${title}${COLORS.reset}`;
57
-
58
- // Build right info
59
- let rightInfo = '';
73
+ if (state.type === 'schema-view') breadcrumb += ` > schema`;
74
+ if (state.type === 'row-detail') breadcrumb += ` > row ${state.rowIndex + 1}`;
75
+ if (state.type === 'health') breadcrumb += ` > health`;
76
+
77
+ const leftPart = `${ANSI.bold}${ANSI.fg(THEME.primary)}${breadcrumb}${ANSI.reset}`;
78
+
79
+ let rightPart = '';
60
80
  if (state.type === 'tables') {
61
- const totalTables = state.tables.length;
62
- const currentPos = state.cursor + 1;
63
- rightInfo = `${COLORS.dim}${currentPos}/${totalTables} tables${COLORS.reset}`;
81
+ rightPart = `${state.cursor + 1}/${state.tables.length} tables`;
64
82
  } else if (state.type === 'table-detail') {
65
- const totalRows = formatNumber(state.totalRows);
66
- const currentRow = state.dataOffset + state.dataCursor + 1;
67
- rightInfo = `${COLORS.dim}row ${formatNumber(currentRow)}/${totalRows}${COLORS.reset}`;
83
+ const current = state.dataOffset + state.dataCursor + 1;
84
+ rightPart = `row ${formatNumber(current)}/${formatNumber(state.totalRows)}`;
68
85
  } else if (state.type === 'schema-view') {
69
- const totalCols = state.schema.length;
70
- const currentPos = state.cursor + 1;
71
- rightInfo = `${COLORS.dim}${currentPos}/${totalCols} columns${COLORS.reset}`;
86
+ rightPart = `${state.cursor + 1}/${state.schema.length} columns`;
72
87
  } else if (state.type === 'row-detail') {
73
- const colCount = state.schema.length;
74
- rightInfo = `${COLORS.dim}${colCount} fields${COLORS.reset}`;
75
- } else if (state.type === 'health') {
76
- rightInfo = `${COLORS.dim}overview${COLORS.reset}`;
77
- }
78
-
79
- // Calculate spacing using visible width (accounts for CJK double-width)
80
- const titleWidth = getVisibleWidth(title);
81
- const rightInfoWidth = getVisibleWidth(rightInfo);
82
- const availableSpace = width - titleWidth - rightInfoWidth;
83
-
84
- // Adjust width to prevent wrapping issues with ambiguous width characters
85
- const safeWidth = width - 1;
86
-
87
- if (availableSpace > 0) {
88
- // Ensure we don't exceed safeWidth
89
- let result = title + ' '.repeat(availableSpace) + rightInfo;
90
- const resultWidth = getVisibleWidth(result);
91
- if (resultWidth > safeWidth) {
92
- return truncate(result, safeWidth);
93
- }
94
- return result;
95
- } else {
96
- // Not enough space, truncate title
97
- const maxTitleWidth = safeWidth - rightInfoWidth - 3; // Reserve space for "..."
98
- if (maxTitleWidth > 10) {
99
- const truncatedTitle = truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), maxTitleWidth);
100
- const truncatedWidth = getVisibleWidth(truncatedTitle);
101
- const padding = safeWidth - truncatedWidth - rightInfoWidth;
102
- return `${COLORS.bold}${truncatedTitle}${COLORS.reset}` + ' '.repeat(Math.max(0, padding)) + rightInfo;
103
- } else {
104
- // Very narrow screen, just show title
105
- return truncate(title.replace(/\x1b\[[0-9;]*m/g, ''), safeWidth);
106
- }
88
+ rightPart = `${state.schema.length} fields`;
107
89
  }
108
- }
109
90
 
110
- /**
111
- * Build separator line
112
- */
113
- private buildSeparator(width: number): string {
114
- return `${COLORS.dim}${BORDERS.horizontal.repeat(width)}${COLORS.reset}`;
91
+ const rightPartStyled = `${ANSI.fg(THEME.textDim)}${rightPart}${ANSI.reset}`;
92
+
93
+ const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPartStyled)));
94
+ const rowContent = `${leftPart}${padding}${rightPartStyled}`;
95
+
96
+ const box = new Box({ width, padding: 1, background: THEME.headerBg });
97
+ return box.render(rowContent);
115
98
  }
116
99
 
117
- /**
118
- * Build main content area
119
- */
120
100
  private buildContent(state: ViewState, height: number, width: number): string[] {
101
+ let content: string[] = [];
102
+ const bgBox = new Box({ width, background: THEME.background, padding: 1 });
103
+
121
104
  if (state.type === 'tables') {
122
- return this.buildTablesList(state, height, width);
105
+ content = this.renderTables(state, height, width);
123
106
  } else if (state.type === 'table-detail') {
124
- return this.buildTableDetail(state, height, width);
107
+ content = this.renderTableDetail(state, height, width);
125
108
  } else if (state.type === 'schema-view') {
126
- return this.buildSchemaView(state, height, width);
109
+ content = this.renderSchema(state, height, width);
127
110
  } else if (state.type === 'row-detail') {
128
- return this.buildRowDetail(state, height, width);
111
+ content = this.renderRowDetail(state, height, width);
129
112
  } else if (state.type === 'health') {
130
- return this.buildHealthView(state, height, width);
113
+ content = this.renderHealth(state, height, width);
114
+ }
115
+
116
+ // Fill remaining lines with background
117
+ while (content.length < height) {
118
+ content.push(bgBox.render(''));
131
119
  }
132
- return [];
120
+ return content;
133
121
  }
134
122
 
135
- /**
136
- * Build tables list view
137
- */
138
- private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
123
+ private renderTables(state: TablesViewState, height: number, width: number): string[] {
139
124
  const lines: string[] = [];
140
125
  const { tables, cursor } = state;
126
+ const box = new Box({ width, padding: 1 });
141
127
 
142
- if (tables.length === 0) {
143
- lines.push('');
144
- lines.push(pad('No tables found', width, 'center'));
145
- while (lines.length < height) {
146
- lines.push('');
147
- }
148
- return lines;
149
- }
150
-
151
- // Calculate visible window
152
- const halfHeight = Math.floor(height / 2);
153
- let startIdx = Math.max(0, cursor - halfHeight);
154
- let endIdx = Math.min(tables.length, startIdx + height);
155
-
156
- // Adjust if at the end
157
- if (endIdx - startIdx < height) {
158
- startIdx = Math.max(0, endIdx - height);
159
- }
128
+ const half = Math.floor(height / 2);
129
+ let start = Math.max(0, cursor - half);
130
+ let end = Math.min(tables.length, start + height);
131
+ if (end - start < height) start = Math.max(0, end - height);
160
132
 
161
- for (let i = startIdx; i < endIdx; i++) {
162
- const table = tables[i];
133
+ for (let i = start; i < end; i++) {
163
134
  const isSelected = i === cursor;
135
+ const table = tables[i];
164
136
 
165
- // Build line without color codes first
166
- const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
167
- const maxNameWidth = width - 20; // Reserve space for row count
168
- const name = truncate(table.name, maxNameWidth - 5);
169
- const namePadded = pad(name, maxNameWidth - 3);
170
- const count = formatNumber(table.row_count) + ' rows';
171
- const countPadded = pad(count, 15, 'right');
172
-
173
- let content = `${cursorChar} ${namePadded} ${countPadded}`;
174
-
175
- // Ensure exact width before adding color codes
176
- const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
177
- const contentWidth = getVisibleWidth(content);
178
- if (contentWidth > safeWidth) {
179
- content = truncate(content, safeWidth);
180
- } else if (contentWidth < safeWidth) {
181
- content += ' '.repeat(safeWidth - contentWidth);
182
- }
183
-
184
- // Apply color codes to properly sized line
185
- let line: string;
186
- if (isSelected) {
187
- line = COLORS.inverse + content + COLORS.reset;
188
- } else {
189
- line = content;
190
- }
191
-
192
- lines.push(line);
193
- }
137
+ const name = isSelected ? `${ANSI.bold}${table.name}` : `${table.name}`;
138
+ const count = `${formatNumber(table.row_count)} rows`;
194
139
 
195
- // Fill remaining lines
196
- while (lines.length < height) {
197
- lines.push(' '.repeat(width));
198
- }
140
+ const bg = isSelected ? THEME.selectionBg : THEME.background;
141
+ const fg = isSelected ? THEME.primary : THEME.text;
199
142
 
143
+ const leftPart = `${ANSI.fg(fg)}${name}${ANSI.reset}`;
144
+ const rightPart = `${ANSI.fg(isSelected ? fg : THEME.textDim)}${count}${ANSI.reset}`;
145
+
146
+ const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPart)));
147
+ const rowContent = `${leftPart}${padding}${rightPart}`;
148
+ lines.push(box.render(rowContent, { background: bg }));
149
+ }
200
150
  return lines;
201
151
  }
202
152
 
203
- /**
204
- * Build table detail view
205
- */
206
- private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
153
+ private renderTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
207
154
  const lines: string[] = [];
208
- const { data, totalRows, dataOffset, dataCursor } = state;
155
+ const { data, dataOffset, dataCursor, bufferOffset } = state;
156
+ const box = new Box({ width, padding: 1 });
209
157
 
210
- if (data.length === 0) {
211
- lines.push(pad('No data', width, 'center'));
212
- } else {
213
- // Get column names
214
- const columns = Object.keys(data[0]);
215
-
216
- // Calculate optimal column widths based on content
158
+ if (data.length === 0) return [box.render('No data', { align: 'center' })];
159
+
160
+ const columns = Object.keys(data[0]).slice(0, 8);
161
+
162
+ // Calculate or use cached column widths
163
+ if (!state.cachedColWidths || state.cachedScreenWidth !== width) {
164
+ const numCols = columns.length;
217
165
  const minColWidth = 8;
218
- const maxColWidth = 50; // Maximum width for any single column
219
- const maxVisibleCols = 8;
220
- const visibleColumns = columns.slice(0, maxVisibleCols);
221
- const availableWidth = width - 4; // Reserve space for padding and cursor
222
- const spacingWidth = visibleColumns.length - 1; // Space between columns
223
- const usableWidth = availableWidth - spacingWidth;
224
-
225
- // Calculate ideal width for each column based on content
226
- const idealWidths = visibleColumns.map(col => {
227
- // Check column name length
228
- let maxWidth = col.length;
229
-
230
- // Check data values length (sample first few rows for performance)
231
- const sampleSize = Math.min(data.length, 20);
232
- for (let i = 0; i < sampleSize; i++) {
233
- const value = formatValue(data[i][col]);
234
- maxWidth = Math.max(maxWidth, value.length);
235
- }
236
-
237
- // Apply constraints: add 2 for padding, cap at maxColWidth
238
- return Math.max(minColWidth, Math.min(maxWidth + 2, maxColWidth));
239
- });
240
-
241
- // Calculate total ideal width
242
- let totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0);
243
-
244
- // Allocate widths
245
- const colWidths: number[] = [];
246
-
247
- if (totalIdealWidth <= usableWidth) {
248
- // We have extra space - distribute it intelligently
249
- const extraSpace = usableWidth - totalIdealWidth;
250
-
251
- // Find columns that could use more space (those at maxColWidth)
252
- const expandableIndices = idealWidths
253
- .map((w, i) => ({ width: w, index: i }))
254
- .filter(item => item.width === maxColWidth)
255
- .map(item => item.index);
256
-
257
- // Distribute extra space only to expandable columns
258
- if (expandableIndices.length > 0) {
259
- const extraPerCol = Math.floor(extraSpace / expandableIndices.length);
260
- for (let i = 0; i < visibleColumns.length; i++) {
261
- if (expandableIndices.includes(i)) {
262
- colWidths[i] = idealWidths[i] + extraPerCol;
263
- } else {
264
- colWidths[i] = idealWidths[i];
265
- }
266
- }
267
- // Add remainder to last expandable column
268
- const remainder = extraSpace - (extraPerCol * expandableIndices.length);
269
- colWidths[expandableIndices[expandableIndices.length - 1]] += remainder;
270
- } else {
271
- // No expandable columns, distribute evenly to all
272
- const extraPerCol = Math.floor(extraSpace / visibleColumns.length);
273
- for (let i = 0; i < visibleColumns.length; i++) {
274
- colWidths[i] = idealWidths[i] + extraPerCol;
275
- }
276
- const remainder = extraSpace - (extraPerCol * visibleColumns.length);
277
- colWidths[colWidths.length - 1] += remainder;
278
- }
279
- } else {
280
- // Need to scale down - use proportional scaling
281
- const scale = usableWidth / totalIdealWidth;
282
- for (let i = 0; i < visibleColumns.length; i++) {
283
- colWidths[i] = Math.max(minColWidth, Math.floor(idealWidths[i] * scale));
284
- }
285
-
286
- // Adjust to fill exact width
287
- const totalWidth = colWidths.reduce((sum, w) => sum + w, 0);
288
- const diff = usableWidth - totalWidth;
289
- if (diff !== 0) {
290
- colWidths[colWidths.length - 1] += diff;
291
- }
292
- }
293
-
294
- // Render table header
295
- const headerCells = visibleColumns.map((col, idx) => {
296
- const truncatedCol = truncate(col, colWidths[idx] - 1);
297
- return pad(truncatedCol, colWidths[idx] - 1);
298
- });
299
-
300
- let headerLine = ` ${COLORS.dim} ${headerCells.join(' ')}${COLORS.reset}`;
301
- const headerWidth = getVisibleWidth(headerLine);
302
-
303
- if (headerWidth < width) {
304
- headerLine += ' '.repeat(width - headerWidth);
305
- } else if (headerWidth > width) {
306
- // Truncate if somehow too wide
307
- const plainHeader = ` ${headerCells.map(c => c.replace(/\x1b\[[0-9;]*m/g, '')).join(' ')}`;
308
- headerLine = truncate(plainHeader, width);
309
- }
310
-
311
- lines.push(headerLine);
312
-
313
- // Update visible rows for navigator (subtract 1 for header)
314
- state.visibleRows = height - 1;
315
-
316
- // Render data rows
317
- const maxRows = Math.min(data.length, height - 1);
318
-
319
- for (let i = 0; i < maxRows; i++) {
320
- const row = data[i];
321
- const isSelected = i === dataCursor;
322
-
323
- const cells = visibleColumns.map((col, idx) => {
324
- const value = formatValue(row[col]);
325
- return truncate(value, colWidths[idx] - 1);
326
- });
327
-
328
- // Build content without color codes first
329
- const prefix = isSelected ? ` ${UI.cursor}` : ' ';
330
- const contentWithoutPrefix = cells.map((cell, idx) => pad(cell, colWidths[idx] - 1)).join(' ');
331
-
332
- // Ensure exact width before adding color codes (using visible width for CJK)
333
- const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
334
- let content = prefix + ' ' + contentWithoutPrefix;
335
- const contentWidth = getVisibleWidth(content);
336
-
337
- if (contentWidth > safeWidth) {
338
- content = truncate(content, safeWidth);
339
- } else if (contentWidth < safeWidth) {
340
- content += ' '.repeat(safeWidth - contentWidth);
341
- }
342
-
343
- // Apply color codes to properly sized line
344
- let line: string;
345
- if (isSelected) {
346
- line = COLORS.inverse + content + COLORS.reset;
347
- } else {
348
- line = content;
349
- }
350
-
351
- lines.push(line);
352
- }
353
- }
354
166
 
355
- // Fill remaining lines
356
- while (lines.length < height) {
357
- lines.push(' '.repeat(width));
167
+ const configs: ColumnConfig[] = columns.map((_, i) => ({
168
+ weight: (state.columnWeights && state.columnWeights[i]) || 1,
169
+ minWidth: minColWidth
170
+ }));
171
+
172
+ state.cachedColWidths = Grid.calculateWidths(width - 2, configs);
173
+ state.cachedScreenWidth = width;
358
174
  }
359
175
 
176
+ const colWidths = state.cachedColWidths!;
177
+
178
+ // Table Header Block
179
+ let headerContent = `${ANSI.fg(THEME.textDim)}${ANSI.bold}`;
180
+ columns.forEach((col, i) => {
181
+ const w = colWidths[i];
182
+ headerContent += pad(col, w - 1).slice(0, w - 1) + ' ';
183
+ });
184
+ lines.push(box.render(headerContent, { background: THEME.surface }));
185
+ lines.push(Transition.draw(width, THEME.surface, THEME.background));
186
+
187
+ // Data Rows
188
+ const relativeOffset = dataOffset - bufferOffset;
189
+ const displayData = data.slice(relativeOffset, relativeOffset + height - 2);
190
+ state.visibleRows = height - 2;
191
+
192
+ displayData.forEach((row, idx) => {
193
+ const isSelected = idx === dataCursor;
194
+ const rowBg = isSelected ? THEME.selectionBg : THEME.background;
195
+ const rowFg = isSelected ? THEME.primary : THEME.text;
196
+
197
+ let rowContent = `${ANSI.fg(rowFg)}`;
198
+ columns.forEach((col, i) => {
199
+ const w = colWidths[i];
200
+ const val = formatValue(row[col], w - 1);
201
+ rowContent += pad(val, w - 1).slice(0, w - 1) + ' ';
202
+ });
203
+ lines.push(box.render(rowContent, { background: rowBg }));
204
+ });
205
+
360
206
  return lines;
361
207
  }
362
208
 
363
- /**
364
- * Build schema view (full screen)
365
- */
366
- private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
209
+ private renderSchema(state: SchemaViewState, height: number, width: number): string[] {
367
210
  const lines: string[] = [];
368
- const { schema, cursor, scrollOffset } = state;
369
-
370
- if (schema.length === 0) {
371
- lines.push(pad('No schema information', width, 'center'));
372
- while (lines.length < height) {
373
- lines.push(' '.repeat(width));
374
- }
375
- return lines;
376
- }
377
-
378
- // Calculate visible window
379
- const contentHeight = height; // Use full height
380
- const halfHeight = Math.floor(contentHeight / 2);
381
- let startIdx = Math.max(0, cursor - halfHeight);
382
- let endIdx = Math.min(schema.length, startIdx + contentHeight);
383
-
384
- // Adjust if at the end
385
- if (endIdx - startIdx < contentHeight) {
386
- startIdx = Math.max(0, endIdx - contentHeight);
387
- }
211
+ const { schema, cursor } = state;
212
+ const box = new Box({ width, padding: 1 });
388
213
 
389
- // Calculate column widths
390
- const nameWidth = Math.min(30, Math.max(...schema.map(col => col.name.length)) + 2);
391
- const typeWidth = Math.min(20, Math.max(...schema.map(col => col.type.length)) + 2);
392
- const attrWidth = width - nameWidth - typeWidth - 10;
214
+ const half = Math.floor(height / 2);
215
+ let start = Math.max(0, cursor - half);
216
+ let end = Math.min(schema.length, start + height);
217
+ if (end - start < height) start = Math.max(0, end - height);
393
218
 
394
- // Render schema rows
395
- for (let i = startIdx; i < endIdx; i++) {
219
+ for (let i = start; i < end; i++) {
396
220
  const col = schema[i];
397
221
  const isSelected = i === cursor;
222
+ const rowBg = isSelected ? THEME.selectionBg : THEME.background;
398
223
 
399
- const name = pad(truncate(col.name, nameWidth - 1), nameWidth);
400
- const type = pad(truncate(col.type, typeWidth - 1), typeWidth);
401
-
402
- // Build attributes string
403
- const attrs: string[] = [];
224
+ const name = pad(col.name, 25);
225
+ const type = pad(col.type, 15);
226
+ const attrs = [];
404
227
  if (col.pk) attrs.push('PK');
405
228
  if (col.notnull) attrs.push('NOT NULL');
406
- if (col.dflt_value !== null) attrs.push(`DEFAULT ${col.dflt_value}`);
407
- const attrStr = attrs.length > 0 ? truncate(attrs.join(', '), attrWidth) : '';
408
-
409
- // Build content without selection color first
410
- const cursorChar = isSelected ? ` ${UI.cursor}` : ' ';
411
- const content = `${cursorChar} ${name} ${COLORS.cyan}${type}${COLORS.reset} ${COLORS.dim}${attrStr}${COLORS.reset}`;
412
-
413
- // Calculate visible width (accounting for CJK and ANSI codes)
414
- const contentWidth = getVisibleWidth(content);
415
229
 
416
- // Build line with exact width
417
- const safeWidth = width - 1; // Subtract 1 to prevent wrapping issues
418
- let line: string;
419
- if (contentWidth < safeWidth) {
420
- line = content + ' '.repeat(safeWidth - contentWidth);
421
- } else if (contentWidth > safeWidth) {
422
- // Need to truncate - build without colors first
423
- const basicContent = `${cursorChar} ${name} ${type} ${attrStr}`;
424
- const truncatedBasic = truncate(basicContent, safeWidth);
425
- line = truncatedBasic;
230
+ let rowContent = `${ANSI.fg(isSelected ? THEME.primary : THEME.text)}${name}`;
231
+ rowContent += `${ANSI.fg(THEME.secondary)}${type}`;
232
+ rowContent += `${ANSI.fg(THEME.textDim)}${attrs.join(', ')}`;
233
+ lines.push(box.render(rowContent, { background: rowBg }));
234
+ }
235
+ return lines;
236
+ }
237
+
238
+ private renderRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
239
+ const allLines: string[] = [];
240
+ const { row, schema } = state;
241
+ const innerWidth = width - 2;
242
+ const box = new Box({ width, padding: 1, background: THEME.background });
243
+
244
+ // Calculate max label width for alignment
245
+ let maxLabelWidth = 0;
246
+ schema.forEach(col => {
247
+ maxLabelWidth = Math.max(maxLabelWidth, getVisibleWidth(col.name));
248
+ });
249
+ const labelPad = maxLabelWidth + 2; // +2 for ": "
250
+
251
+ schema.forEach((col) => {
252
+ const label = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${pad(col.name, maxLabelWidth)}${ANSI.reset}: `;
253
+ const val = formatValue(row[col.name], undefined, true);
254
+
255
+ if (labelPad > innerWidth * 0.4) {
256
+ // Label too long, fallback to simpler layout
257
+ const simpleLabel = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${col.name}${ANSI.reset}: `;
258
+ allLines.push(box.render(simpleLabel));
259
+ const wrappedLines = wrapText(val, innerWidth);
260
+ wrappedLines.forEach(line => {
261
+ allLines.push(box.render(`${ANSI.fg(THEME.text)}${line}`));
262
+ });
426
263
  } else {
427
- line = content;
428
- }
429
-
430
- // Apply inverse color for selected row (wraps entire line)
431
- if (isSelected) {
432
- line = COLORS.inverse + line + COLORS.reset;
264
+ const firstLineMax = innerWidth - labelPad;
265
+ const wrappedLines = wrapText(val, firstLineMax);
266
+
267
+ if (wrappedLines.length === 0 || (wrappedLines.length === 1 && wrappedLines[0] === '')) {
268
+ allLines.push(box.render(`${label}`));
269
+ } else {
270
+ allLines.push(box.render(`${label}${ANSI.fg(THEME.text)}${wrappedLines[0]}`));
271
+
272
+ if (wrappedLines.length > 1) {
273
+ wrappedLines.slice(1).forEach(line => {
274
+ allLines.push(box.render(`${' '.repeat(labelPad)}${ANSI.fg(THEME.text)}${line}`));
275
+ });
276
+ }
277
+ }
433
278
  }
434
-
435
- lines.push(line);
436
- }
279
+ });
437
280
 
438
- // Fill remaining lines
439
- while (lines.length < height) {
440
- lines.push(' '.repeat(width));
441
- }
281
+ state.totalLines = allLines.length;
282
+ state.visibleHeight = height;
442
283
 
443
- return lines;
284
+ // Apply scroll offset
285
+ return allLines.slice(state.scrollOffset, state.scrollOffset + height);
444
286
  }
445
287
 
446
- /**
447
- * Build row detail view
448
- */
449
- private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
288
+ private renderHealth(state: HealthViewState, height: number, width: number): string[] {
450
289
  const lines: string[] = [];
451
- const { row, schema, tableName, rowIndex } = state;
452
-
453
- // Get max column name length for alignment
454
- const maxColNameLength = Math.max(...schema.map(col => col.name.length), 10);
455
- const rightMargin = 2; // Right side margin
456
- const valueWidth = width - maxColNameLength - 5 - rightMargin;
457
-
458
- // Display each field (one line per field)
459
- let lineCount = 0;
460
- for (const col of schema) {
461
- if (lineCount >= height) break;
462
-
463
- const colName = `${COLORS.cyan}${pad(col.name, maxColNameLength)}${COLORS.reset}`;
464
- const value = formatValue(row[col.name]);
465
-
466
- // Truncate value to fit in single line
467
- const truncatedValue = truncate(value, valueWidth);
468
-
469
- let line = ` ${colName} ${COLORS.dim}:${COLORS.reset} ${truncatedValue}`;
470
- const lineWidth = getVisibleWidth(line);
471
- if (lineWidth < width - rightMargin) {
472
- line += ' '.repeat(width - rightMargin - lineWidth);
473
- }
474
- lines.push(line);
475
- lineCount++;
476
- }
477
-
478
- // Fill remaining lines
479
- while (lines.length < height) {
480
- lines.push(' '.repeat(width));
481
- }
482
-
290
+ const entries = Object.entries(state.info);
291
+ const box = new Box({ width, padding: 1, background: THEME.background });
292
+
293
+ entries.forEach(([key, val], idx) => {
294
+ if (idx >= height) return;
295
+ const label = pad(key.replace(/_/g, ' '), 25);
296
+ const rowContent = `${ANSI.fg(THEME.secondary)}${label}${ANSI.reset} : ${val}`;
297
+ lines.push(box.render(rowContent));
298
+ });
483
299
  return lines;
484
300
  }
485
301
 
486
- /**
487
- * Build health overview view
488
- */
489
- private buildHealthView(state: HealthViewState, height: number, width: number): string[] {
490
- const lines: string[] = [];
491
- const { info } = state;
492
-
493
- const title = `${COLORS.bold}Core health overview${COLORS.reset}`;
494
- lines.push(pad(title, width));
495
-
496
- const formatBool = (value: string): string => (value === '1' ? 'on' : value === '0' ? 'off' : value);
497
- const formatAutoVacuum = (value: string): string => {
498
- if (value === '0') return 'none';
499
- if (value === '1') return 'full';
500
- if (value === '2') return 'incremental';
501
- return value;
502
- };
503
-
504
- const entries: Array<[string, string]> = [
505
- ['SQLite version', info.sqlite_version],
506
- ['Journal mode', info.journal_mode],
507
- ['Synchronous', info.synchronous],
508
- ['Locking mode', info.locking_mode],
509
- ['Page size', formatNumber(info.page_size)],
510
- ['Page count', formatNumber(info.page_count)],
511
- ['Freelist pages', formatNumber(info.freelist_count)],
512
- ['Cache size', formatNumber(info.cache_size)],
513
- ['WAL autocheckpoint', formatNumber(info.wal_autocheckpoint)],
514
- ['Auto vacuum', formatAutoVacuum(info.auto_vacuum)],
515
- ['User version', String(info.user_version)],
516
- ['Application id', String(info.application_id)],
517
- ['Encoding', info.encoding],
518
- ['Foreign keys', formatBool(info.foreign_keys)],
519
- ['Temp store', info.temp_store],
520
- ['Mmap size', formatNumber(info.mmap_size)],
521
- ['Busy timeout', `${formatNumber(info.busy_timeout)} ms`]
522
- ];
523
-
524
- const labelWidth = Math.min(24, Math.max(...entries.map(([label]) => label.length)) + 1);
525
- const contentWidth = width - labelWidth - 4;
526
-
527
- for (const [label, value] of entries) {
528
- if (lines.length >= height) break;
529
- const labelText = pad(label, labelWidth, 'right');
530
- const valueText = truncate(value, contentWidth);
531
- const line = ` ${COLORS.cyan}${labelText}${COLORS.reset} ${COLORS.dim}:${COLORS.reset} ${valueText}`;
532
- const lineWidth = getVisibleWidth(line);
533
- if (lineWidth < width) {
534
- lines.push(line + ' '.repeat(width - lineWidth));
535
- } else {
536
- const basicLine = ` ${labelText} : ${valueText}`;
537
- lines.push(truncate(basicLine, width - 1));
538
- }
302
+ private buildHelpBar(state: ViewState, width: number): string {
303
+ const box = new Box({ width, padding: 1, background: THEME.footerBg });
304
+
305
+ if ((state as any).notice) {
306
+ return box.render(`${ANSI.fg(THEME.textDim)}${(state as any).notice}`);
539
307
  }
540
308
 
541
- while (lines.length < height) {
542
- lines.push(' '.repeat(width));
309
+ let helpItems: { key: string; label: string }[] = [];
310
+ switch (state.type) {
311
+ case 'tables':
312
+ helpItems = [
313
+ { key: 'j/k', label: 'select' },
314
+ { key: 'g/G', label: 'first/last' },
315
+ { key: 'Enter/l', label: 'open' },
316
+ { key: 'i', label: 'info' },
317
+ { key: 'q', label: 'quit' }
318
+ ];
319
+ break;
320
+ case 'table-detail':
321
+ helpItems = [
322
+ { key: 'j/k', label: 'scroll' },
323
+ { key: 'g/G', label: 'first/last' },
324
+ { key: 'Enter/l', label: 'row' },
325
+ { key: 's', label: 'schema' },
326
+ { key: 'h', label: 'back' },
327
+ { key: 'q', label: 'quit' }
328
+ ];
329
+ break;
330
+ case 'schema-view':
331
+ helpItems = [
332
+ { key: 'j/k', label: 'scroll' },
333
+ { key: 'g/G', label: 'first/last' },
334
+ { key: 's/h', label: 'back' },
335
+ { key: 'q', label: 'quit' }
336
+ ];
337
+ break;
338
+ case 'row-detail':
339
+ helpItems = [
340
+ { key: 'j/k', label: 'switch' },
341
+ { key: '↑/↓', label: 'scroll' },
342
+ { key: 'h', label: 'back' },
343
+ { key: 'q', label: 'quit' }
344
+ ];
345
+ break;
346
+ default:
347
+ helpItems = [
348
+ { key: 'h', label: 'back' },
349
+ { key: 'q', label: 'quit' }
350
+ ];
351
+ break;
543
352
  }
544
353
 
545
- return lines;
546
- }
354
+ const styledHelp = helpItems
355
+ .map(item => `${ANSI.fg(THEME.text)}${item.key} ${ANSI.fg(THEME.textDim)}${item.label}`)
356
+ .join(' ');
547
357
 
548
- /**
549
- * Build help bar (bottom line)
550
- */
551
- private buildHelpBar(state: ViewState, width: number): string {
552
- let help = '';
553
- const notice =
554
- (state.type === 'table-detail' || state.type === 'row-detail') ? state.notice : undefined;
555
- const deleteStep =
556
- (state.type === 'table-detail' || state.type === 'row-detail') ? state.deleteConfirm?.step : undefined;
557
- const isDeleteConfirm =
558
- (state.type === 'table-detail' || state.type === 'row-detail') ? Boolean(state.deleteConfirm) : false;
559
-
560
- if (notice && isDeleteConfirm) {
561
- const stepLabel = deleteStep ? `Step ${deleteStep}/2` : '';
562
- const actionLabel = deleteStep === 2 ? 'delete' : 'confirm';
563
- help = ` ${COLORS.red}${COLORS.bold}${stepLabel ? `${stepLabel} ` : ''}${notice}${COLORS.reset} ${COLORS.yellow}[y] ${actionLabel}${COLORS.reset} [h/Esc] cancel`;
564
- const paddedHelp = pad(help, width);
565
- return paddedHelp;
566
- } else if (notice) {
567
- help = ` ${COLORS.yellow}${notice}${COLORS.reset}`;
568
- const paddedHelp = pad(help, width);
569
- return paddedHelp;
570
- } else if (state.type === 'tables') {
571
- help = ' [j/k] select [Enter/l] open [i] info [g/G] top/bottom [q] quit';
572
- } else if (state.type === 'table-detail') {
573
- help = ' [j/k] scroll [Enter/l] view row [Backspace] delete [s] toggle schema [h/Esc] back [q] quit';
574
- } else if (state.type === 'schema-view') {
575
- help = ' [j/k] scroll [g/G] top/bottom [s/h/Esc] back [q] quit';
576
- } else if (state.type === 'row-detail') {
577
- help = ' [Backspace] delete [h/Esc] back [q] quit';
578
- } else if (state.type === 'health') {
579
- help = ' [i] back [h/Esc] back [q] quit';
580
- }
581
-
582
- const paddedHelp = pad(help, width);
583
- return `${COLORS.dim}${paddedHelp}${COLORS.reset}`;
358
+ return box.render(styledHelp, { align: 'right' });
584
359
  }
585
360
  }