dbn-cli 0.3.0 → 0.5.2

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,10 +1,12 @@
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
- import type { ViewState, TablesViewState, TableDetailViewState, SchemaViewState, RowDetailViewState } from '../types.ts';
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;
@@ -15,20 +17,25 @@ export class Renderer {
15
17
 
16
18
  /**
17
19
  * Render the current state to screen
18
- * @param state - Current navigation state
19
- * @param dbPath - Database file path
20
20
  */
21
21
  render(state: ViewState, dbPath: string): void {
22
22
  const { width, height } = this.screen;
23
23
  const lines: string[] = [];
24
24
 
25
- // Build all lines
25
+ // Header transition color can match table header if in table-detail
26
+ const contentTopBg = state.type === 'table-detail' ? THEME.surface : THEME.background;
27
+
28
+ // 1. Title Bar (Header Block)
26
29
  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(Transition.draw(width, THEME.headerBg, contentTopBg));
31
+
32
+ // 2. Main Content area
33
+ const contentHeight = height - 4; // 1 header, 1 footer, 2 transitions
34
+ const contentLines = this.buildContent(state, contentHeight, width);
30
35
  lines.push(...contentLines);
31
-
36
+
37
+ // 3. Help/Status Bar (Footer Block)
38
+ lines.push(Transition.draw(width, THEME.background, THEME.footerBg));
32
39
  lines.push(this.buildHelpBar(state, width));
33
40
 
34
41
  // Clear and render
@@ -36,464 +43,295 @@ export class Renderer {
36
43
  this.screen.write(lines.join('\n'));
37
44
  }
38
45
 
39
- /**
40
- * Build title bar (top line)
41
- */
42
46
  private buildTitleBar(state: ViewState, dbPath: string, width: number): string {
43
47
  const fileName = dbPath.split('/').pop() || dbPath;
44
- let title = ` ${fileName}`;
48
+ let breadcrumb = ` ${fileName}`;
45
49
 
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}`;
50
+ if (state.type === 'table-detail' || state.type === 'schema-view' || state.type === 'row-detail') {
51
+ breadcrumb += ` > ${state.tableName}`;
52
52
  }
53
-
54
- title = `${COLORS.bold}${title}${COLORS.reset}`;
55
-
56
- // Build right info
57
- let rightInfo = '';
53
+ if (state.type === 'schema-view') breadcrumb += ` > schema`;
54
+ if (state.type === 'row-detail') breadcrumb += ` > row ${state.rowIndex + 1}`;
55
+ if (state.type === 'health') breadcrumb += ` > health`;
56
+
57
+ const leftPart = `${ANSI.bold}${ANSI.fg(THEME.primary)}${breadcrumb}${ANSI.reset}`;
58
+
59
+ let rightPart = '';
58
60
  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}`;
61
+ rightPart = `${state.cursor + 1}/${state.tables.length} tables`;
62
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}`;
63
+ const current = state.dataOffset + state.dataCursor + 1;
64
+ rightPart = `row ${formatNumber(current)}/${formatNumber(state.totalRows)}`;
66
65
  } 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}`;
66
+ rightPart = `${state.cursor + 1}/${state.schema.length} columns`;
70
67
  } 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
- }
68
+ rightPart = `${state.schema.length} fields`;
103
69
  }
104
- }
105
70
 
106
- /**
107
- * Build separator line
108
- */
109
- private buildSeparator(width: number): string {
110
- return `${COLORS.dim}${BORDERS.horizontal.repeat(width)}${COLORS.reset}`;
71
+ const rightPartStyled = `${ANSI.fg(THEME.textDim)}${rightPart}${ANSI.reset}`;
72
+
73
+ const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPartStyled)));
74
+ const rowContent = `${leftPart}${padding}${rightPartStyled}`;
75
+
76
+ const box = new Box({ width, padding: 1, background: THEME.headerBg });
77
+ return box.render(rowContent);
111
78
  }
112
79
 
