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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dbn
1
+ # dbn - DB Navigator
2
2
 
3
3
  A lightweight terminal SQLite browser with an ncdu-style interface.
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbn-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.2",
4
4
  "description": "A lightweight terminal-based database browser",
5
5
  "repository": "https://github.com/amio/dbn-cli",
6
6
  "type": "module",
@@ -9,10 +9,10 @@
9
9
  "dbn": "bin/dbn.ts"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test --experimental-strip-types test/*.test.ts",
13
- "test:watch": "node --test --watch --experimental-strip-types test/*.test.ts",
14
- "test:coverage": "node --test --experimental-test-coverage --experimental-strip-types test/*.test.ts",
15
- "dev": "node bin/dbp.ts"
12
+ "test": "node --test --experimental-strip-types src/**/*.test.ts src/*.test.ts",
13
+ "test:watch": "node --test --watch --experimental-strip-types src/**/*.test.ts src/*.test.ts",
14
+ "test:coverage": "node --test --experimental-test-coverage --experimental-strip-types src/**/*.test.ts src/*.test.ts",
15
+ "dev": "node bin/dbn.ts"
16
16
  },
17
17
  "keywords": [
18
18
  "database",
@@ -0,0 +1,207 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { existsSync, unlinkSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { SQLiteAdapter } from '../adapter/sqlite.ts';
8
+
9
+ const TEST_DB = join(tmpdir(), 'dbn-cli-test.db');
10
+
11
+ /**
12
+ * Set up test database
13
+ */
14
+ function setupTestDB(): void {
15
+ // Remove old test db if exists
16
+ if (existsSync(TEST_DB)) {
17
+ unlinkSync(TEST_DB);
18
+ }
19
+
20
+ // Create test database
21
+ const db = new DatabaseSync(TEST_DB);
22
+
23
+ // Create test tables
24
+ db.exec(`
25
+ CREATE TABLE users (
26
+ id INTEGER PRIMARY KEY,
27
+ name TEXT NOT NULL,
28
+ email TEXT,
29
+ age INTEGER
30
+ );
31
+ `);
32
+
33
+ db.exec(`
34
+ CREATE TABLE posts (
35
+ id INTEGER PRIMARY KEY,
36
+ user_id INTEGER,
37
+ title TEXT NOT NULL,
38
+ content TEXT,
39
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
40
+ );
41
+ `);
42
+
43
+ // Insert test data
44
+ const insertUser = db.prepare('INSERT INTO users (name, email, age) VALUES (?, ?, ?)');
45
+ insertUser.run('Alice', 'alice@example.com', 30);
46
+ insertUser.run('Bob', 'bob@example.com', 25);
47
+ insertUser.run('Charlie', 'charlie@example.com', 35);
48
+
49
+ const insertPost = db.prepare('INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)');
50
+ insertPost.run(1, 'Hello World', 'This is my first post');
51
+ insertPost.run(1, 'Second Post', 'Another post by Alice');
52
+ insertPost.run(2, 'Bobs Post', 'Post by Bob');
53
+
54
+ db.close();
55
+ }
56
+
57
+ /**
58
+ * Clean up test database
59
+ */
60
+ function cleanupTestDB(): void {
61
+ if (existsSync(TEST_DB)) {
62
+ unlinkSync(TEST_DB);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Test suite for SQLiteAdapter
68
+ */
69
+ describe('SQLiteAdapter', () => {
70
+ before(() => {
71
+ setupTestDB();
72
+ });
73
+
74
+ after(() => {
75
+ cleanupTestDB();
76
+ });
77
+
78
+ it('should connect to database', () => {
79
+ const adapter = new SQLiteAdapter();
80
+ adapter.connect(TEST_DB);
81
+ assert.ok((adapter as any).db !== null, 'Database should be connected');
82
+ assert.strictEqual((adapter as any).path, TEST_DB, 'Path should be set correctly');
83
+ adapter.close();
84
+ });
85
+
86
+ it('should throw error when connecting to invalid path', () => {
87
+ const adapter = new SQLiteAdapter();
88
+ // Try to connect to an invalid path (directory that doesn't exist)
89
+ assert.throws(
90
+ () => adapter.connect('/nonexistent/path/to/database.db'),
91
+ /Failed to open database/,
92
+ 'Should throw error for invalid path'
93
+ );
94
+ });
95
+
96
+ it('should get all tables', () => {
97
+ const adapter = new SQLiteAdapter();
98
+ adapter.connect(TEST_DB);
99
+
100
+ const tables = adapter.getTables();
101
+ assert.strictEqual(tables.length, 2, 'Should have 2 tables');
102
+ assert.strictEqual(tables[0].name, 'posts', 'First table should be posts');
103
+ assert.strictEqual(tables[1].name, 'users', 'Second table should be users');
104
+ assert.ok(tables[0].row_count !== undefined, 'Should have row_count property');
105
+
106
+ adapter.close();
107
+ });
108
+
109
+ it('should get table schema', () => {
110
+ const adapter = new SQLiteAdapter();
111
+ adapter.connect(TEST_DB);
112
+
113
+ const schema = adapter.getTableSchema('users');
114
+ assert.strictEqual(schema.length, 4, 'Users table should have 4 columns');
115
+ assert.strictEqual(schema[0].name, 'id', 'First column should be id');
116
+ assert.strictEqual(schema[0].type, 'INTEGER', 'ID should be INTEGER');
117
+ assert.strictEqual(schema[0].pk, 1, 'ID should be primary key');
118
+ assert.strictEqual(schema[1].name, 'name', 'Second column should be name');
119
+ assert.strictEqual(schema[1].type, 'TEXT', 'Name should be TEXT');
120
+ assert.strictEqual(schema[1].notnull, 1, 'Name should be NOT NULL');
121
+
122
+ adapter.close();
123
+ });
124
+
125
+ it('should get table data', () => {
126
+ const adapter = new SQLiteAdapter();
127
+ adapter.connect(TEST_DB);
128
+
129
+ const data = adapter.getTableData('users');
130
+ assert.strictEqual(data.length, 3, 'Should have 3 users');
131
+ assert.strictEqual(data[0].name, 'Alice', 'First user should be Alice');
132
+ assert.strictEqual(data[0].email, 'alice@example.com', 'Alice email should match');
133
+ assert.strictEqual(data[0].age, 30, 'Alice age should be 30');
134
+
135
+ adapter.close();
136
+ });
137
+
138
+ it('should get table data with pagination', () => {
139
+ const adapter = new SQLiteAdapter();
140
+ adapter.connect(TEST_DB);
141
+
142
+ // Get 2 rows starting from offset 1
143
+ const data = adapter.getTableData('users', { limit: 2, offset: 1 });
144
+ assert.strictEqual(data.length, 2, 'Should return 2 users');
145
+ assert.strictEqual(data[0].name, 'Bob', 'First result should be Bob');
146
+ assert.strictEqual(data[1].name, 'Charlie', 'Second result should be Charlie');
147
+
148
+ adapter.close();
149
+ });
150
+
151
+ it('should get table data with custom limit', () => {
152
+ const adapter = new SQLiteAdapter();
153
+ adapter.connect(TEST_DB);
154
+
155
+ const data = adapter.getTableData('users', { limit: 1 });
156
+ assert.strictEqual(data.length, 1, 'Should return 1 user');
157
+ assert.strictEqual(data[0].name, 'Alice', 'Should return Alice');
158
+
159
+ adapter.close();
160
+ });
161
+
162
+ it('should get row count', () => {
163
+ const adapter = new SQLiteAdapter();
164
+ adapter.connect(TEST_DB);
165
+
166
+ const userCount = adapter.getRowCount('users');
167
+ assert.strictEqual(userCount, 3, 'Should have 3 users');
168
+
169
+ const postCount = adapter.getRowCount('posts');
170
+ assert.strictEqual(postCount, 3, 'Should have 3 posts');
171
+
172
+ adapter.close();
173
+ });
174
+
175
+ it('should close database connection', () => {
176
+ const adapter = new SQLiteAdapter();
177
+ adapter.connect(TEST_DB);
178
+ assert.ok((adapter as any).db !== null, 'Database should be connected');
179
+
180
+ adapter.close();
181
+ assert.strictEqual((adapter as any).db, null, 'Database should be null after close');
182
+ });
183
+
184
+ it('should handle closing when not connected', () => {
185
+ const adapter = new SQLiteAdapter();
186
+ assert.doesNotThrow(() => adapter.close(), 'Should not throw when closing unconnected adapter');
187
+ });
188
+
189
+ it('should delete a row', () => {
190
+ const adapter = new SQLiteAdapter();
191
+ adapter.connect(TEST_DB);
192
+
193
+ const initialCount = adapter.getRowCount('users');
194
+ const user = adapter.getTableData('users', { limit: 1, offset: 0 })[0];
195
+ assert.ok(user.id, 'User should have an id');
196
+
197
+ adapter.deleteRow('users', { id: user.id });
198
+
199
+ const newCount = adapter.getRowCount('users');
200
+ assert.strictEqual(newCount, initialCount - 1, 'Row count should decrease by 1');
201
+
202
+ const remainingUsers = adapter.getTableData('users');
203
+ assert.ok(!remainingUsers.find(u => u.id === user.id), 'Deleted user should not be in results');
204
+
205
+ adapter.close();
206
+ });
207
+ });
@@ -1,4 +1,4 @@
1
- import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
1
+ import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
2
2
 
3
3
  /**
4
4
  * Base class for database adapters
@@ -39,6 +39,19 @@ export abstract class DatabaseAdapter {
39
39
  */
40
40
  abstract getRowCount(tableName: string): number;
41
41
 
42
+ /**
43
+ * Delete a single row from a table by its primary key values
44
+ * @param tableName - Name of the table
45
+ * @param keyValues - Primary key column/value mapping
46
+ */
47
+ abstract deleteRow(tableName: string, keyValues: Record<string, any>): void;
48
+
49
+ /**
50
+ * Get core database health info
51
+ * @returns Health information
52
+ */
53
+ abstract getHealthInfo(): HealthInfo;
54
+
42
55
  /**
43
56
  * Close the database connection
44
57
  */
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { DatabaseAdapter } from './base.ts';
3
- import type { TableInfo, ColumnSchema, QueryOptions } from '../types.ts';
3
+ import type { TableInfo, ColumnSchema, QueryOptions, HealthInfo } from '../types.ts';
4
4
 
5
5
  /**
6
6
  * SQLite adapter using Node.js 22+ built-in node:sqlite
@@ -98,6 +98,76 @@ export class SQLiteAdapter extends DatabaseAdapter {
98
98
  return result.count;
99
99
  }
100
100
 
101
+ /**
102
+ * Delete a single row from a table by its primary key values
103
+ * @param tableName - Name of the table
104
+ * @param keyValues - Primary key column/value mapping
105
+ */
106
+ deleteRow(tableName: string, keyValues: Record<string, any>): void {
107
+ if (!this.db) {
108
+ throw new Error('Database not connected');
109
+ }
110
+
111
+ const keys = Object.keys(keyValues);
112
+ if (keys.length === 0) {
113
+ throw new Error(`No primary key values provided for delete on ${tableName}`);
114
+ }
115
+
116
+ const conditions: string[] = [];
117
+ const values: any[] = [];
118
+
119
+ for (const key of keys) {
120
+ const value = keyValues[key];
121
+ if (value === null || value === undefined) {
122
+ conditions.push(`"${key}" IS NULL`);
123
+ } else {
124
+ conditions.push(`"${key}" = ?`);
125
+ values.push(value);
126
+ }
127
+ }
128
+
129
+ const whereClause = conditions.join(' AND ');
130
+ const stmt = this.db.prepare(`DELETE FROM "${tableName}" WHERE ${whereClause}`);
131
+ stmt.run(...values);
132
+ }
133
+
134
+ /**
135
+ * Get core database health info
136
+ */
137
+ getHealthInfo(): HealthInfo {
138
+ if (!this.db) {
139
+ throw new Error('Database not connected');
140
+ }
141
+
142
+ const singleValue = <T = string>(sql: string): T => {
143
+ const stmt = this.db!.prepare(sql);
144
+ const row = stmt.get() as Record<string, any> | undefined;
145
+ if (!row) return '' as T;
146
+ const value = Object.values(row)[0];
147
+ return value as T;
148
+ };
149
+
150
+ return {
151
+ sqlite_version: singleValue<string>('SELECT sqlite_version()'),
152
+ journal_mode: singleValue<string>('PRAGMA journal_mode'),
153
+ synchronous: String(singleValue<number>('PRAGMA synchronous')),
154
+ locking_mode: singleValue<string>('PRAGMA locking_mode'),
155
+ page_size: singleValue<number>('PRAGMA page_size'),
156
+ page_count: singleValue<number>('PRAGMA page_count'),
157
+ freelist_count: singleValue<number>('PRAGMA freelist_count'),
158
+ cache_size: singleValue<number>('PRAGMA cache_size'),
159
+ wal_autocheckpoint: singleValue<number>('PRAGMA wal_autocheckpoint'),
160
+ auto_vacuum: String(singleValue<number>('PRAGMA auto_vacuum')),
161
+ user_version: singleValue<number>('PRAGMA user_version'),
162
+ application_id: singleValue<number>('PRAGMA application_id'),
163
+ encoding: singleValue<string>('PRAGMA encoding'),
164
+ foreign_keys: String(singleValue<number>('PRAGMA foreign_keys')),
165
+ temp_store: String(singleValue<number>('PRAGMA temp_store')),
166
+ mmap_size: singleValue<number>('PRAGMA mmap_size'),
167
+ busy_timeout: singleValue<number>('PRAGMA busy_timeout')
168
+ };
169
+ }
170
+
101
171
  /**
102
172
  * Close the database connection
103
173
  */
package/src/index.ts CHANGED
@@ -86,7 +86,9 @@ export class DBPeek {
86
86
  */
87
87
  private setupInput(): void {
88
88
  // Enable raw mode for key-by-key input
89
- stdin.setRawMode(true);
89
+ if (stdin.isTTY) {
90
+ stdin.setRawMode(true);
91
+ }
90
92
  stdin.resume();
91
93
  stdin.setEncoding('utf8');
92
94
 
@@ -112,6 +114,25 @@ export class DBPeek {
112
114
  return;
113
115
  }
114
116
 
117
+ if (key.name === 'y' && this.navigator.hasPendingDelete()) {
118
+ this.navigator.confirmDelete();
119
+ this.render();
120
+ return;
121
+ }
122
+
123
+ if (
124
+ this.navigator.hasPendingDelete() &&
125
+ (key.name === 'escape' || key.name === 'h' || key.name === 'left')
126
+ ) {
127
+ this.navigator.cancelDelete();
128
+ this.render();
129
+ return;
130
+ }
131
+
132
+ if (this.navigator.hasPendingDelete()) {
133
+ return;
134
+ }
135
+
115
136
  // Handle different keys
116
137
  switch (key.name) {
117
138
  case 'q':
@@ -119,12 +140,28 @@ export class DBPeek {
119
140
  break;
120
141
 
121
142
  case 'j':
143
+ if (this.navigator.getState().type === 'row-detail') {
144
+ (this.navigator as any).nextRecord();
145
+ } else {
146
+ this.navigator.moveDown();
147
+ }
148
+ this.render();
149
+ break;
150
+
151
+ case 'k':
152
+ if (this.navigator.getState().type === 'row-detail') {
153
+ (this.navigator as any).prevRecord();
154
+ } else {
155
+ this.navigator.moveUp();
156
+ }
157
+ this.render();
158
+ break;
159
+
122
160
  case 'down':
123
161
  this.navigator.moveDown();
124
162
  this.render();
125
163
  break;
126
164
 
127
- case 'k':
128
165
  case 'up':
129
166
  this.navigator.moveUp();
130
167
  this.render();
@@ -161,6 +198,15 @@ export class DBPeek {
161
198
  this.render();
162
199
  break;
163
200
 
201
+ case 'backspace': {
202
+ const deleteState = this.navigator.getState();
203
+ if (deleteState.type === 'table-detail' || deleteState.type === 'row-detail') {
204
+ this.navigator.requestDelete();
205
+ this.render();
206
+ }
207
+ break;
208
+ }
209
+
164
210
  case 's':
165
211
  // Toggle schema view in full screen mode
166
212
  const currentState = this.navigator.getState();
@@ -172,6 +218,17 @@ export class DBPeek {
172
218
  this.render();
173
219
  }
174
220
  break;
221
+
222
+ case 'i':
223
+ // Toggle core health overview
224
+ const infoState = this.navigator.getState();
225
+ if (infoState.type === 'health') {
226
+ this.navigator.back();
227
+ } else if (infoState.type === 'tables') {
228
+ this.navigator.viewHealth();
229
+ }
230
+ this.render();
231
+ break;
175
232
  }
176
233
  }
177
234
 
@@ -0,0 +1,101 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { unlinkSync, existsSync } from 'node:fs';
5
+ import { SQLiteAdapter } from './adapter/sqlite.ts';
6
+ import { Navigator } from './ui/navigator.ts';
7
+
8
+ const REPRO_DB = './repro-bug.db';
9
+
10
+ describe('Navigator Bug Reproduction & Edge Cases', () => {
11
+ let adapter: SQLiteAdapter;
12
+ let navigator: Navigator;
13
+
14
+ before(() => {
15
+ const db = new DatabaseSync(REPRO_DB);
16
+ db.exec(`
17
+ CREATE TABLE items (
18
+ id INTEGER PRIMARY KEY,
19
+ name TEXT
20
+ );
21
+ `);
22
+
23
+ const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
24
+ for (let i = 1; i <= 100; i++) {
25
+ insert.run(`Item ${i}`);
26
+ }
27
+ db.close();
28
+
29
+ adapter = new SQLiteAdapter();
30
+ adapter.connect(REPRO_DB);
31
+ navigator = new Navigator(adapter);
32
+ });
33
+
34
+ after(() => {
35
+ adapter.close();
36
+ if (existsSync(REPRO_DB)) {
37
+ unlinkSync(REPRO_DB);
38
+ }
39
+ });
40
+
41
+ it('should enter the correct row when scrolled down', () => {
42
+ navigator.init();
43
+ // Enter table detail for 'items'
44
+ navigator.enter();
45
+
46
+ let state = navigator.getState() as any;
47
+ assert.strictEqual(state.type, 'table-detail');
48
+ assert.strictEqual(state.dataCursor, 0);
49
+ assert.strictEqual(state.dataOffset, 0);
50
+
51
+ // Move down 30 times.
52
+ // Default visibleRows is 20, so this should push dataOffset to 11 (if cursor hits bottom at 19)
53
+ for (let i = 0; i < 30; i++) {
54
+ navigator.moveDown();
55
+ }
56
+
57
+ state = navigator.getState() as any;
58
+ const expectedRowIndex = state.dataOffset + state.dataCursor;
59
+ const expectedName = `Item ${expectedRowIndex + 1}`;
60
+
61
+ navigator.enter(); // Enter row detail
62
+ const rowState = navigator.getState() as any;
63
+
64
+ assert.strictEqual(rowState.type, 'row-detail');
65
+ assert.strictEqual(rowState.rowIndex, expectedRowIndex, `Row index should be ${expectedRowIndex}`);
66
+ assert.strictEqual(rowState.row.name, expectedName, `Row name should be ${expectedName}`);
67
+ });
68
+
69
+ it('should handle jumping to bottom and moving up', () => {
70
+ navigator.init();
71
+ navigator.enter();
72
+
73
+ navigator.jumpToBottom();
74
+ let state = navigator.getState() as any;
75
+ assert.strictEqual(state.dataOffset + state.dataCursor, 99);
76
+
77
+ navigator.moveUp();
78
+ state = navigator.getState() as any;
79
+ assert.strictEqual(state.dataOffset + state.dataCursor, 98);
80
+
81
+ navigator.enter();
82
+ const rowState = navigator.getState() as any;
83
+ assert.strictEqual(rowState.row.name, 'Item 99');
84
+ assert.strictEqual(rowState.rowIndex, 98);
85
+ });
86
+
87
+ it('should handle jumping to top after scrolling', () => {
88
+ navigator.init();
89
+ navigator.enter();
90
+
91
+ for (let i = 0; i < 50; i++) navigator.moveDown();
92
+ navigator.jumpToTop();
93
+
94
+ let state = navigator.getState() as any;
95
+ assert.strictEqual(state.dataOffset, 0);
96
+ assert.strictEqual(state.dataCursor, 0);
97
+
98
+ const selectedRow = (navigator as any).getSelectedRow(state);
99
+ assert.strictEqual(selectedRow.name, 'Item 1');
100
+ });
101
+ });
package/src/types.ts CHANGED
@@ -30,6 +30,39 @@ export interface QueryOptions {
30
30
  offset?: number;
31
31
  }
32
32
 
33
+ /**
34
+ * Core database health overview info
35
+ */
36
+ export interface HealthInfo {
37
+ sqlite_version: string;
38
+ journal_mode: string;
39
+ synchronous: string;
40
+ locking_mode: string;
41
+ page_size: number;
42
+ page_count: number;
43
+ freelist_count: number;
44
+ cache_size: number;
45
+ wal_autocheckpoint: number;
46
+ auto_vacuum: string;
47
+ user_version: number;
48
+ application_id: number;
49
+ encoding: string;
50
+ foreign_keys: string;
51
+ temp_store: string;
52
+ mmap_size: number;
53
+ busy_timeout: number;
54
+ }
55
+
56
+ /**
57
+ * Delete confirmation state for write actions
58
+ */
59
+ export interface DeleteConfirmationState {
60
+ tableName: string;
61
+ rowIndex: number;
62
+ keyValues: Record<string, any>;
63
+ step: 1 | 2;
64
+ }
65
+
33
66
  /**
34
67
  * Base view state properties
35
68
  */
@@ -58,8 +91,14 @@ export interface TableDetailViewState extends BaseViewState {
58
91
  totalRows: number;
59
92
  dataOffset: number;
60
93
  dataCursor: number;
94
+ bufferOffset: number;
61
95
  visibleRows: number;
62
96
  showSchema?: boolean;
97
+ deleteConfirm?: DeleteConfirmationState;
98
+ notice?: string;
99
+ columnWeights?: number[];
100
+ cachedColWidths?: number[];
101
+ cachedScreenWidth?: number;
63
102
  }
64
103
 
65
104
  /**
@@ -81,13 +120,27 @@ export interface RowDetailViewState extends BaseViewState {
81
120
  tableName: string;
82
121
  row: Record<string, any>;
83
122
  rowIndex: number;
123
+ totalRows: number;
84
124
  schema: ColumnSchema[];
125
+ scrollOffset: number;
126
+ totalLines: number;
127
+ visibleHeight: number;
128
+ deleteConfirm?: DeleteConfirmationState;
129
+ notice?: string;
130
+ }
131
+
132
+ /**
133
+ * Health overview view state
134
+ */
135
+ export interface HealthViewState extends BaseViewState {
136
+ type: 'health';
137
+ info: HealthInfo;
85
138
  }
86
139
 
87
140
  /**
88
141
  * Union type for all possible view states
89
142
  */
90
- export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState;
143
+ export type ViewState = TablesViewState | TableDetailViewState | SchemaViewState | RowDetailViewState | HealthViewState;
91
144
 
92
145
  /**
93
146
  * Screen dimensions
@@ -0,0 +1,58 @@
1
+ # GRIT (Grid-TUI)
2
+
3
+ GRIT is a lightweight TUI layout library built for Node.js. It focuses on a modern "OpenCode" aesthetic using solid background blocks and Unicode half-height characters for smooth transitions, rather than traditional box-drawing characters.
4
+
5
+ ## Design Philosophy
6
+
7
+ - **Grid-based**: Everything is a grid or a box.
8
+ - **No Lines**: Uses background colors and Unicode blocks (`▀`, `▄`) for visual separation.
9
+ - **TrueColor**: Native support for 24-bit ANSI colors.
10
+ - **Width-Aware**: Correctly handles CJK characters and emojis.
11
+
12
+ ## API
13
+
14
+ ### `Box`
15
+
16
+ A container that handles background color, padding, and alignment.
17
+
18
+ ```typescript
19
+ import { Box } from './grit/index.ts';
20
+
21
+ const box = new Box({
22
+ width: 80,
23
+ background: '#1A1A1A',
24
+ padding: 1
25
+ });
26
+
27
+ const line = box.render("Hello World", { align: 'center' });
28
+ ```
29
+
30
+ ### `Transition`
31
+
32
+ Creates a half-height vertical transition between two background colors.
33
+
34
+ ```typescript
35
+ import { Transition } from './grit/index.ts';
36
+
37
+ // Top half is #1A1A1A, bottom half is #0D0D0D
38
+ const line = Transition.draw(80, '#1A1A1A', '#0D0D0D');
39
+ ```
40
+
41
+ ### `Grid`
42
+
43
+ Calculates weighted column widths for tabular layouts.
44
+
45
+ ```typescript
46
+ import { Grid } from './grit/index.ts';
47
+
48
+ const columns = Grid.calculateWidths(100, [
49
+ { weight: 1 }, // Flexible
50
+ { weight: 2 }, // Twice as wide
51
+ { minWidth: 10 } // Fixed minimum
52
+ ]);
53
+ ```
54
+
55
+ ## Internal Utilities
56
+
57
+ - `ANSI`: Low-level ANSI escape sequence generators.
58
+ - `wrapAnsiBg`: Helper to persist background colors across ANSI resets.