113
- /**
114
- * Build main content area
115
- */
116
80
  private buildContent(state: ViewState, height: number, width: number): string[] {
81
+ let content: string[] = [];
82
+ const bgBox = new Box({ width, background: THEME.background, padding: 1 });
83
+
117
84
  if (state.type === 'tables') {
118
- return this.buildTablesList(state, height, width);
85
+ content = this.renderTables(state, height, width);
119
86
  } else if (state.type === 'table-detail') {
120
- return this.buildTableDetail(state, height, width);
87
+ content = this.renderTableDetail(state, height, width);
121
88
  } else if (state.type === 'schema-view') {
122
- return this.buildSchemaView(state, height, width);
89
+ content = this.renderSchema(state, height, width);
123
90
  } else if (state.type === 'row-detail') {
124
- return this.buildRowDetail(state, height, width);
91
+ content = this.renderRowDetail(state, height, width);
92
+ } else if (state.type === 'health') {
93
+ content = this.renderHealth(state, height, width);
125
94
  }
126
- return [];
95
+
96
+ // Fill remaining lines with background
97
+ while (content.length < height) {
98
+ content.push(bgBox.render(''));
99
+ }
100
+ return content;
127
101
  }
128
102
 
129
- /**
130
- * Build tables list view
131
- */
132
- private buildTablesList(state: TablesViewState, height: number, width: number): string[] {
103
+ private renderTables(state: TablesViewState, height: number, width: number): string[] {
133
104
  const lines: string[] = [];
134
105
  const { tables, cursor } = state;
106
+ const box = new Box({ width, padding: 1 });
135
107
 
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
- }
108
+ const half = Math.floor(height / 2);
109
+ let start = Math.max(0, cursor - half);
110
+ let end = Math.min(tables.length, start + height);
111
+ if (end - start < height) start = Math.max(0, end - height);
154
112
 
155
- for (let i = startIdx; i < endIdx; i++) {
156
- const table = tables[i];
113
+ for (let i = start; i < end; i++) {
157
114
  const isSelected = i === cursor;
115
+ const table = tables[i];
158
116
 
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
- }
117
+ const name = isSelected ? `${ANSI.bold}${table.name}` : `${table.name}`;
118
+ const count = `${formatNumber(table.row_count)} rows`;
188
119
 
189
- // Fill remaining lines
190
- while (lines.length < height) {
191
- lines.push(' '.repeat(width));
192
- }
120
+ const bg = isSelected ? THEME.selectionBg : THEME.background;
121
+ const fg = isSelected ? THEME.primary : THEME.text;
122
+
123
+ const leftPart = `${ANSI.fg(fg)}${name}${ANSI.reset}`;
124
+ const rightPart = `${ANSI.fg(isSelected ? fg : THEME.textDim)}${count}${ANSI.reset}`;
193
125
 
126
+ const padding = ' '.repeat(Math.max(0, width - 2 - getVisibleWidth(leftPart) - getVisibleWidth(rightPart)));
127
+ const rowContent = `${leftPart}${padding}${rightPart}`;
128
+ lines.push(box.render(rowContent, { background: bg }));
129
+ }
194
130
  return lines;
195
131
  }
196
132
 
197
- /**
198
- * Build table detail view
199
- */
200
- private buildTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
133
+ private renderTableDetail(state: TableDetailViewState, height: number, width: number): string[] {
201
134
  const lines: string[] = [];
202
- const { data, totalRows, dataOffset, dataCursor } = state;
135
+ const { data, dataOffset, dataCursor, bufferOffset } = state;
136
+ const box = new Box({ width, padding: 1 });
203
137
 
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
138
+ if (data.length === 0) return [box.render('No data', { align: 'center' })];
139
+
140
+ const columns = Object.keys(data[0]).slice(0, 8);
141
+
142
+ // Calculate or use cached column widths
143
+ if (!state.cachedColWidths || state.cachedScreenWidth !== width) {
144
+ const numCols = columns.length;
211
145
  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
146
 
349
- // Fill remaining lines
350
- while (lines.length < height) {
351
- lines.push(' '.repeat(width));
147
+ const configs: ColumnConfig[] = columns.map((_, i) => ({
148
+ weight: (state.columnWeights && state.columnWeights[i]) || 1,
149
+ minWidth: minColWidth
150
+ }));
151
+
152
+ state.cachedColWidths = Grid.calculateWidths(width - 2, configs);
153
+ state.cachedScreenWidth = width;
352
154
  }
353
155
 
156
+ const colWidths = state.cachedColWidths!;
157
+
158
+ // Table Header Block
159
+ let headerContent = `${ANSI.fg(THEME.textDim)}${ANSI.bold}`;
160
+ columns.forEach((col, i) => {
161
+ const w = colWidths[i];
162
+ headerContent += pad(col, w - 1).slice(0, w - 1) + ' ';
163
+ });
164
+ lines.push(box.render(headerContent, { background: THEME.surface }));
165
+ lines.push(Transition.draw(width, THEME.surface, THEME.background));
166
+
167
+ // Data Rows
168
+ const relativeOffset = dataOffset - bufferOffset;
169
+ const displayData = data.slice(relativeOffset, relativeOffset + height - 2);
170
+ state.visibleRows = height - 2;
171
+
172
+ displayData.forEach((row, idx) => {
173
+ const isSelected = idx === dataCursor;
174
+ const rowBg = isSelected ? THEME.selectionBg : THEME.background;
175
+ const rowFg = isSelected ? THEME.primary : THEME.text;
176
+
177
+ let rowContent = `${ANSI.fg(rowFg)}`;
178
+ columns.forEach((col, i) => {
179
+ const w = colWidths[i];
180
+ const val = formatValue(row[col], w - 1);
181
+ rowContent += pad(val, w - 1).slice(0, w - 1) + ' ';
182
+ });
183
+ lines.push(box.render(rowContent, { background: rowBg }));
184
+ });
185
+
354
186
  return lines;
355
187
  }
356
188
 
357
- /**
358
- * Build schema view (full screen)
359
- */
360
- private buildSchemaView(state: SchemaViewState, height: number, width: number): string[] {
189
+ private renderSchema(state: SchemaViewState, height: number, width: number): string[] {
361
190
  const lines: string[] = [];
362
- const { schema, cursor, scrollOffset } = state;
191
+ const { schema, cursor } = state;
192
+ const box = new Box({ width, padding: 1 });
363
193
 
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
- }
194
+ const half = Math.floor(height / 2);
195
+ let start = Math.max(0, cursor - half);
196
+ let end = Math.min(schema.length, start + height);
197
+ if (end - start < height) start = Math.max(0, end - height);
382
198
 
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++) {
199
+ for (let i = start; i < end; i++) {
390
200
  const col = schema[i];
391
201
  const isSelected = i === cursor;
202
+ const rowBg = isSelected ? THEME.selectionBg : THEME.background;
392
203
 
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[] = [];
204
+ const name = pad(col.name, 25);
205
+ const type = pad(col.type, 15);
206
+ const attrs = [];
398
207
  if (col.pk) attrs.push('PK');
399
208
  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
209
 
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;
210
+ let rowContent = `${ANSI.fg(isSelected ? THEME.primary : THEME.text)}${name}`;
211
+ rowContent += `${ANSI.fg(THEME.secondary)}${type}`;
212
+ rowContent += `${ANSI.fg(THEME.textDim)}${attrs.join(', ')}`;
213
+ lines.push(box.render(rowContent, { background: rowBg }));
214
+ }
215
+ return lines;
216
+ }
217
+
218
+ private renderRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
219
+ const allLines: string[] = [];
220
+ const { row, schema } = state;
221
+ const innerWidth = width - 2;
222
+ const box = new Box({ width, padding: 1, background: THEME.background });
223
+
224
+ // Calculate max label width for alignment
225
+ let maxLabelWidth = 0;
226
+ schema.forEach(col => {
227
+ maxLabelWidth = Math.max(maxLabelWidth, getVisibleWidth(col.name));
228
+ });
229
+ const labelPad = maxLabelWidth + 2; // +2 for ": "
230
+
231
+ schema.forEach((col) => {
232
+ const label = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${pad(col.name, maxLabelWidth)}${ANSI.reset}: `;
233
+ const val = formatValue(row[col.name], undefined, true);
234
+
235
+ if (labelPad > innerWidth * 0.4) {
236
+ // Label too long, fallback to simpler layout
237
+ const simpleLabel = `${ANSI.bold}${ANSI.fg(THEME.secondary)}${col.name}${ANSI.reset}: `;
238
+ allLines.push(box.render(simpleLabel));
239
+ const wrappedLines = wrapText(val, innerWidth);
240
+ wrappedLines.forEach(line => {
241
+ allLines.push(box.render(`${ANSI.fg(THEME.text)}${line}`));
242
+ });
420
243
  } 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;
244
+ const firstLineMax = innerWidth - labelPad;
245
+ const wrappedLines = wrapText(val, firstLineMax);
246
+
247
+ if (wrappedLines.length === 0 || (wrappedLines.length === 1 && wrappedLines[0] === '')) {
248
+ allLines.push(box.render(`${label}`));
249
+ } else {
250
+ allLines.push(box.render(`${label}${ANSI.fg(THEME.text)}${wrappedLines[0]}`));
251
+
252
+ if (wrappedLines.length > 1) {
253
+ wrappedLines.slice(1).forEach(line => {
254
+ allLines.push(box.render(`${' '.repeat(labelPad)}${ANSI.fg(THEME.text)}${line}`));
255
+ });
256
+ }
257
+ }
427
258
  }
428
-
429
- lines.push(line);
430
- }
259
+ });
431
260
 
432
- // Fill remaining lines
433
- while (lines.length < height) {
434
- lines.push(' '.repeat(width));
435
- }
261
+ state.totalLines = allLines.length;
262
+ state.visibleHeight = height;
436
263
 
437
- return lines;
264
+ // Apply scroll offset
265
+ return allLines.slice(state.scrollOffset, state.scrollOffset + height);
438
266
  }
439
267
 
440
- /**
441
- * Build row detail view
442
- */
443
- private buildRowDetail(state: RowDetailViewState, height: number, width: number): string[] {
268
+ private renderHealth(state: HealthViewState, height: number, width: number): string[] {
444
269
  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
-
270
+ const entries = Object.entries(state.info);
271
+ const box = new Box({ width, padding: 1, background: THEME.background });
272
+
273
+ entries.forEach(([key, val], idx) => {
274
+ if (idx >= height) return;
275
+ const label = pad(key.replace(/_/g, ' '), 25);
276
+ const rowContent = `${ANSI.fg(THEME.secondary)}${label}${ANSI.reset} : ${val}`;
277
+ lines.push(box.render(rowContent));
278
+ });
477
279
  return lines;
478
280
  }
479
281
 
480
- /**
481
- * Build help bar (bottom line)
482
- */
483
282
  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';
283
+ const box = new Box({ width, padding: 1, background: THEME.footerBg });
284
+
285
+ if ((state as any).notice) {
286
+ return box.render(`${ANSI.fg(THEME.textDim)}${(state as any).notice}`);
494
287
  }
495
-
496
- const paddedHelp = pad(help, width);
497
- return `${COLORS.dim}${paddedHelp}${COLORS.reset}`;
288
+
289
+ let helpItems: { key: string; label: string }[] = [];
290
+ switch (state.type) {
291
+ case 'tables':
292
+ helpItems = [
293
+ { key: 'j/k', label: 'select' },
294
+ { key: 'Enter/l', label: 'open' },
295
+ { key: 'i', label: 'info' },
296
+ { key: 'q', label: 'quit' }
297
+ ];
298
+ break;
299
+ case 'table-detail':
300
+ helpItems = [
301
+ { key: 'j/k', label: 'scroll' },
302
+ { key: 'Enter/l', label: 'row' },
303
+ { key: 's', label: 'schema' },
304
+ { key: 'h', label: 'back' },
305
+ { key: 'q', label: 'quit' }
306
+ ];
307
+ break;
308
+ case 'schema-view':
309
+ helpItems = [
310
+ { key: 'j/k', label: 'scroll' },
311
+ { key: 's/h', label: 'back' },
312
+ { key: 'q', label: 'quit' }
313
+ ];
314
+ break;
315
+ case 'row-detail':
316
+ helpItems = [
317
+ { key: 'j/k', label: 'switch' },
318
+ { key: '↑/↓', label: 'scroll' },
319
+ { key: 'h', label: 'back' },
320
+ { key: 'q', label: 'quit' }
321
+ ];
322
+ break;
323
+ default:
324
+ helpItems = [
325
+ { key: 'h', label: 'back' },
326
+ { key: 'q', label: 'quit' }
327
+ ];
328
+ break;
329
+ }
330
+
331
+ const styledHelp = helpItems
332
+ .map(item => `${ANSI.fg(THEME.text)}${item.key} ${ANSI.fg(THEME.textDim)}${item.label}`)
333
+ .join(' ');
334
+
335
+ return box.render(styledHelp, { align: 'right' });
498
336
  }
499
337
  }