@deaquinodev/querky 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -0
  3. package/dist/index.js +2310 -0
  4. package/package.json +52 -0
package/dist/index.js ADDED
@@ -0,0 +1,2310 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import { useState as useState5, useEffect as useEffect5 } from "react";
5
+ import { parseArgs } from "util";
6
+ import { render, Box as Box7, Text as Text7 } from "ink";
7
+
8
+ // src/db/client.ts
9
+ import { Client } from "pg";
10
+ import { createConnection } from "mysql2/promise";
11
+ import Database from "better-sqlite3";
12
+
13
+ // src/config/keychain.ts
14
+ var SERVICE = "querky";
15
+ function accountKey(driver, user, host, port) {
16
+ return `${driver}:${user}@${host}:${port}`;
17
+ }
18
+ async function savePassword(driver, user, host, port, password) {
19
+ if (!password) return;
20
+ try {
21
+ const keytar = await import("keytar");
22
+ await keytar.default.setPassword(SERVICE, accountKey(driver, user, host, port), password);
23
+ } catch {
24
+ }
25
+ }
26
+ async function getPassword(driver, user, host, port) {
27
+ try {
28
+ const keytar = await import("keytar");
29
+ return await keytar.default.getPassword(SERVICE, accountKey(driver, user, host, port));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // src/db/client.ts
36
+ var PgDbClient = class {
37
+ constructor(pg) {
38
+ this.pg = pg;
39
+ }
40
+ pg;
41
+ async query(sql) {
42
+ const result = await this.pg.query(sql);
43
+ return {
44
+ fields: result.fields.map((f) => f.name),
45
+ rows: result.rows,
46
+ rowCount: result.rowCount ?? 0
47
+ };
48
+ }
49
+ async end() {
50
+ await this.pg.end();
51
+ }
52
+ };
53
+ var MysqlDbClient = class {
54
+ constructor(conn) {
55
+ this.conn = conn;
56
+ }
57
+ conn;
58
+ async query(sql) {
59
+ const [result, fields] = await this.conn.query(sql);
60
+ if (Array.isArray(result)) {
61
+ return {
62
+ fields: fields ? fields.map((f) => f.name ?? "") : [],
63
+ rows: result,
64
+ rowCount: result.length
65
+ };
66
+ }
67
+ const header = result;
68
+ return { fields: [], rows: [], rowCount: header.affectedRows ?? 0 };
69
+ }
70
+ async end() {
71
+ await this.conn.end();
72
+ }
73
+ };
74
+ var SqliteDbClient = class {
75
+ constructor(db) {
76
+ this.db = db;
77
+ }
78
+ db;
79
+ async query(sql) {
80
+ const stmt = this.db.prepare(sql);
81
+ if (stmt.reader) {
82
+ const rows = stmt.all();
83
+ const fields = stmt.columns().map((c) => c.name);
84
+ return { fields, rows, rowCount: rows.length };
85
+ }
86
+ const info = stmt.run();
87
+ return { fields: [], rows: [], rowCount: info.changes };
88
+ }
89
+ async end() {
90
+ this.db.close();
91
+ }
92
+ };
93
+ async function connectParams(params) {
94
+ try {
95
+ if (params.driver === "sqlite") {
96
+ const db = new Database(params.database);
97
+ return {
98
+ status: "connected",
99
+ client: new SqliteDbClient(db),
100
+ database: params.database,
101
+ host: "local",
102
+ user: "",
103
+ driver: "sqlite",
104
+ params
105
+ };
106
+ }
107
+ if (params.driver === "mysql") {
108
+ const conn = await createConnection({
109
+ host: params.host,
110
+ port: params.port,
111
+ database: params.database,
112
+ user: params.user,
113
+ password: params.password
114
+ });
115
+ const [rows] = await conn.query("SELECT DATABASE() AS db");
116
+ const database = rows[0]?.db ?? params.database;
117
+ void savePassword(params.driver, params.user, params.host, params.port, params.password);
118
+ return {
119
+ status: "connected",
120
+ client: new MysqlDbClient(conn),
121
+ database,
122
+ host: params.host,
123
+ user: params.user,
124
+ driver: "mysql",
125
+ params
126
+ };
127
+ }
128
+ const pg = new Client({
129
+ host: params.host,
130
+ port: params.port,
131
+ database: params.database,
132
+ user: params.user,
133
+ password: params.password
134
+ });
135
+ await pg.connect();
136
+ const res = await pg.query("SELECT current_database()");
137
+ void savePassword(params.driver, params.user, params.host, params.port, params.password);
138
+ return {
139
+ status: "connected",
140
+ client: new PgDbClient(pg),
141
+ database: res.rows[0].current_database,
142
+ host: pg.host,
143
+ user: pg.user ?? "unknown",
144
+ driver: "postgresql",
145
+ params
146
+ };
147
+ } catch (err) {
148
+ return { status: "error", message: err instanceof Error ? err.message : String(err) };
149
+ }
150
+ }
151
+ async function connectDsn(dsn) {
152
+ try {
153
+ if (dsn.startsWith("sqlite:")) {
154
+ const filePath = dsn.replace(/^sqlite:\/\//, "").replace(/^\//, "") || dsn.replace("sqlite:", "");
155
+ return connectParams({ driver: "sqlite", host: "local", port: 0, database: filePath, user: "", password: "" });
156
+ }
157
+ const url = new URL(dsn);
158
+ const driver = url.protocol.startsWith("mysql") ? "mysql" : "postgresql";
159
+ if (driver === "postgresql") {
160
+ const pg = new Client({ connectionString: dsn });
161
+ await pg.connect();
162
+ const res = await pg.query("SELECT current_database()");
163
+ const params = {
164
+ driver: "postgresql",
165
+ host: pg.host,
166
+ port: pg.port ?? 5432,
167
+ database: res.rows[0].current_database,
168
+ user: pg.user ?? "",
169
+ password: decodeURIComponent(url.password)
170
+ };
171
+ return {
172
+ status: "connected",
173
+ client: new PgDbClient(pg),
174
+ database: res.rows[0].current_database,
175
+ host: pg.host,
176
+ user: pg.user ?? "unknown",
177
+ driver: "postgresql",
178
+ params
179
+ };
180
+ }
181
+ return connectParams({
182
+ driver: "mysql",
183
+ host: url.hostname || "localhost",
184
+ port: url.port ? parseInt(url.port) : 3306,
185
+ database: url.pathname.slice(1),
186
+ user: decodeURIComponent(url.username),
187
+ password: decodeURIComponent(url.password)
188
+ });
189
+ } catch (err) {
190
+ return { status: "error", message: err instanceof Error ? err.message : String(err) };
191
+ }
192
+ }
193
+
194
+ // src/ui/components/App.tsx
195
+ import { useState as useState3, useEffect as useEffect4, useRef as useRef2 } from "react";
196
+ import { writeFileSync as writeFileSync5 } from "fs";
197
+ import { homedir as homedir4 } from "os";
198
+ import { join as join6 } from "path";
199
+ import { Box as Box5, Text as Text5, Static, useApp, useInput as useInput2, useStdin as useStdin2 } from "ink";
200
+
201
+ // src/db/query.ts
202
+ async function runQuery(client, sql) {
203
+ try {
204
+ const result = await client.query(sql);
205
+ return { status: "success", result };
206
+ } catch (err) {
207
+ return {
208
+ status: "error",
209
+ message: err instanceof Error ? err.message : String(err)
210
+ };
211
+ }
212
+ }
213
+
214
+ // src/config/aliases.ts
215
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
216
+ import { homedir } from "os";
217
+ import { join } from "path";
218
+ var CONFIG_DIR = join(homedir(), ".config", "querky");
219
+ var ALIASES_FILE = join(CONFIG_DIR, "aliases.json");
220
+ function load() {
221
+ try {
222
+ return JSON.parse(readFileSync(ALIASES_FILE, "utf8"));
223
+ } catch {
224
+ return {};
225
+ }
226
+ }
227
+ function persist(store) {
228
+ mkdirSync(CONFIG_DIR, { recursive: true });
229
+ writeFileSync(ALIASES_FILE, JSON.stringify(store, null, 2));
230
+ }
231
+ function makeScope(driver, user, host, database) {
232
+ return `${driver}:${user}@${host}/${database}`;
233
+ }
234
+ function getAllAliases(scope) {
235
+ return load()[scope] ?? {};
236
+ }
237
+ function saveAlias(scope, name, query) {
238
+ const store = load();
239
+ store[scope] ??= {};
240
+ store[scope][name] = query;
241
+ persist(store);
242
+ }
243
+ function deleteAlias(scope, name) {
244
+ const store = load();
245
+ if (!store[scope]?.[name]) return false;
246
+ delete store[scope][name];
247
+ persist(store);
248
+ return true;
249
+ }
250
+ function expandAlias(template, rawArgs) {
251
+ if (/:([a-zA-Z_]\w*)/.test(template)) {
252
+ const named = {};
253
+ const re = /(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g;
254
+ let m;
255
+ while ((m = re.exec(rawArgs)) !== null) {
256
+ named[m[1]] = m[2].replace(/^"|"$/g, "").replace(/\\"/g, '"');
257
+ }
258
+ return template.replace(/:([a-zA-Z_]\w*)/g, (_, k) => named[k] ?? `:${k}`);
259
+ }
260
+ const args = rawArgs ? rawArgs.match(/("(?:[^"\\]|\\.)*"|\S+)/g) ?? [] : [];
261
+ return template.replace(/\$(\d+)/g, (_, n) => {
262
+ const val = args[Number(n) - 1];
263
+ return val !== void 0 ? val.replace(/^"|"$/g, "") : `$${n}`;
264
+ });
265
+ }
266
+
267
+ // src/ui/completions.ts
268
+ function fuzzyScore(token, candidate) {
269
+ if (token.length === 0) return 0;
270
+ let score = 0;
271
+ let ti = 0;
272
+ let run = 0;
273
+ for (let ci = 0; ci < candidate.length && ti < token.length; ci++) {
274
+ if (candidate[ci] === token[ti]) {
275
+ run++;
276
+ score += run * 2;
277
+ score += candidate.length - ci;
278
+ ti++;
279
+ } else {
280
+ run = 0;
281
+ }
282
+ }
283
+ if (ti < token.length) return -1;
284
+ if (candidate.startsWith(token)) score += 100;
285
+ return score;
286
+ }
287
+ function fuzzyMatchPositions(token, candidate) {
288
+ const positions = [];
289
+ let ti = 0;
290
+ for (let ci = 0; ci < candidate.length && ti < token.length; ci++) {
291
+ if (candidate[ci] === token[ti]) {
292
+ positions.push(ci);
293
+ ti++;
294
+ }
295
+ }
296
+ return ti === token.length ? positions : [];
297
+ }
298
+ var SQL_KEYWORDS = [
299
+ "SELECT",
300
+ "FROM",
301
+ "WHERE",
302
+ "JOIN",
303
+ "LEFT",
304
+ "RIGHT",
305
+ "INNER",
306
+ "OUTER",
307
+ "CROSS",
308
+ "ON",
309
+ "AND",
310
+ "OR",
311
+ "NOT",
312
+ "IN",
313
+ "LIKE",
314
+ "ILIKE",
315
+ "BETWEEN",
316
+ "IS",
317
+ "NULL",
318
+ "INSERT",
319
+ "INTO",
320
+ "VALUES",
321
+ "UPDATE",
322
+ "SET",
323
+ "DELETE",
324
+ "CREATE",
325
+ "TABLE",
326
+ "DROP",
327
+ "ALTER",
328
+ "ADD",
329
+ "COLUMN",
330
+ "ORDER",
331
+ "BY",
332
+ "GROUP",
333
+ "HAVING",
334
+ "LIMIT",
335
+ "OFFSET",
336
+ "DISTINCT",
337
+ "AS",
338
+ "CASE",
339
+ "WHEN",
340
+ "THEN",
341
+ "ELSE",
342
+ "END",
343
+ "UNION",
344
+ "ALL",
345
+ "EXISTS",
346
+ "WITH",
347
+ "RETURNING",
348
+ "COUNT",
349
+ "SUM",
350
+ "AVG",
351
+ "MAX",
352
+ "MIN",
353
+ "COALESCE",
354
+ "NULLIF",
355
+ "CAST",
356
+ "PRIMARY",
357
+ "KEY",
358
+ "FOREIGN",
359
+ "REFERENCES",
360
+ "UNIQUE",
361
+ "DEFAULT",
362
+ "CONSTRAINT",
363
+ "BEGIN",
364
+ "COMMIT",
365
+ "ROLLBACK",
366
+ "EXPLAIN",
367
+ "ANALYZE"
368
+ ];
369
+ var TABLE_CONTEXT = /* @__PURE__ */ new Set(["FROM", "JOIN", "INTO", "UPDATE", "TABLE"]);
370
+ var COLUMN_CONTEXT = /* @__PURE__ */ new Set(["SELECT", "WHERE", "ON", "SET", "HAVING", "BY", "AND", "OR"]);
371
+ function getCurrentToken(value, cursor) {
372
+ const before = value.slice(0, cursor);
373
+ const dotMatch = before.match(/\w+\.(\w*)$/);
374
+ if (dotMatch) return dotMatch[1];
375
+ const match = before.match(/(\w+)$/);
376
+ return match ? match[1] : "";
377
+ }
378
+ function getQualifiedTable(value, cursor) {
379
+ const before = value.slice(0, cursor);
380
+ const match = before.match(/(\w+)\.\w*$/);
381
+ return match ? match[1] : null;
382
+ }
383
+ function getContextKeyword(value, cursor) {
384
+ const before = value.slice(0, cursor).replace(/\w*$/, "");
385
+ const opens = (before.match(/\(/g) ?? []).length;
386
+ const closes = (before.match(/\)/g) ?? []).length;
387
+ if (opens > 0 && closes >= opens) return "";
388
+ const tokens = before.trim().split(/\s+/).filter(Boolean);
389
+ for (let i = tokens.length - 1; i >= 0; i--) {
390
+ const t = tokens[i].toUpperCase().replace(/[^A-Z]/g, "");
391
+ if (TABLE_CONTEXT.has(t) || COLUMN_CONTEXT.has(t)) return t;
392
+ }
393
+ return "";
394
+ }
395
+ function matchCase(keyword, token) {
396
+ const isLower = token === token.toLowerCase();
397
+ return isLower ? keyword.toLowerCase() : keyword.toUpperCase();
398
+ }
399
+ function getSqlCompletions(value, cursor, schema) {
400
+ const token = getCurrentToken(value, cursor);
401
+ const tokenLower = token.toLowerCase();
402
+ const ctx = getContextKeyword(value, cursor).toUpperCase();
403
+ function fuzzyRank(items) {
404
+ return items.map((item) => ({ item, score: fuzzyScore(tokenLower, item.toLowerCase()) })).filter(({ score }) => score >= 0).sort((a, b) => b.score - a.score).map(({ item }) => item);
405
+ }
406
+ const qualTable = getQualifiedTable(value, cursor);
407
+ if (qualTable) {
408
+ const cols = schema.columns[qualTable] ?? schema.columns[qualTable.toLowerCase()] ?? [];
409
+ return token.length === 0 ? cols.slice(0, 8) : fuzzyRank(cols).slice(0, 8);
410
+ }
411
+ if (TABLE_CONTEXT.has(ctx)) {
412
+ return token.length === 0 ? schema.tables.slice(0, 8) : fuzzyRank(schema.tables).slice(0, 8);
413
+ }
414
+ if (token.length < 1) return [];
415
+ if (COLUMN_CONTEXT.has(ctx)) {
416
+ const allCols2 = [...new Set(Object.values(schema.columns).flat())];
417
+ const matchingCols2 = fuzzyRank(allCols2);
418
+ const matchingTables2 = fuzzyRank(schema.tables);
419
+ const matchingKw2 = SQL_KEYWORDS.filter((k) => k.toLowerCase().startsWith(tokenLower)).map((k) => matchCase(k, token));
420
+ return [...matchingTables2, ...matchingCols2, ...matchingKw2].slice(0, 8);
421
+ }
422
+ const matchingKw = SQL_KEYWORDS.filter((k) => k.toLowerCase().startsWith(tokenLower)).map((k) => matchCase(k, token));
423
+ const matchingTables = fuzzyRank(schema.tables);
424
+ const allCols = [...new Set(Object.values(schema.columns).flat())];
425
+ const matchingCols = fuzzyRank(allCols);
426
+ return [...matchingKw, ...matchingTables, ...matchingCols].slice(0, 8);
427
+ }
428
+ function applyCompletion(value, cursor, completion) {
429
+ const before = value.slice(0, cursor);
430
+ const after = value.slice(cursor);
431
+ const dotMatch = before.match(/^([\s\S]*\w+\.)(\w*)$/);
432
+ if (dotMatch) {
433
+ const newBefore = dotMatch[1] + completion;
434
+ return { value: newBefore + after, cursor: newBefore.length };
435
+ }
436
+ const tokenMatch = before.match(/^([\s\S]*?)(\w+)$/);
437
+ if (tokenMatch) {
438
+ const newBefore = tokenMatch[1] + completion;
439
+ return { value: newBefore + after, cursor: newBefore.length };
440
+ }
441
+ return { value: before + completion + after, cursor: cursor + completion.length };
442
+ }
443
+
444
+ // src/commands/router.ts
445
+ var DB_QUERIES = {
446
+ postgresql: {
447
+ databases: `SELECT datname AS database FROM pg_database WHERE datistemplate = false ORDER BY datname`,
448
+ tables: `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`,
449
+ users: `SELECT usename AS user, usesuper AS superuser FROM pg_user ORDER BY usename`
450
+ },
451
+ mysql: {
452
+ databases: `SHOW DATABASES`,
453
+ tables: `SHOW TABLES`,
454
+ users: `SELECT user, host FROM mysql.user ORDER BY user`
455
+ },
456
+ sqlite: {
457
+ databases: `SELECT name FROM pragma_database_list`,
458
+ tables: `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`,
459
+ users: `SELECT 'SQLite has no users' AS message`
460
+ }
461
+ };
462
+ var PSQL_ALIASES = {
463
+ l: "databases",
464
+ d: "d",
465
+ dt: "tables",
466
+ du: "users",
467
+ c: "changeDatabase"
468
+ };
469
+ function describeBasicSql(driver, table) {
470
+ const t = table.replace(/'/g, "''");
471
+ if (driver === "postgresql") {
472
+ return `SELECT column_name, data_type, is_nullable AS nullable, COALESCE(column_default, '') AS "default" FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${t}' ORDER BY ordinal_position`;
473
+ }
474
+ if (driver === "mysql") {
475
+ return `SELECT column_name, column_type AS data_type, is_nullable AS nullable, COALESCE(column_default, '') AS \`default\` FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '${t}' ORDER BY ordinal_position`;
476
+ }
477
+ return `SELECT name AS column_name, type AS data_type, CASE WHEN "notnull" = 0 THEN 'YES' ELSE 'NO' END AS nullable, COALESCE(dflt_value, '') AS "default" FROM pragma_table_info('${t}')`;
478
+ }
479
+ function describeFullSql(driver, table) {
480
+ const t = table.replace(/'/g, "''");
481
+ if (driver === "postgresql") {
482
+ return `SELECT c.column_name, c.data_type, c.is_nullable AS nullable, COALESCE(c.column_default, '') AS "default", COALESCE(string_agg(DISTINCT CASE tc.constraint_type WHEN 'PRIMARY KEY' THEN 'PK' WHEN 'FOREIGN KEY' THEN 'FK' WHEN 'UNIQUE' THEN 'UQ' END, ', ') FILTER (WHERE tc.constraint_type IS NOT NULL), '') AS key FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE c.table_schema = 'public' AND c.table_name = '${t}' GROUP BY c.column_name, c.data_type, c.is_nullable, c.column_default, c.ordinal_position ORDER BY c.ordinal_position`;
483
+ }
484
+ if (driver === "mysql") {
485
+ return `SELECT c.column_name, c.column_type AS data_type, c.is_nullable AS nullable, COALESCE(c.column_default, '') AS \`default\`, COALESCE(GROUP_CONCAT(DISTINCT CASE tc.constraint_type WHEN 'PRIMARY KEY' THEN 'PK' WHEN 'FOREIGN KEY' THEN 'FK' WHEN 'UNIQUE' THEN 'UQ' END SEPARATOR ', '), '') AS \`key\` FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE c.table_schema = DATABASE() AND c.table_name = '${t}' GROUP BY c.column_name, c.column_type, c.is_nullable, c.column_default, c.ordinal_position ORDER BY c.ordinal_position`;
486
+ }
487
+ return `SELECT name AS column_name, type AS data_type, CASE WHEN "notnull" = 0 THEN 'YES' ELSE 'NO' END AS nullable, COALESCE(dflt_value, '') AS "default", CASE WHEN pk > 0 THEN 'PK' ELSE '' END AS key FROM pragma_table_info('${t}')`;
488
+ }
489
+ var COMMANDS = {
490
+ "clear": {
491
+ description: "Clear the scrollback history",
492
+ run: (ctx) => {
493
+ ctx.onClear();
494
+ return { ok: true, message: "", cleared: true };
495
+ }
496
+ },
497
+ "toggle-vim-mode": {
498
+ description: "Toggle vim keybindings on/off",
499
+ run: (ctx) => {
500
+ const next = !ctx.vimEnabled;
501
+ ctx.setVimEnabled(next);
502
+ return { ok: true, message: `Vim mode ${next ? "enabled" : "disabled"}` };
503
+ }
504
+ },
505
+ "d": {
506
+ description: "List tables, or describe a table: \\d [table]",
507
+ run: (ctx) => {
508
+ if (!ctx.args.trim()) {
509
+ ctx.onQuery(DB_QUERIES[ctx.driver].tables);
510
+ } else {
511
+ ctx.onQuery(describeBasicSql(ctx.driver, ctx.args.trim()));
512
+ }
513
+ return { ok: true, message: "" };
514
+ }
515
+ },
516
+ "describe": {
517
+ description: "Describe a table with constraints: /describe <table>",
518
+ run: (ctx) => {
519
+ const table = ctx.args.trim();
520
+ if (!table) return { ok: false, message: "Usage: /describe <table>" };
521
+ ctx.onQuery(describeFullSql(ctx.driver, table));
522
+ return { ok: true, message: "" };
523
+ }
524
+ },
525
+ "databases": {
526
+ description: "List available databases",
527
+ run: (ctx) => {
528
+ ctx.onQuery(DB_QUERIES[ctx.driver].databases);
529
+ return { ok: true, message: "" };
530
+ }
531
+ },
532
+ "tables": {
533
+ description: "List tables in the current database",
534
+ run: (ctx) => {
535
+ ctx.onQuery(DB_QUERIES[ctx.driver].tables);
536
+ return { ok: true, message: "" };
537
+ }
538
+ },
539
+ "users": {
540
+ description: "List database users",
541
+ run: (ctx) => {
542
+ ctx.onQuery(DB_QUERIES[ctx.driver].users);
543
+ return { ok: true, message: "" };
544
+ }
545
+ },
546
+ "changeDatabase": {
547
+ description: "Switch to a different database: \\c dbname",
548
+ run: (ctx) => {
549
+ if (!ctx.args) return { ok: true, message: `Connected to database: ${ctx.currentDatabase}` };
550
+ ctx.onChangeDatabase(ctx.args.trim());
551
+ return { ok: true, message: "" };
552
+ }
553
+ },
554
+ "export": {
555
+ description: "Export last result to a file: /export csv or /export json",
556
+ run: (ctx) => {
557
+ const fmt = ctx.args.trim().toLowerCase();
558
+ if (fmt !== "csv" && fmt !== "json") {
559
+ return { ok: false, message: "Usage: /export csv or /export json" };
560
+ }
561
+ ctx.onExport(fmt);
562
+ return { ok: true, message: "" };
563
+ }
564
+ },
565
+ "explain": {
566
+ description: "Explain a SQL query using AI: /explain SELECT ...",
567
+ run: (ctx) => {
568
+ if (!ctx.args) return { ok: false, message: "Usage: /explain <SQL query>" };
569
+ ctx.onExplain(ctx.args);
570
+ return { ok: true, message: "" };
571
+ }
572
+ },
573
+ "explain-previous": {
574
+ description: "Explain the last executed query using AI",
575
+ run: (ctx) => {
576
+ if (!ctx.lastSqlQuery) {
577
+ return { ok: false, message: "No query to explain \u2014 run a SQL query first." };
578
+ }
579
+ ctx.onExplain(ctx.lastSqlQuery);
580
+ return { ok: true, message: "" };
581
+ }
582
+ },
583
+ "save": {
584
+ description: "Save last query as an alias: /save <name>",
585
+ run: (ctx) => {
586
+ const name = ctx.args.trim();
587
+ if (!name) return { ok: false, message: "Usage: /save <name>" };
588
+ if (!ctx.lastSqlQuery) return { ok: false, message: "No query to save \u2014 run a SQL query first." };
589
+ if (/\s/.test(name)) return { ok: false, message: "Alias name must not contain spaces." };
590
+ ctx.onSaveAlias(name, ctx.lastSqlQuery);
591
+ return { ok: true, message: `Saved /${name}` };
592
+ }
593
+ },
594
+ "alias": {
595
+ description: "Define an alias inline: /alias <name> <SQL>",
596
+ run: (ctx) => {
597
+ const [name, ...rest] = ctx.args.trim().split(/\s+/);
598
+ if (!name || rest.length === 0) return { ok: false, message: "Usage: /alias <name> <SQL>" };
599
+ if (/\s/.test(name)) return { ok: false, message: "Alias name must not contain spaces." };
600
+ ctx.onSaveAlias(name, rest.join(" "));
601
+ return { ok: true, message: `Saved /${name}` };
602
+ }
603
+ },
604
+ "aliases": {
605
+ description: "List all saved aliases for this database",
606
+ run: (ctx) => {
607
+ const entries = Object.entries(ctx.aliases);
608
+ if (entries.length === 0) return { ok: true, message: "No aliases saved. Use /save <name> or /alias <name> <SQL>." };
609
+ const maxLen = Math.max(...entries.map(([n]) => n.length));
610
+ const lines = entries.map(
611
+ ([n, sql], i) => `${i === 0 ? "\xB7" : " \xB7"} /${n.padEnd(maxLen)} \u2192 ${sql}`
612
+ );
613
+ return { ok: true, message: lines.join("\n") };
614
+ }
615
+ },
616
+ "unalias": {
617
+ description: "Remove a saved alias: /unalias <name>",
618
+ run: (ctx) => {
619
+ const name = ctx.args.trim();
620
+ if (!name) return { ok: false, message: "Usage: /unalias <name>" };
621
+ const removed = ctx.onDeleteAlias(name);
622
+ return removed ? { ok: true, message: `Removed /${name}` } : { ok: false, message: `No alias named /${name}` };
623
+ }
624
+ }
625
+ };
626
+ var BUILTIN_COMMAND_LIST = Object.entries(COMMANDS).map(([name, cmd]) => ({
627
+ name,
628
+ description: cmd.description
629
+ }));
630
+ function getCompletions(partial, aliases = {}) {
631
+ if (partial.length === 0) return BUILTIN_COMMAND_LIST.map((c) => c.name);
632
+ const tokenLower = partial.toLowerCase();
633
+ const candidates = [
634
+ ...BUILTIN_COMMAND_LIST.map((c) => c.name),
635
+ ...Object.keys(aliases).filter((n) => !COMMANDS[n])
636
+ ];
637
+ return candidates.map((n) => ({ n, score: fuzzyScore(tokenLower, n.toLowerCase()) })).filter(({ score }) => score >= 0).sort((a, b) => b.score - a.score).map(({ n }) => n);
638
+ }
639
+ function runCommand(input, ctx) {
640
+ const trimmed = input.slice(1).trim();
641
+ const spaceIdx = trimmed.indexOf(" ");
642
+ const rawName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
643
+ const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
644
+ const name = PSQL_ALIASES[rawName ?? ""] ?? rawName ?? "";
645
+ const cmd = COMMANDS[name];
646
+ const prefix = input.startsWith("\\") ? "\\" : "/";
647
+ if (cmd) return cmd.run({ ...ctx, args });
648
+ const template = ctx.aliases[rawName];
649
+ if (template) {
650
+ const expanded = expandAlias(template, args);
651
+ ctx.onQuery(expanded);
652
+ return { ok: true, message: "" };
653
+ }
654
+ return { ok: false, message: `Unknown command: ${prefix}${rawName}` };
655
+ }
656
+
657
+ // src/commands/shell.ts
658
+ import { exec } from "child_process";
659
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync } from "fs";
660
+ import { homedir as homedir2, tmpdir } from "os";
661
+ import { join as join2 } from "path";
662
+ var ANSI_CONTROL_RE = /\x1B\[(?:[0-9;]*[GKHJFST]|[?][0-9;]*[hl])/g;
663
+ var stripControl = (s) => s.replace(ANSI_CONTROL_RE, "").replace(/\r/g, "");
664
+ var MAX_LINES = 200;
665
+ function trimLines(s) {
666
+ const lines = s.split("\n");
667
+ if (lines.length <= MAX_LINES) return s;
668
+ return lines.slice(0, MAX_LINES).join("\n") + `
669
+ \u2026 (truncated, ${lines.length - MAX_LINES} more lines)`;
670
+ }
671
+ function readHistory(countArg) {
672
+ const shell = process.env.SHELL ?? "/bin/sh";
673
+ const histFile = process.env.HISTFILE ?? (shell.endsWith("zsh") ? join2(homedir2(), ".zsh_history") : join2(homedir2(), ".bash_history"));
674
+ let raw;
675
+ try {
676
+ raw = readFileSync2(histFile, "utf8");
677
+ } catch {
678
+ return { stdout: "", stderr: `history: cannot read ${histFile}`, exitCode: 1 };
679
+ }
680
+ const commands = raw.split("\n").map((line) => line.replace(/^: \d+:\d+;/, "")).filter(Boolean);
681
+ const count = countArg ? parseInt(countArg, 10) : NaN;
682
+ const visible = Number.isFinite(count) && count > 0 ? commands.slice(-count) : commands;
683
+ const offset = commands.length - visible.length + 1;
684
+ const output = visible.map((cmd, i) => `${String(offset + i).padStart(5)} ${cmd}`).join("\n");
685
+ return { stdout: output || "(no history)", stderr: "", exitCode: 0 };
686
+ }
687
+ function runShell(command) {
688
+ const trimmed = command.trim();
689
+ const historyMatch = trimmed.match(/^history(?:\s+(\d+))?(\s.*)?$/);
690
+ if (historyMatch) {
691
+ const countStr = historyMatch[1] ?? "";
692
+ const rest = (historyMatch[2] ?? "").trimStart();
693
+ const histResult = readHistory(countStr);
694
+ if (histResult.exitCode !== 0 || !rest) return Promise.resolve(histResult);
695
+ const tmpPath = join2(tmpdir(), `querky-hist-${Date.now()}.txt`);
696
+ writeFileSync2(tmpPath, histResult.stdout);
697
+ const piped = `cat '${tmpPath}' ${rest}`;
698
+ const shell2 = process.env.SHELL ?? "/bin/sh";
699
+ const env2 = { ...process.env, CLICOLOR_FORCE: "1", COLORTERM: "truecolor", FORCE_COLOR: "3" };
700
+ return new Promise((resolve) => {
701
+ exec(piped, { shell: shell2, timeout: 3e4, env: env2 }, (err, stdout, stderr) => {
702
+ try {
703
+ unlinkSync(tmpPath);
704
+ } catch {
705
+ }
706
+ resolve({
707
+ stdout: trimLines(stripControl(stdout).trimEnd()),
708
+ stderr: trimLines(stripControl(stderr).trimEnd()),
709
+ exitCode: err?.code ?? 0
710
+ });
711
+ });
712
+ });
713
+ }
714
+ const shell = process.env.SHELL ?? "/bin/sh";
715
+ const env = {
716
+ ...process.env,
717
+ CLICOLOR_FORCE: "1",
718
+ // BSD + newer GNU coreutils: force color even without TTY
719
+ COLORTERM: "truecolor",
720
+ // hint: terminal supports 24-bit color
721
+ FORCE_COLOR: "3"
722
+ // Node.js / chalk-based CLIs
723
+ };
724
+ return new Promise((resolve) => {
725
+ exec(command, { shell, timeout: 3e4, env }, (err, stdout, stderr) => {
726
+ resolve({
727
+ stdout: trimLines(stripControl(stdout).trimEnd()),
728
+ stderr: trimLines(stripControl(stderr).trimEnd()),
729
+ exitCode: err?.code ?? 0
730
+ });
731
+ });
732
+ });
733
+ }
734
+
735
+ // src/ai/client.ts
736
+ var EXPLAIN_PROMPT = (query) => `You are a SQL expert. Explain this query in plain English. Be concise \u2014 2-4 sentences max. Do not repeat the SQL back.
737
+
738
+ SQL: ${query}`;
739
+ async function* streamExplain(query, baseUrl, model, apiKey) {
740
+ let res;
741
+ const headers = { "Content-Type": "application/json" };
742
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
743
+ try {
744
+ res = await fetch(`${baseUrl}/chat/completions`, {
745
+ method: "POST",
746
+ headers,
747
+ body: JSON.stringify({
748
+ model,
749
+ stream: true,
750
+ messages: [{ role: "user", content: EXPLAIN_PROMPT(query) }]
751
+ })
752
+ });
753
+ } catch {
754
+ throw new Error("Could not reach Ollama \u2014 is it running? Try: ollama serve");
755
+ }
756
+ if (!res.ok) {
757
+ const body = await res.text().catch(() => "");
758
+ throw new Error(`Ollama error ${res.status}: ${body || res.statusText}`);
759
+ }
760
+ const reader = res.body?.getReader();
761
+ if (!reader) throw new Error("No response body from Ollama");
762
+ const decoder = new TextDecoder();
763
+ let buf = "";
764
+ while (true) {
765
+ const { done, value } = await reader.read();
766
+ if (done) break;
767
+ buf += decoder.decode(value, { stream: true });
768
+ const lines = buf.split("\n");
769
+ buf = lines.pop() ?? "";
770
+ for (const line of lines) {
771
+ const trimmed = line.trim();
772
+ if (!trimmed.startsWith("data:")) continue;
773
+ const data = trimmed.slice(5).trim();
774
+ if (data === "[DONE]") return;
775
+ try {
776
+ const chunk = JSON.parse(data);
777
+ const content = chunk.choices[0]?.delta?.content;
778
+ if (content) yield content;
779
+ } catch {
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // src/config/history.ts
786
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "fs";
787
+ import { homedir as homedir3 } from "os";
788
+ import { join as join3 } from "path";
789
+ var DIR = join3(homedir3(), ".config", "querky");
790
+ var FILE = join3(DIR, "history.json");
791
+ var MAX = 1e3;
792
+ function loadHistory() {
793
+ try {
794
+ return JSON.parse(readFileSync3(FILE, "utf8"));
795
+ } catch {
796
+ return [];
797
+ }
798
+ }
799
+ function saveHistory(entries) {
800
+ try {
801
+ mkdirSync2(DIR, { recursive: true });
802
+ writeFileSync3(FILE, JSON.stringify(entries));
803
+ } catch {
804
+ }
805
+ }
806
+ function addToHistory(history, query) {
807
+ const deduped = history.filter((e) => e !== query);
808
+ return [...deduped, query].slice(-MAX);
809
+ }
810
+
811
+ // src/db/schema.ts
812
+ var TABLE_QUERY = {
813
+ postgresql: `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name`,
814
+ mysql: `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name`,
815
+ sqlite: `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
816
+ };
817
+ var COLUMN_QUERY = {
818
+ postgresql: `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position`,
819
+ mysql: `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = DATABASE() ORDER BY table_name, ordinal_position`,
820
+ sqlite: null
821
+ };
822
+ async function fetchSchema(client, driver) {
823
+ try {
824
+ const tableResult = await client.query(TABLE_QUERY[driver]);
825
+ const tables = tableResult.rows.map((r) => String(Object.values(r)[0] ?? "")).filter(Boolean);
826
+ const columns = {};
827
+ if (driver === "sqlite") {
828
+ for (const table of tables) {
829
+ try {
830
+ const info = await client.query(`PRAGMA table_info("${table}")`);
831
+ columns[table] = info.rows.map((r) => String(r["name"] ?? "")).filter(Boolean);
832
+ } catch {
833
+ }
834
+ }
835
+ } else {
836
+ const colQuery = COLUMN_QUERY[driver];
837
+ if (colQuery) {
838
+ const colResult = await client.query(colQuery);
839
+ for (const row of colResult.rows) {
840
+ const table = String(row["table_name"] ?? "");
841
+ const col = String(row["column_name"] ?? "");
842
+ if (table && col) {
843
+ columns[table] ??= [];
844
+ columns[table].push(col);
845
+ }
846
+ }
847
+ }
848
+ }
849
+ return { tables, columns };
850
+ } catch {
851
+ return { tables: [], columns: {} };
852
+ }
853
+ }
854
+
855
+ // src/ui/components/QueryInput.tsx
856
+ import { useEffect as useEffect2 } from "react";
857
+ import { Box, Text } from "ink";
858
+
859
+ // src/ui/hooks/useVimInput.ts
860
+ import { useState, useEffect, useRef } from "react";
861
+ import { spawnSync } from "child_process";
862
+ import { writeFileSync as writeFileSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
863
+ import { tmpdir as tmpdir2 } from "os";
864
+ import { join as join4 } from "path";
865
+ import { useInput, useStdin } from "ink";
866
+ function wordForward(str, pos) {
867
+ let i = pos;
868
+ while (i < str.length && !/\s/.test(str[i])) i++;
869
+ while (i < str.length && /\s/.test(str[i])) i++;
870
+ return i;
871
+ }
872
+ function wordBackward(str, pos) {
873
+ let i = pos;
874
+ while (i > 0 && /\s/.test(str[i - 1])) i--;
875
+ while (i > 0 && !/\s/.test(str[i - 1])) i--;
876
+ return i;
877
+ }
878
+ function deleteWordForward(str, pos) {
879
+ const end = wordForward(str, pos);
880
+ return str.slice(0, pos) + str.slice(end);
881
+ }
882
+ function deleteWordBackward(str, pos) {
883
+ const start = wordBackward(str, pos);
884
+ return { value: str.slice(0, start) + str.slice(pos), cursor: start };
885
+ }
886
+ function useVimInput(onSubmit, isActive, vimEnabled = true, onTab, history = [], getSuggestions, onSuggestionAccept) {
887
+ const { isRawModeSupported } = useStdin();
888
+ const [state, setState] = useState({
889
+ value: "",
890
+ cursor: 0,
891
+ mode: "INSERT",
892
+ pending: "",
893
+ yank: "",
894
+ historyIndex: -1,
895
+ draft: "",
896
+ suggestionIndex: -1,
897
+ pendingSubmit: null,
898
+ pendingEditor: null
899
+ });
900
+ const onSubmitRef = useRef(onSubmit);
901
+ onSubmitRef.current = onSubmit;
902
+ useEffect(() => {
903
+ if (state.pendingSubmit !== null) {
904
+ onSubmitRef.current(state.pendingSubmit);
905
+ setState((s) => ({ ...s, pendingSubmit: null }));
906
+ }
907
+ }, [state.pendingSubmit]);
908
+ useEffect(() => {
909
+ if (state.pendingEditor === null) return;
910
+ const initialContent = state.pendingEditor;
911
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
912
+ const tmpPath = join4(tmpdir2(), `querky-edit-${Date.now()}.sql`);
913
+ const ttyStdin = process.stdin;
914
+ try {
915
+ writeFileSync4(tmpPath, initialContent);
916
+ process.stdout.write("\x1B[?25h");
917
+ ttyStdin.setRawMode?.(false);
918
+ spawnSync(editor, [tmpPath], { stdio: "inherit" });
919
+ process.stdout.write("\x1B[2J\x1B[H\x1B[?25l");
920
+ ttyStdin.setRawMode?.(true);
921
+ const content = readFileSync4(tmpPath, "utf8").trimEnd();
922
+ setState((s) => ({ ...s, pendingEditor: null, value: content, cursor: content.length, mode: "INSERT" }));
923
+ } catch {
924
+ ttyStdin.setRawMode?.(true);
925
+ setState((s) => ({ ...s, pendingEditor: null }));
926
+ } finally {
927
+ try {
928
+ unlinkSync2(tmpPath);
929
+ } catch {
930
+ }
931
+ }
932
+ }, [state.pendingEditor]);
933
+ useInput(
934
+ (input, key) => {
935
+ setState((s) => {
936
+ if (s.mode === "INSERT") {
937
+ if (key.escape) {
938
+ if (s.value.startsWith("!")) {
939
+ return { ...s, value: "", cursor: 0, pending: "" };
940
+ }
941
+ if (!vimEnabled) return s;
942
+ return { ...s, mode: "NORMAL", cursor: Math.max(0, s.cursor - 1), pending: "" };
943
+ }
944
+ if (key.return) {
945
+ const sugs = getSuggestions?.(s.value, s.cursor) ?? [];
946
+ if (sugs.length > 0 && s.suggestionIndex >= 0) {
947
+ const sug = sugs[s.suggestionIndex];
948
+ if (onSuggestionAccept) {
949
+ const r = onSuggestionAccept(s.value, s.cursor, sug);
950
+ return { ...s, value: r.value, cursor: r.cursor, suggestionIndex: -1 };
951
+ }
952
+ const val = `/${sug}`;
953
+ return { ...s, value: val, cursor: val.length, suggestionIndex: -1 };
954
+ }
955
+ const trimmed = s.value.trim();
956
+ return { value: "", cursor: 0, mode: "INSERT", pending: "", yank: s.yank, historyIndex: -1, draft: "", suggestionIndex: -1, pendingSubmit: trimmed || null };
957
+ }
958
+ if (key.upArrow) {
959
+ const sugs = getSuggestions?.(s.value, s.cursor) ?? [];
960
+ if (sugs.length > 0) {
961
+ const next2 = s.suggestionIndex <= 0 ? sugs.length - 1 : s.suggestionIndex - 1;
962
+ return { ...s, suggestionIndex: next2 };
963
+ }
964
+ if (history.length === 0) return s;
965
+ const draft = s.historyIndex === -1 ? s.value : s.draft;
966
+ const next = s.historyIndex === -1 ? history.length - 1 : Math.max(0, s.historyIndex - 1);
967
+ const val = history[next] ?? "";
968
+ return { ...s, value: val, cursor: val.length, historyIndex: next, draft };
969
+ }
970
+ if (key.downArrow) {
971
+ const sugs = getSuggestions?.(s.value, s.cursor) ?? [];
972
+ if (sugs.length > 0) {
973
+ const next2 = s.suggestionIndex >= sugs.length - 1 ? 0 : s.suggestionIndex + 1;
974
+ return { ...s, suggestionIndex: next2 };
975
+ }
976
+ if (s.historyIndex === -1) return s;
977
+ if (s.historyIndex === history.length - 1) {
978
+ return { ...s, value: s.draft, cursor: s.draft.length, historyIndex: -1, draft: "" };
979
+ }
980
+ const next = s.historyIndex + 1;
981
+ const val = history[next] ?? "";
982
+ return { ...s, value: val, cursor: val.length, historyIndex: next };
983
+ }
984
+ if (key.backspace || key.delete) {
985
+ if (s.cursor === 0) return s;
986
+ return {
987
+ ...s,
988
+ value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
989
+ cursor: s.cursor - 1,
990
+ historyIndex: -1
991
+ };
992
+ }
993
+ if (key.leftArrow) return { ...s, cursor: Math.max(0, s.cursor - 1) };
994
+ if (key.rightArrow) return { ...s, cursor: Math.min(s.value.length, s.cursor + 1) };
995
+ if (key.tab) {
996
+ const sugs = getSuggestions?.(s.value, s.cursor) ?? [];
997
+ if (sugs.length > 0) {
998
+ const next = s.suggestionIndex < sugs.length - 1 ? s.suggestionIndex + 1 : 0;
999
+ return { ...s, suggestionIndex: next };
1000
+ }
1001
+ const completed = onTab?.(s.value);
1002
+ if (completed != null) return { ...s, value: completed, cursor: completed.length };
1003
+ return s;
1004
+ }
1005
+ if (key.ctrl && input === "e") {
1006
+ return { ...s, pendingEditor: s.value };
1007
+ }
1008
+ if (!key.ctrl && !key.meta && input) {
1009
+ const chars = input === "!" && s.value === "" ? "! " : input;
1010
+ return {
1011
+ ...s,
1012
+ value: s.value.slice(0, s.cursor) + chars + s.value.slice(s.cursor),
1013
+ cursor: s.cursor + chars.length,
1014
+ historyIndex: -1,
1015
+ suggestionIndex: -1
1016
+ };
1017
+ }
1018
+ return s;
1019
+ }
1020
+ if (key.return) {
1021
+ const trimmed = s.value.trim();
1022
+ return { value: "", cursor: 0, mode: "INSERT", pending: "", yank: s.yank, historyIndex: -1, draft: "", suggestionIndex: -1, pendingSubmit: trimmed || null };
1023
+ }
1024
+ if (s.pending === "d") {
1025
+ if (input === "d") {
1026
+ return { ...s, value: "", cursor: 0, pending: "" };
1027
+ }
1028
+ if (input === "w") {
1029
+ const val = deleteWordForward(s.value, s.cursor);
1030
+ return { ...s, value: val, cursor: Math.min(s.cursor, Math.max(0, val.length - 1)), pending: "" };
1031
+ }
1032
+ if (input === "b") {
1033
+ const { value, cursor } = deleteWordBackward(s.value, s.cursor);
1034
+ return { ...s, value, cursor: Math.min(cursor, Math.max(0, value.length - 1)), pending: "" };
1035
+ }
1036
+ return { ...s, pending: "" };
1037
+ }
1038
+ if (s.pending === "c") {
1039
+ if (input === "c") {
1040
+ return { ...s, value: "", cursor: 0, mode: "INSERT", pending: "" };
1041
+ }
1042
+ if (input === "w") {
1043
+ const val = deleteWordForward(s.value, s.cursor);
1044
+ return { ...s, value: val, cursor: s.cursor, mode: "INSERT", pending: "" };
1045
+ }
1046
+ if (input === "b") {
1047
+ const { value, cursor } = deleteWordBackward(s.value, s.cursor);
1048
+ return { ...s, value, cursor, mode: "INSERT", pending: "" };
1049
+ }
1050
+ return { ...s, pending: "" };
1051
+ }
1052
+ if (s.pending === "y") {
1053
+ if (input === "y") {
1054
+ return { ...s, yank: s.value, pending: "" };
1055
+ }
1056
+ return { ...s, pending: "" };
1057
+ }
1058
+ const maxCursor = Math.max(0, s.value.length - 1);
1059
+ switch (input) {
1060
+ case "i":
1061
+ return { ...s, mode: "INSERT", pending: "" };
1062
+ case "a":
1063
+ return { ...s, mode: "INSERT", cursor: Math.min(s.value.length, s.cursor + 1), pending: "" };
1064
+ case "A":
1065
+ return { ...s, mode: "INSERT", cursor: s.value.length, pending: "" };
1066
+ case "I":
1067
+ return { ...s, mode: "INSERT", cursor: 0, pending: "" };
1068
+ case "h":
1069
+ return { ...s, cursor: Math.max(0, s.cursor - 1) };
1070
+ case "l":
1071
+ return { ...s, cursor: Math.min(maxCursor, s.cursor + 1) };
1072
+ case "w":
1073
+ return { ...s, cursor: Math.min(maxCursor, wordForward(s.value, s.cursor)) };
1074
+ case "b":
1075
+ return { ...s, cursor: wordBackward(s.value, s.cursor) };
1076
+ case "e":
1077
+ return { ...s, pendingEditor: s.value };
1078
+ case "0":
1079
+ return { ...s, cursor: 0 };
1080
+ case "$":
1081
+ return { ...s, cursor: maxCursor };
1082
+ case "x": {
1083
+ if (s.cursor >= s.value.length) return s;
1084
+ const val = s.value.slice(0, s.cursor) + s.value.slice(s.cursor + 1);
1085
+ return { ...s, value: val, cursor: Math.min(s.cursor, Math.max(0, val.length - 1)) };
1086
+ }
1087
+ case "s": {
1088
+ if (s.cursor >= s.value.length) return { ...s, mode: "INSERT" };
1089
+ const val = s.value.slice(0, s.cursor) + s.value.slice(s.cursor + 1);
1090
+ return { ...s, value: val, cursor: s.cursor, mode: "INSERT" };
1091
+ }
1092
+ case "S": {
1093
+ return { ...s, value: "", cursor: 0, mode: "INSERT" };
1094
+ }
1095
+ case "C": {
1096
+ const val = s.value.slice(0, s.cursor);
1097
+ return { ...s, value: val, cursor: Math.min(s.cursor, Math.max(0, val.length - 1)), mode: "INSERT" };
1098
+ }
1099
+ case "D": {
1100
+ const val = s.value.slice(0, s.cursor);
1101
+ return { ...s, value: val, cursor: Math.min(s.cursor, Math.max(0, val.length - 1)) };
1102
+ }
1103
+ case "p": {
1104
+ if (!s.yank) return s;
1105
+ const insertAt = Math.min(s.cursor + 1, s.value.length);
1106
+ const val = s.value.slice(0, insertAt) + s.yank + s.value.slice(insertAt);
1107
+ return { ...s, value: val, cursor: Math.max(0, insertAt + s.yank.length - 1) };
1108
+ }
1109
+ case "d":
1110
+ return { ...s, pending: "d" };
1111
+ case "c":
1112
+ return { ...s, pending: "c" };
1113
+ case "y":
1114
+ return { ...s, pending: "y" };
1115
+ default:
1116
+ return s;
1117
+ }
1118
+ });
1119
+ },
1120
+ { isActive: isActive && (isRawModeSupported ?? false) }
1121
+ );
1122
+ return { value: state.value, cursor: state.cursor, mode: vimEnabled ? state.mode : "INSERT", suggestionIndex: state.suggestionIndex };
1123
+ }
1124
+
1125
+ // src/ui/sqlHighlight.ts
1126
+ var SQL_KW = /* @__PURE__ */ new Set([
1127
+ "SELECT",
1128
+ "FROM",
1129
+ "WHERE",
1130
+ "AND",
1131
+ "OR",
1132
+ "NOT",
1133
+ "IN",
1134
+ "IS",
1135
+ "NULL",
1136
+ "LIKE",
1137
+ "BETWEEN",
1138
+ "EXISTS",
1139
+ "CASE",
1140
+ "WHEN",
1141
+ "THEN",
1142
+ "ELSE",
1143
+ "END",
1144
+ "JOIN",
1145
+ "INNER",
1146
+ "LEFT",
1147
+ "RIGHT",
1148
+ "FULL",
1149
+ "OUTER",
1150
+ "CROSS",
1151
+ "ON",
1152
+ "AS",
1153
+ "DISTINCT",
1154
+ "ORDER",
1155
+ "BY",
1156
+ "GROUP",
1157
+ "HAVING",
1158
+ "LIMIT",
1159
+ "OFFSET",
1160
+ "INSERT",
1161
+ "INTO",
1162
+ "VALUES",
1163
+ "UPDATE",
1164
+ "SET",
1165
+ "DELETE",
1166
+ "CREATE",
1167
+ "TABLE",
1168
+ "DROP",
1169
+ "ALTER",
1170
+ "ADD",
1171
+ "COLUMN",
1172
+ "PRIMARY",
1173
+ "KEY",
1174
+ "FOREIGN",
1175
+ "REFERENCES",
1176
+ "INDEX",
1177
+ "UNIQUE",
1178
+ "DEFAULT",
1179
+ "CONSTRAINT",
1180
+ "WITH",
1181
+ "UNION",
1182
+ "ALL",
1183
+ "EXCEPT",
1184
+ "INTERSECT",
1185
+ "RETURNING",
1186
+ "TRUE",
1187
+ "FALSE",
1188
+ "ASC",
1189
+ "DESC",
1190
+ "NULLS",
1191
+ "FIRST",
1192
+ "LAST",
1193
+ "ILIKE",
1194
+ "SIMILAR",
1195
+ "TO",
1196
+ "CAST",
1197
+ "COALESCE",
1198
+ "NULLIF",
1199
+ "COUNT",
1200
+ "SUM",
1201
+ "AVG",
1202
+ "MIN",
1203
+ "MAX",
1204
+ "GREATEST",
1205
+ "LEAST",
1206
+ "OVER",
1207
+ "PARTITION",
1208
+ "WINDOW",
1209
+ "FILTER",
1210
+ "LATERAL",
1211
+ "USING",
1212
+ "NATURAL",
1213
+ "EXPLAIN",
1214
+ "ANALYZE",
1215
+ "VERBOSE"
1216
+ ]);
1217
+ function tokenizeSql(sql) {
1218
+ const tokens = [];
1219
+ let i = 0;
1220
+ while (i < sql.length) {
1221
+ if (sql[i] === "-" && sql[i + 1] === "-") {
1222
+ const nl = sql.indexOf("\n", i);
1223
+ const end = nl === -1 ? sql.length : nl;
1224
+ tokens.push({ type: "comment", text: sql.slice(i, end) });
1225
+ i = end;
1226
+ continue;
1227
+ }
1228
+ if (sql[i] === "/" && sql[i + 1] === "*") {
1229
+ const close = sql.indexOf("*/", i + 2);
1230
+ const end = close === -1 ? sql.length : close + 2;
1231
+ tokens.push({ type: "comment", text: sql.slice(i, end) });
1232
+ i = end;
1233
+ continue;
1234
+ }
1235
+ if (sql[i] === "'") {
1236
+ let j = i + 1;
1237
+ while (j < sql.length) {
1238
+ if (sql[j] === "'" && sql[j + 1] === "'") {
1239
+ j += 2;
1240
+ continue;
1241
+ }
1242
+ if (sql[j] === "'") {
1243
+ j++;
1244
+ break;
1245
+ }
1246
+ j++;
1247
+ }
1248
+ tokens.push({ type: "string", text: sql.slice(i, j) });
1249
+ i = j;
1250
+ continue;
1251
+ }
1252
+ if (/[0-9]/.test(sql[i])) {
1253
+ let j = i + 1;
1254
+ while (j < sql.length && /[0-9.]/.test(sql[j])) j++;
1255
+ tokens.push({ type: "number", text: sql.slice(i, j) });
1256
+ i = j;
1257
+ continue;
1258
+ }
1259
+ if (/[a-zA-Z_]/.test(sql[i])) {
1260
+ let j = i + 1;
1261
+ while (j < sql.length && /[a-zA-Z0-9_]/.test(sql[j])) j++;
1262
+ const word = sql.slice(i, j);
1263
+ tokens.push({ type: SQL_KW.has(word.toUpperCase()) ? "keyword" : "plain", text: word });
1264
+ i = j;
1265
+ continue;
1266
+ }
1267
+ tokens.push({ type: "plain", text: sql[i] });
1268
+ i++;
1269
+ }
1270
+ return tokens;
1271
+ }
1272
+
1273
+ // src/ui/theme.ts
1274
+ var theme = {
1275
+ accent: "#818cf8",
1276
+ insertMode: "#22d3ee",
1277
+ normalMode: "#ff7722",
1278
+ shellMode: "#4ade80",
1279
+ logo: "#ff9f43",
1280
+ success: "green",
1281
+ error: "red",
1282
+ warning: "yellow",
1283
+ muted: "white"
1284
+ };
1285
+
1286
+ // src/ui/components/QueryInput.tsx
1287
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1288
+ function FuzzyHighlight({ text, token, selected }) {
1289
+ if (!token) return /* @__PURE__ */ jsx(Text, { dimColor: !selected, children: text });
1290
+ const matched = new Set(fuzzyMatchPositions(token.toLowerCase(), text.toLowerCase()));
1291
+ const segs = [];
1292
+ for (let i = 0; i < text.length; i++) {
1293
+ const hit = matched.has(i);
1294
+ const last = segs[segs.length - 1];
1295
+ if (last && last.hit === hit) last.chars += text[i];
1296
+ else segs.push({ chars: text[i], hit });
1297
+ }
1298
+ return /* @__PURE__ */ jsx(Fragment, { children: segs.map(
1299
+ (seg, i) => seg.hit ? /* @__PURE__ */ jsx(Text, { color: ACCENT, bold: true, children: seg.chars }, i) : /* @__PURE__ */ jsx(Text, { dimColor: !selected, children: seg.chars }, i)
1300
+ ) });
1301
+ }
1302
+ var BG = "#1e1b4b";
1303
+ var ACCENT = "#818cf8";
1304
+ var PLACEHOLDER = "#6366f1";
1305
+ var KW_COLOR = "#a5b4fc";
1306
+ var STR_COLOR = "#fb923c";
1307
+ var NUM_COLOR = "#86efac";
1308
+ var CMT_COLOR = "#6b7280";
1309
+ function sqlTokenColor(type) {
1310
+ if (type === "keyword") return KW_COLOR;
1311
+ if (type === "string") return STR_COLOR;
1312
+ if (type === "number") return NUM_COLOR;
1313
+ if (type === "comment") return CMT_COLOR;
1314
+ return void 0;
1315
+ }
1316
+ function SqlLine({ text }) {
1317
+ const tokens = tokenizeSql(text);
1318
+ return /* @__PURE__ */ jsx(Fragment, { children: tokens.map((tok, i) => /* @__PURE__ */ jsx(Text, { color: sqlTokenColor(tok.type), children: tok.text }, i)) });
1319
+ }
1320
+ function SqlHighlightedInput({ text, cursorAt, mode, pad: pad2 }) {
1321
+ const tokens = tokenizeSql(text);
1322
+ const parts = [];
1323
+ let pos = 0;
1324
+ let cursorDone = false;
1325
+ for (let i = 0; i < tokens.length; i++) {
1326
+ const tok = tokens[i];
1327
+ const tokEnd = pos + tok.text.length;
1328
+ const color = sqlTokenColor(tok.type);
1329
+ if (!cursorDone && cursorAt >= pos && cursorAt < tokEnd) {
1330
+ const rel = cursorAt - pos;
1331
+ const before = tok.text.slice(0, rel);
1332
+ const ch = tok.text[rel];
1333
+ const after = tok.text.slice(rel + 1);
1334
+ if (before) parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, color, children: before }, `${i}b`));
1335
+ if (mode === "INSERT") {
1336
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: ACCENT, bold: true, children: "\u258C" }, `${i}c`));
1337
+ } else {
1338
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: ACCENT, color: BG, bold: true, children: ch || " " }, `${i}c`));
1339
+ }
1340
+ if (after) parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, color, children: after }, `${i}a`));
1341
+ cursorDone = true;
1342
+ } else {
1343
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, color, children: tok.text }, i));
1344
+ }
1345
+ pos = tokEnd;
1346
+ }
1347
+ if (!cursorDone) {
1348
+ if (mode === "INSERT") {
1349
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: ACCENT, bold: true, children: "\u258C" }, "c"));
1350
+ } else {
1351
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: ACCENT, color: BG, bold: true, children: " " }, "c"));
1352
+ }
1353
+ }
1354
+ parts.push(/* @__PURE__ */ jsx(Text, { backgroundColor: BG, children: pad2 }, "pad"));
1355
+ return /* @__PURE__ */ jsx(Fragment, { children: parts });
1356
+ }
1357
+ var HINTS = {
1358
+ INSERT: [
1359
+ ["Esc", "NORMAL MODE"],
1360
+ ["Enter", "RUN QUERY"],
1361
+ ["Ctrl+E", "EDITOR"],
1362
+ ["!cmd", "SHELL"],
1363
+ ["Ctrl+C", "EXIT"]
1364
+ ],
1365
+ NORMAL: [
1366
+ ["i/a/A", "INSERT"],
1367
+ ["h/l", "MOVE"],
1368
+ ["w/b", "WORD"],
1369
+ ["0/$", "LINE ENDS"],
1370
+ ["dd/cc/S", "CLEAR"],
1371
+ ["x/s/dw/cw", "DELETE"],
1372
+ ["D/C", "TO END"],
1373
+ ["e", "EDITOR"]
1374
+ ],
1375
+ PLAIN: [
1376
+ ["Enter", "RUN QUERY"],
1377
+ ["!cmd", "SHELL"],
1378
+ ["Ctrl+C", "EXIT"],
1379
+ ["Ctrl+Z", "BACKGROUND"],
1380
+ ["/toggle-vim-mode", "ENABLE VIM"]
1381
+ ]
1382
+ };
1383
+ var BUILTIN_DESC_MAP = Object.fromEntries(BUILTIN_COMMAND_LIST.map((c) => [c.name, c.description]));
1384
+ function QueryInput({ onSubmit, isLoading, onModeChange, onShellModeChange, vimEnabled = true, history = [], aliases = {}, schema }) {
1385
+ function handleTab(current) {
1386
+ if (!current.startsWith("/")) return null;
1387
+ const partial2 = current.slice(1);
1388
+ const matches = getCompletions(partial2, aliases);
1389
+ if (matches.length === 0) return null;
1390
+ if (matches.length === 1) return `/${matches[0]}`;
1391
+ let prefix = matches[0];
1392
+ for (const m of matches.slice(1)) {
1393
+ while (!m.startsWith(prefix)) prefix = prefix.slice(0, -1);
1394
+ if (!prefix) return null;
1395
+ }
1396
+ return `/${prefix}`;
1397
+ }
1398
+ function handleSuggestionAccept(v, cur, suggestion) {
1399
+ if (v.startsWith("/")) {
1400
+ const val = `/${suggestion}`;
1401
+ return { value: val, cursor: val.length };
1402
+ }
1403
+ return applyCompletion(v, cur, suggestion);
1404
+ }
1405
+ const { value, cursor: cursorPos, mode, suggestionIndex } = useVimInput(
1406
+ onSubmit,
1407
+ !isLoading,
1408
+ vimEnabled,
1409
+ handleTab,
1410
+ history,
1411
+ (v, cur) => v.startsWith("/") ? getCompletions(v.slice(1), aliases) : schema ? getSqlCompletions(v, cur, schema) : [],
1412
+ handleSuggestionAccept
1413
+ );
1414
+ const isShellMode = value.startsWith("!");
1415
+ const isCommand = !isShellMode && value.startsWith("/");
1416
+ const partial = isCommand ? value.slice(1) : "";
1417
+ const suggestions = isCommand ? getCompletions(partial, aliases) : !isShellMode && schema ? getSqlCompletions(value, cursorPos, schema) : [];
1418
+ const sqlToken = isCommand ? "" : getCurrentToken(value, cursorPos);
1419
+ const descMap = {
1420
+ ...BUILTIN_DESC_MAP,
1421
+ ...Object.fromEntries(
1422
+ Object.entries(aliases).map(([name, sql]) => [
1423
+ name,
1424
+ sql.length > 55 ? sql.slice(0, 55) + "\u2026" : sql
1425
+ ])
1426
+ )
1427
+ };
1428
+ useEffect2(() => {
1429
+ onModeChange?.(mode);
1430
+ }, [mode, onModeChange]);
1431
+ useEffect2(() => {
1432
+ onShellModeChange?.(isShellMode);
1433
+ }, [isShellMode, onShellModeChange]);
1434
+ const termWidth = process.stdout.columns ?? 80;
1435
+ const innerWidth = termWidth - 2;
1436
+ const emptyLine = " ".repeat(innerWidth);
1437
+ const isEmpty = value === "";
1438
+ const isMultiLine = !isEmpty && !isShellMode && !isCommand && value.includes("\n");
1439
+ const dOff = isShellMode ? 2 : 0;
1440
+ const placeholder = "Type a SQL query\u2026";
1441
+ const lineWidth = innerWidth - 4;
1442
+ const displayValue = value.slice(dOff);
1443
+ const displayCursor = Math.max(0, cursorPos - dOff);
1444
+ const scrollStart = displayCursor > lineWidth - 1 ? displayCursor - lineWidth + 1 : 0;
1445
+ const visibleBefore = displayValue.slice(scrollStart, displayCursor);
1446
+ const cursorChar = cursorPos < dOff ? " " : displayValue[displayCursor] ?? " ";
1447
+ const visibleAfter = displayValue.slice(displayCursor + 1, scrollStart + lineWidth);
1448
+ const textPad = " ".repeat(Math.max(0, lineWidth - visibleBefore.length - 1 - visibleAfter.length));
1449
+ const visibleSql = value.slice(scrollStart, scrollStart + lineWidth);
1450
+ const relativeSqlCursor = cursorPos - scrollStart;
1451
+ const sqlCursorAtEnd = relativeSqlCursor >= visibleSql.length;
1452
+ const sqlPad = " ".repeat(Math.max(0, lineWidth - visibleSql.length - (sqlCursorAtEnd ? 1 : 0)));
1453
+ const emptyPad = " ".repeat(Math.max(0, lineWidth - placeholder.length - 1));
1454
+ const SEP = " | ";
1455
+ const allHints = vimEnabled ? HINTS[mode] : HINTS.PLAIN;
1456
+ const totalHintsWidth = allHints.reduce(
1457
+ (w, [key, desc], i) => w + (i > 0 ? SEP.length : 0) + desc.length + 2 + key.length,
1458
+ 0
1459
+ );
1460
+ const hintsInline = totalHintsWidth <= termWidth;
1461
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
1462
+ isMultiLine ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
1463
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: ACCENT, paddingX: 1, children: value.split("\n").map((line, i, arr) => {
1464
+ const numW = String(arr.length).length;
1465
+ const isLast = i === arr.length - 1;
1466
+ return /* @__PURE__ */ jsxs(Box, { children: [
1467
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1468
+ String(i + 1).padStart(numW),
1469
+ " \u2502 "
1470
+ ] }),
1471
+ /* @__PURE__ */ jsx(SqlLine, { text: line }),
1472
+ isLast && (mode === "INSERT" ? /* @__PURE__ */ jsx(Text, { color: ACCENT, bold: true, children: "\u258C" }) : /* @__PURE__ */ jsx(Text, { backgroundColor: ACCENT, color: BG, bold: true, children: " " }))
1473
+ ] }, i);
1474
+ }) }),
1475
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Enter: run \xB7 Ctrl+E / e: re-edit" }) })
1476
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1477
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, children: emptyLine }),
1478
+ /* @__PURE__ */ jsxs(Box, { children: [
1479
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: isShellMode ? theme.shellMode : ACCENT, bold: true, children: isShellMode ? " $ " : " > " }),
1480
+ isEmpty ? /* @__PURE__ */ jsxs(Fragment, { children: [
1481
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: PLACEHOLDER, children: placeholder }),
1482
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: ACCENT, bold: true, children: "\u258C" }),
1483
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, children: emptyPad })
1484
+ ] }) : isShellMode || isCommand ? /* @__PURE__ */ jsxs(Fragment, { children: [
1485
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, children: visibleBefore }),
1486
+ mode === "INSERT" ? /* @__PURE__ */ jsx(Text, { backgroundColor: BG, color: ACCENT, bold: true, children: "\u258C" }) : /* @__PURE__ */ jsx(Text, { backgroundColor: ACCENT, color: BG, bold: true, children: cursorChar }),
1487
+ /* @__PURE__ */ jsxs(Text, { backgroundColor: BG, children: [
1488
+ visibleAfter,
1489
+ textPad
1490
+ ] })
1491
+ ] }) : /* @__PURE__ */ jsx(SqlHighlightedInput, { text: visibleSql, cursorAt: relativeSqlCursor, mode, pad: sqlPad })
1492
+ ] }),
1493
+ /* @__PURE__ */ jsx(Text, { backgroundColor: BG, children: emptyLine })
1494
+ ] }),
1495
+ suggestions.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [
1496
+ suggestions.map((name, i) => {
1497
+ const selected = i === suggestionIndex;
1498
+ if (isCommand) {
1499
+ return /* @__PURE__ */ jsxs(Box, { children: [
1500
+ /* @__PURE__ */ jsxs(Text, { children: [
1501
+ /* @__PURE__ */ jsx(Text, { dimColor: !selected, color: selected ? ACCENT : void 0, children: "/" }),
1502
+ /* @__PURE__ */ jsx(FuzzyHighlight, { text: name, token: partial, selected })
1503
+ ] }),
1504
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1505
+ " \u2014 ",
1506
+ descMap[name]
1507
+ ] })
1508
+ ] }, name);
1509
+ }
1510
+ return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(FuzzyHighlight, { text: name, token: sqlToken, selected }) }, name);
1511
+ }),
1512
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Tab/\u2191\u2193 navigate Enter select" })
1513
+ ] }),
1514
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: hintsInline ? "row" : "column", children: allHints.map(([key, desc], i) => /* @__PURE__ */ jsxs(Text, { children: [
1515
+ hintsInline && i > 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: " | " }),
1516
+ /* @__PURE__ */ jsx(Text, { color: ACCENT, bold: true, children: desc }),
1517
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `: ${key}` })
1518
+ ] }, desc)) })
1519
+ ] });
1520
+ }
1521
+
1522
+ // src/ui/components/QueryResult.tsx
1523
+ import { useState as useState2, useEffect as useEffect3 } from "react";
1524
+ import { Box as Box3, Text as Text3 } from "ink";
1525
+
1526
+ // src/ui/components/Table.tsx
1527
+ import { Box as Box2, Text as Text2 } from "ink";
1528
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1529
+ var COL_PAD = 1;
1530
+ var INDIGO = "#818cf8";
1531
+ var BORDER = "white";
1532
+ var NULL_COLOR = "#6366f1";
1533
+ var NULL_MARKER = "\u2205";
1534
+ function isNull(val) {
1535
+ return val === null || val === void 0;
1536
+ }
1537
+ function cellValue(val) {
1538
+ if (isNull(val)) return NULL_MARKER;
1539
+ if (val instanceof Date) return val.toISOString();
1540
+ if (typeof val === "object") return JSON.stringify(val);
1541
+ return String(val);
1542
+ }
1543
+ function colWidths(columns, rows) {
1544
+ return columns.map((col) => {
1545
+ const dataMax = rows.reduce(
1546
+ (max, row) => Math.max(max, cellValue(row[col]).length),
1547
+ 0
1548
+ );
1549
+ return Math.max(col.length, dataMax);
1550
+ });
1551
+ }
1552
+ function pad(str, width) {
1553
+ return str.slice(0, width).padEnd(width);
1554
+ }
1555
+ function hline(widths, left, mid, right) {
1556
+ const segments = widths.map((w) => "\u2500".repeat(w + COL_PAD * 2));
1557
+ return left + segments.join(mid) + right;
1558
+ }
1559
+ function ExpandedTable({ columns, rows }) {
1560
+ const keyWidth = columns.reduce((max, col) => Math.max(max, col.length), 0);
1561
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: rows.map((row, i) => /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: i < rows.length - 1 ? 1 : 0, children: [
1562
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: `\u2500[ Record ${i + 1} ]${"\u2500".repeat(Math.max(0, keyWidth + 14 - String(i + 1).length))}` }),
1563
+ columns.map((col) => /* @__PURE__ */ jsxs2(Box2, { children: [
1564
+ /* @__PURE__ */ jsx2(Text2, { color: INDIGO, bold: true, children: col.padEnd(keyWidth) }),
1565
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: " \u2502 " }),
1566
+ isNull(row[col]) ? /* @__PURE__ */ jsx2(Text2, { color: NULL_COLOR, dimColor: true, children: NULL_MARKER }) : /* @__PURE__ */ jsx2(Text2, { children: cellValue(row[col]) })
1567
+ ] }, col))
1568
+ ] }, i)) });
1569
+ }
1570
+ function Table({ columns, rows, expanded = false }) {
1571
+ if (expanded) return /* @__PURE__ */ jsx2(ExpandedTable, { columns, rows });
1572
+ const widths = colWidths(columns, rows);
1573
+ const topLine = hline(widths, "\u256D", "\u252C", "\u256E");
1574
+ const midLine = hline(widths, "\u251C", "\u253C", "\u2524");
1575
+ const botLine = hline(widths, "\u2570", "\u2534", "\u256F");
1576
+ function renderHeaderRow(cols) {
1577
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
1578
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: "\u2502" }),
1579
+ cols.map((v, i) => /* @__PURE__ */ jsxs2(Box2, { children: [
1580
+ /* @__PURE__ */ jsx2(Text2, { color: INDIGO, bold: true, children: " ".repeat(COL_PAD) + pad(v, widths[i]) + " ".repeat(COL_PAD) }),
1581
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: "\u2502" })
1582
+ ] }, i))
1583
+ ] });
1584
+ }
1585
+ function renderDataRow(row) {
1586
+ return /* @__PURE__ */ jsxs2(Box2, { children: [
1587
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: "\u2502" }),
1588
+ columns.map((col, i) => /* @__PURE__ */ jsxs2(Box2, { children: [
1589
+ isNull(row[col]) ? /* @__PURE__ */ jsx2(Text2, { color: NULL_COLOR, dimColor: true, children: " ".repeat(COL_PAD) + pad(NULL_MARKER, widths[i]) + " ".repeat(COL_PAD) }) : /* @__PURE__ */ jsx2(Text2, { children: " ".repeat(COL_PAD) + pad(cellValue(row[col]), widths[i]) + " ".repeat(COL_PAD) }),
1590
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: "\u2502" })
1591
+ ] }, i))
1592
+ ] });
1593
+ }
1594
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1595
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: topLine }),
1596
+ renderHeaderRow(columns),
1597
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: midLine }),
1598
+ rows.map((row, i) => /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1599
+ renderDataRow(row),
1600
+ i < rows.length - 1 && /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: midLine })
1601
+ ] }, i)),
1602
+ /* @__PURE__ */ jsx2(Text2, { color: BORDER, children: botLine })
1603
+ ] });
1604
+ }
1605
+
1606
+ // src/ui/components/QueryResult.tsx
1607
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1608
+ var ERROR_BG = "#3b0f0f";
1609
+ var ERROR_FG = "#ff4444";
1610
+ function ErrorBox({ message }) {
1611
+ const cols = Math.max(0, (process.stdout.columns ?? 80) - 2);
1612
+ const blank = " ".repeat(cols);
1613
+ const label = ` \u2717 ${message}`;
1614
+ const padded = label.length < cols ? label + " ".repeat(cols - label.length) : label;
1615
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, children: [
1616
+ /* @__PURE__ */ jsx3(Text3, { backgroundColor: ERROR_BG, children: blank }),
1617
+ /* @__PURE__ */ jsx3(Text3, { backgroundColor: ERROR_BG, color: ERROR_FG, bold: true, children: padded }),
1618
+ /* @__PURE__ */ jsx3(Text3, { backgroundColor: ERROR_BG, children: blank })
1619
+ ] });
1620
+ }
1621
+ function formatDuration(ms) {
1622
+ if (ms < 1e3) return `${ms}ms`;
1623
+ return `${(ms / 1e3).toFixed(2)}s`;
1624
+ }
1625
+ function useTerminalWidth() {
1626
+ const [width, setWidth] = useState2(process.stdout.columns ?? 80);
1627
+ useEffect3(() => {
1628
+ const handler = () => setWidth(process.stdout.columns ?? 80);
1629
+ process.stdout.on("resize", handler);
1630
+ return () => {
1631
+ process.stdout.off("resize", handler);
1632
+ };
1633
+ }, []);
1634
+ return width;
1635
+ }
1636
+ function QueryResult({ state, elapsed, page, pageSize }) {
1637
+ const termWidth = useTerminalWidth();
1638
+ if (state.status === "idle" || state.status === "running") return null;
1639
+ const timing = elapsed !== null ? ` \xB7 ${formatDuration(elapsed)}` : "";
1640
+ if (state.status === "error") {
1641
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1642
+ /* @__PURE__ */ jsx3(ErrorBox, { message: state.message }),
1643
+ elapsed !== null && /* @__PURE__ */ jsx3(Text3, { color: theme.accent, dimColor: true, children: formatDuration(elapsed) })
1644
+ ] });
1645
+ }
1646
+ const { result } = state;
1647
+ const columns = result.fields;
1648
+ if (result.rows.length === 0) {
1649
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs3(Text3, { color: theme.accent, children: [
1650
+ "No rows returned (",
1651
+ result.rowCount ?? 0,
1652
+ " affected)",
1653
+ timing
1654
+ ] }) });
1655
+ }
1656
+ const totalRows = result.rows.length;
1657
+ const totalPages = Math.ceil(totalRows / pageSize);
1658
+ const currentPage = Math.min(page, totalPages - 1);
1659
+ const start = currentPage * pageSize;
1660
+ const displayRows = result.rows.slice(start, start + pageSize);
1661
+ const isPaged = totalRows > pageSize;
1662
+ const widths = colWidths(columns, displayRows);
1663
+ const tableWidth = 1 + widths.reduce((sum, w) => sum + w + 3, 0);
1664
+ const expanded = tableWidth > termWidth;
1665
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
1666
+ /* @__PURE__ */ jsx3(Table, { columns, rows: displayRows, expanded }),
1667
+ /* @__PURE__ */ jsxs3(Box3, { marginTop: 1, flexDirection: "column", children: [
1668
+ isPaged && /* @__PURE__ */ jsxs3(Text3, { color: theme.warning, children: [
1669
+ "Page ",
1670
+ currentPage + 1,
1671
+ " of ",
1672
+ totalPages,
1673
+ " \xB7 showing rows ",
1674
+ start + 1,
1675
+ "\u2013",
1676
+ start + displayRows.length,
1677
+ " of ",
1678
+ totalRows,
1679
+ currentPage > 0 ? " /prev" : "",
1680
+ currentPage < totalPages - 1 ? " /next" : ""
1681
+ ] }),
1682
+ /* @__PURE__ */ jsxs3(Text3, { color: theme.accent, children: [
1683
+ displayRows.length,
1684
+ " row",
1685
+ displayRows.length !== 1 ? "s" : "",
1686
+ timing,
1687
+ expanded ? " [expanded]" : ""
1688
+ ] })
1689
+ ] })
1690
+ ] });
1691
+ }
1692
+
1693
+ // src/ui/components/Banner.tsx
1694
+ import { Box as Box4, Text as Text4 } from "ink";
1695
+ import { readFileSync as readFileSync5 } from "fs";
1696
+ import { fileURLToPath } from "url";
1697
+ import { dirname, join as join5 } from "path";
1698
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1699
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1700
+ var pkg = { version: "unknown" };
1701
+ try {
1702
+ pkg = JSON.parse(readFileSync5(join5(__dirname, "../../../package.json"), "utf8"));
1703
+ } catch {
1704
+ }
1705
+ var LOGO = [
1706
+ " \u2597\u2584\u2584\u2584\u2584\u2584\u2596",
1707
+ "\u2590\u2591 \u25CF \u25CF \u2591\u258C",
1708
+ "\u2590\u2591 \u25E1\u25E1\u25E1 \u2591\u258C",
1709
+ "\u2590\u2591 \u2597\u259F\u258C",
1710
+ " \u2580\u2580\u2580\u2580\u259D\u2580\u2584"
1711
+ ];
1712
+ var GRADIENT = [
1713
+ "#ff00dd",
1714
+ "#ff33bb",
1715
+ "#ff6699",
1716
+ "#ff9966",
1717
+ "#ff7722"
1718
+ ];
1719
+ function Banner({ connectionState }) {
1720
+ const isConnected = connectionState.status === "connected";
1721
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "row", marginTop: 2, marginBottom: 2, children: [
1722
+ /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginRight: 2, children: LOGO.map((line, i) => /* @__PURE__ */ jsx4(Text4, { color: GRADIENT[i], bold: true, children: line }, i)) }),
1723
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
1724
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: theme.accent, children: [
1725
+ "Querky ",
1726
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1727
+ "v",
1728
+ pkg.version
1729
+ ] })
1730
+ ] }),
1731
+ /* @__PURE__ */ jsx4(Text4, { children: " " }),
1732
+ isConnected ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
1733
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1734
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Connected as " }),
1735
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: theme.insertMode, children: connectionState.user })
1736
+ ] }),
1737
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1738
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Database " }),
1739
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: theme.insertMode, children: connectionState.database })
1740
+ ] }),
1741
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1742
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Host " }),
1743
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: theme.insertMode, children: connectionState.host })
1744
+ ] })
1745
+ ] }) : /* @__PURE__ */ jsxs4(Text4, { color: theme.error, children: [
1746
+ "\u2717 ",
1747
+ connectionState.message
1748
+ ] })
1749
+ ] })
1750
+ ] });
1751
+ }
1752
+
1753
+ // src/ui/components/App.tsx
1754
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1755
+ var PAGE_SIZE = 50;
1756
+ var PLACEHOLDER2 = "#a5b4fc";
1757
+ function activePageSize() {
1758
+ return Math.max(3, (process.stdout.rows ?? 24) - 21);
1759
+ }
1760
+ function limitLines(s, n) {
1761
+ const lines = s.split("\n");
1762
+ if (lines.length <= n) return s;
1763
+ return lines.slice(0, n).join("\n") + `
1764
+ \u2026 +${lines.length - n} more lines (scroll up after next submit)`;
1765
+ }
1766
+ function EntryView({ entry }) {
1767
+ const showAi = entry.aiResponse !== "" || entry.aiError !== null;
1768
+ const isShell = entry.query.startsWith("!");
1769
+ const isCommand = !isShell && (entry.query.startsWith("/") || entry.query.startsWith("\\"));
1770
+ const label = isShell ? "Shell:" : isCommand ? "Command:" : "Query:";
1771
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginBottom: 1, paddingX: 1, children: [
1772
+ /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [
1773
+ /* @__PURE__ */ jsxs5(Text5, { color: theme.accent, bold: true, children: [
1774
+ label,
1775
+ " "
1776
+ ] }),
1777
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: entry.query })
1778
+ ] }),
1779
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: isShell ? entry.shellOutput !== null && /* @__PURE__ */ jsx5(Text5, { children: entry.shellOutput || "(no output)" }) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
1780
+ entry.commandMessage && (entry.commandMessage.ok ? /* @__PURE__ */ jsxs5(Text5, { color: theme.accent, children: [
1781
+ "\u2713 ",
1782
+ entry.commandMessage.text
1783
+ ] }) : /* @__PURE__ */ jsx5(ErrorBox, { message: entry.commandMessage.text })),
1784
+ showAi ? /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1785
+ /* @__PURE__ */ jsx5(Text5, { color: theme.accent, bold: true, children: "Explanation:" }),
1786
+ /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", marginTop: 1, children: entry.aiError ? /* @__PURE__ */ jsx5(ErrorBox, { message: entry.aiError }) : /* @__PURE__ */ jsx5(Text5, { color: PLACEHOLDER2, children: entry.aiResponse }) })
1787
+ ] }) : !entry.commandMessage && /* @__PURE__ */ jsx5(QueryResult, { state: entry.queryState, elapsed: entry.elapsed, page: entry.page, pageSize: PAGE_SIZE })
1788
+ ] }) })
1789
+ ] });
1790
+ }
1791
+ function App({ connectionState, aiUrl: aiUrl2, aiModel: aiModel2, aiKey: aiKey2, onChangeDatabase }) {
1792
+ const { exit } = useApp();
1793
+ const { isRawModeSupported } = useStdin2();
1794
+ const [queryState, setQueryState] = useState3({ status: "idle" });
1795
+ const [lastQuery, setLastQuery] = useState3("");
1796
+ const [lastSqlQuery, setLastSqlQuery] = useState3("");
1797
+ const [lastResult, setLastResult] = useState3(null);
1798
+ const [page, setPage] = useState3(0);
1799
+ const [vimMode, setVimMode] = useState3("INSERT");
1800
+ const [inputIsShell, setInputIsShell] = useState3(false);
1801
+ const [elapsed, setElapsed] = useState3(null);
1802
+ const [vimEnabled, setVimEnabled] = useState3(true);
1803
+ const [commandMessage, setCommandMessage] = useState3(null);
1804
+ const [history, setHistory] = useState3(() => loadHistory());
1805
+ const [schema, setSchema] = useState3(null);
1806
+ const [aiResponse, setAiResponse] = useState3("");
1807
+ const [isStreaming, setIsStreaming] = useState3(false);
1808
+ const [aiError, setAiError] = useState3(null);
1809
+ const [shellOutput, setShellOutput] = useState3(null);
1810
+ const [isShellRunning, setIsShellRunning] = useState3(false);
1811
+ const [completedEntries, setCompletedEntries] = useState3([]);
1812
+ const entryIdRef = useRef2(0);
1813
+ const aliasScope = connectionState.status === "connected" ? makeScope(connectionState.driver, connectionState.user, connectionState.host, connectionState.database) : "";
1814
+ const [aliases, setAliases] = useState3(
1815
+ () => aliasScope ? getAllAliases(aliasScope) : {}
1816
+ );
1817
+ function handleSaveAlias(name, query) {
1818
+ saveAlias(aliasScope, name, query);
1819
+ setAliases(getAllAliases(aliasScope));
1820
+ }
1821
+ function handleDeleteAlias(name) {
1822
+ const removed = deleteAlias(aliasScope, name);
1823
+ if (removed) setAliases(getAllAliases(aliasScope));
1824
+ return removed;
1825
+ }
1826
+ useEffect4(() => {
1827
+ if (connectionState.status !== "connected") {
1828
+ setSchema(null);
1829
+ return;
1830
+ }
1831
+ void fetchSchema(connectionState.client, connectionState.driver).then(setSchema);
1832
+ }, [connectionState]);
1833
+ useEffect4(() => {
1834
+ if (!isRawModeSupported) return;
1835
+ const ttyStdin = process.stdin;
1836
+ const handleCont = () => ttyStdin.setRawMode?.(true);
1837
+ process.on("SIGCONT", handleCont);
1838
+ return () => {
1839
+ process.off("SIGCONT", handleCont);
1840
+ };
1841
+ }, [isRawModeSupported]);
1842
+ useEffect4(() => {
1843
+ if (!isRawModeSupported) return;
1844
+ process.stdout.write("\x1B[?25l");
1845
+ return () => {
1846
+ process.stdout.write("\x1B[?25h");
1847
+ };
1848
+ }, [isRawModeSupported]);
1849
+ useInput2(
1850
+ (input, key) => {
1851
+ if (key.ctrl && input === "c") exit();
1852
+ if (key.ctrl && input === "z") {
1853
+ const ttyStdin = process.stdin;
1854
+ ttyStdin.setRawMode?.(false);
1855
+ process.kill(process.pid, "SIGTSTP");
1856
+ }
1857
+ },
1858
+ { isActive: isRawModeSupported }
1859
+ );
1860
+ async function handleExplain(query) {
1861
+ setAiResponse("");
1862
+ setAiError(null);
1863
+ setIsStreaming(true);
1864
+ setCommandMessage(null);
1865
+ setQueryState({ status: "idle" });
1866
+ try {
1867
+ for await (const chunk of streamExplain(query, aiUrl2, aiModel2, aiKey2 || void 0)) {
1868
+ setAiResponse((prev) => prev + chunk);
1869
+ }
1870
+ } catch (err) {
1871
+ setAiError(err instanceof Error ? err.message : String(err));
1872
+ } finally {
1873
+ setIsStreaming(false);
1874
+ }
1875
+ }
1876
+ async function handleQuery(sql) {
1877
+ if (connectionState.status !== "connected") return;
1878
+ setAiResponse("");
1879
+ setAiError(null);
1880
+ setCommandMessage(null);
1881
+ setLastQuery(sql);
1882
+ setLastSqlQuery(sql);
1883
+ setElapsed(null);
1884
+ setPage(0);
1885
+ setQueryState({ status: "running" });
1886
+ const updated = addToHistory(history, sql);
1887
+ setHistory(updated);
1888
+ saveHistory(updated);
1889
+ const start = Date.now();
1890
+ const result = await runQuery(connectionState.client, sql);
1891
+ setElapsed(Date.now() - start);
1892
+ setQueryState(result);
1893
+ if (result.status === "success") setLastResult(result.result);
1894
+ }
1895
+ function handleExport(format) {
1896
+ if (!lastResult || lastResult.rows.length === 0) {
1897
+ setCommandMessage({ ok: false, text: "No results to export \u2014 run a query first." });
1898
+ return;
1899
+ }
1900
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1901
+ const filename = join6(homedir4(), `q-export-${ts}.${format}`);
1902
+ try {
1903
+ if (format === "json") {
1904
+ writeFileSync5(filename, JSON.stringify(lastResult.rows, null, 2));
1905
+ } else {
1906
+ const header = lastResult.fields.join(",");
1907
+ const rows = lastResult.rows.map(
1908
+ (row) => lastResult.fields.map((f) => {
1909
+ const v = row[f];
1910
+ if (v === null || v === void 0) return "";
1911
+ const s = String(v);
1912
+ return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s;
1913
+ }).join(",")
1914
+ );
1915
+ writeFileSync5(filename, [header, ...rows].join("\n"));
1916
+ }
1917
+ setCommandMessage({ ok: true, text: `Exported to ${filename}` });
1918
+ } catch (err) {
1919
+ setCommandMessage({ ok: false, text: err instanceof Error ? err.message : String(err) });
1920
+ }
1921
+ }
1922
+ function snapshotActiveEntry() {
1923
+ if (lastQuery === "") return;
1924
+ setCompletedEntries((prev) => [
1925
+ ...prev,
1926
+ {
1927
+ id: entryIdRef.current++,
1928
+ query: lastQuery,
1929
+ commandMessage,
1930
+ queryState,
1931
+ elapsed,
1932
+ page,
1933
+ aiResponse,
1934
+ aiError,
1935
+ shellOutput
1936
+ }
1937
+ ]);
1938
+ }
1939
+ async function handleShell(cmd) {
1940
+ setShellOutput(null);
1941
+ setIsShellRunning(true);
1942
+ setCommandMessage(null);
1943
+ setAiResponse("");
1944
+ setAiError(null);
1945
+ setQueryState({ status: "idle" });
1946
+ const result = await runShell(cmd);
1947
+ const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
1948
+ setShellOutput(combined || "(no output)");
1949
+ setIsShellRunning(false);
1950
+ }
1951
+ async function handleSubmit(sql) {
1952
+ if (sql === "/next") {
1953
+ setPage((p) => p + 1);
1954
+ return;
1955
+ }
1956
+ if (sql === "/prev") {
1957
+ setPage((p) => Math.max(0, p - 1));
1958
+ return;
1959
+ }
1960
+ snapshotActiveEntry();
1961
+ if (sql.startsWith("!")) {
1962
+ const cmd = sql.slice(1).trim();
1963
+ setLastQuery(sql);
1964
+ setShellOutput(null);
1965
+ void handleShell(cmd);
1966
+ return;
1967
+ }
1968
+ if (sql.startsWith("/") || sql.startsWith("\\")) {
1969
+ const result = runCommand(sql, {
1970
+ vimEnabled,
1971
+ setVimEnabled,
1972
+ lastSqlQuery,
1973
+ currentDatabase: connectionState.status === "connected" ? connectionState.database : "",
1974
+ driver: connectionState.status === "connected" ? connectionState.driver : "postgresql",
1975
+ onExplain: (query) => {
1976
+ void handleExplain(query);
1977
+ },
1978
+ onQuery: (query) => {
1979
+ void handleQuery(query);
1980
+ },
1981
+ onChangeDatabase: (db) => {
1982
+ onChangeDatabase?.(db);
1983
+ },
1984
+ onExport: handleExport,
1985
+ onClear: () => {
1986
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
1987
+ setCompletedEntries([]);
1988
+ setLastQuery("");
1989
+ setCommandMessage(null);
1990
+ setQueryState({ status: "idle" });
1991
+ setShellOutput(null);
1992
+ setAiResponse("");
1993
+ setAiError(null);
1994
+ setElapsed(null);
1995
+ setPage(0);
1996
+ },
1997
+ aliases,
1998
+ onSaveAlias: handleSaveAlias,
1999
+ onDeleteAlias: handleDeleteAlias
2000
+ });
2001
+ if (result.cleared) return;
2002
+ setLastQuery(sql);
2003
+ setAiResponse("");
2004
+ setAiError(null);
2005
+ setElapsed(null);
2006
+ setPage(0);
2007
+ setQueryState({ status: "idle" });
2008
+ if (result.message) setCommandMessage({ ok: result.ok, text: result.message });
2009
+ else setCommandMessage(null);
2010
+ return;
2011
+ }
2012
+ void handleQuery(sql);
2013
+ }
2014
+ const isLoading = queryState.status === "running" || isStreaming || isShellRunning;
2015
+ const isConnected = connectionState.status === "connected";
2016
+ const showAi = aiResponse !== "" || isStreaming || aiError !== null;
2017
+ const isShellEntry = lastQuery.startsWith("!");
2018
+ const isCommand = !isShellEntry && (lastQuery.startsWith("/") || lastQuery.startsWith("\\"));
2019
+ const activeLabel = isShellEntry ? "Shell:" : isCommand ? "Command:" : "Query:";
2020
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
2021
+ /* @__PURE__ */ jsx5(Static, { items: completedEntries, children: (entry) => /* @__PURE__ */ jsx5(EntryView, { entry }, entry.id) }),
2022
+ /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 1, children: [
2023
+ lastQuery === "" && /* @__PURE__ */ jsx5(Banner, { connectionState }),
2024
+ lastQuery !== "" && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginBottom: 2, children: [
2025
+ /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: theme.accent, paddingX: 1, children: [
2026
+ /* @__PURE__ */ jsxs5(Text5, { color: theme.accent, bold: true, children: [
2027
+ activeLabel,
2028
+ " "
2029
+ ] }),
2030
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: lastQuery })
2031
+ ] }),
2032
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: isShellEntry ? isShellRunning ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "running\u2026" }) : /* @__PURE__ */ jsx5(Text5, { children: limitLines(shellOutput ?? "", activePageSize()) }) : /* @__PURE__ */ jsxs5(Fragment3, { children: [
2033
+ commandMessage && (commandMessage.ok ? /* @__PURE__ */ jsxs5(Text5, { color: theme.accent, children: [
2034
+ "\u2713 ",
2035
+ commandMessage.text
2036
+ ] }) : /* @__PURE__ */ jsx5(ErrorBox, { message: commandMessage.text })),
2037
+ showAi ? /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
2038
+ /* @__PURE__ */ jsx5(Text5, { color: theme.accent, bold: true, children: "Explanation:" }),
2039
+ /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", marginTop: 1, children: aiError ? /* @__PURE__ */ jsx5(ErrorBox, { message: aiError }) : /* @__PURE__ */ jsxs5(Text5, { color: PLACEHOLDER2, children: [
2040
+ aiResponse,
2041
+ isStreaming && /* @__PURE__ */ jsx5(Text5, { color: PLACEHOLDER2, children: "\u258B" })
2042
+ ] }) })
2043
+ ] }) : !commandMessage && /* @__PURE__ */ jsx5(QueryResult, { state: queryState, elapsed, page, pageSize: activePageSize() })
2044
+ ] }) })
2045
+ ] }),
2046
+ isConnected ? /* @__PURE__ */ jsx5(
2047
+ QueryInput,
2048
+ {
2049
+ onSubmit: handleSubmit,
2050
+ isLoading,
2051
+ onModeChange: setVimMode,
2052
+ onShellModeChange: setInputIsShell,
2053
+ vimEnabled,
2054
+ history,
2055
+ aliases,
2056
+ schema: schema ?? void 0
2057
+ }
2058
+ ) : /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Not connected. Press Ctrl+C to exit." }),
2059
+ (vimEnabled || inputIsShell) && /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { bold: true, color: inputIsShell ? theme.shellMode : vimMode === "NORMAL" ? theme.normalMode : theme.insertMode, children: isRawModeSupported ? inputIsShell ? "[SHELL]" : `[${vimMode}]` : "" }) })
2060
+ ] })
2061
+ ] });
2062
+ }
2063
+
2064
+ // src/ui/components/ConnectionWizard.tsx
2065
+ import { useState as useState4 } from "react";
2066
+ import { Box as Box6, Text as Text6, useInput as useInput3, useStdin as useStdin3 } from "ink";
2067
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2068
+ var DRIVERS = ["postgresql", "mysql", "sqlite"];
2069
+ var LABEL_WIDTH = 10;
2070
+ function fieldLabels(driver) {
2071
+ if (driver === "sqlite") return ["Driver", "File path"];
2072
+ return ["Driver", "Host", "Port", "Database", "User", "Password"];
2073
+ }
2074
+ function ConnectionWizard({ onConnect, initialError }) {
2075
+ const { isRawModeSupported } = useStdin3();
2076
+ const [focus, setFocus] = useState4(0);
2077
+ const [fields, setFields] = useState4({
2078
+ driver: "postgresql",
2079
+ host: "localhost",
2080
+ port: "5432",
2081
+ database: "",
2082
+ user: "",
2083
+ password: ""
2084
+ });
2085
+ const [connecting, setConnecting] = useState4(false);
2086
+ const [error, setError] = useState4(initialError ?? null);
2087
+ const [keychainHint, setKeychainHint] = useState4(false);
2088
+ const labels = fieldLabels(fields.driver);
2089
+ const fieldCount = labels.length;
2090
+ function getTextValue(idx) {
2091
+ if (fields.driver === "sqlite") {
2092
+ return idx === 1 ? fields.database : "";
2093
+ }
2094
+ switch (idx) {
2095
+ case 1:
2096
+ return fields.host;
2097
+ case 2:
2098
+ return fields.port;
2099
+ case 3:
2100
+ return fields.database;
2101
+ case 4:
2102
+ return fields.user;
2103
+ case 5:
2104
+ return fields.password;
2105
+ default:
2106
+ return "";
2107
+ }
2108
+ }
2109
+ function setTextValue(idx, value) {
2110
+ setFields((prev) => {
2111
+ if (prev.driver === "sqlite") {
2112
+ return idx === 1 ? { ...prev, database: value } : prev;
2113
+ }
2114
+ switch (idx) {
2115
+ case 1:
2116
+ return { ...prev, host: value };
2117
+ case 2:
2118
+ return { ...prev, port: value };
2119
+ case 3:
2120
+ return { ...prev, database: value };
2121
+ case 4:
2122
+ return { ...prev, user: value };
2123
+ case 5:
2124
+ return { ...prev, password: value };
2125
+ default:
2126
+ return prev;
2127
+ }
2128
+ });
2129
+ }
2130
+ function cycleDriver(dir) {
2131
+ setFields((prev) => {
2132
+ const idx = DRIVERS.indexOf(prev.driver);
2133
+ const next = DRIVERS[(idx + dir + DRIVERS.length) % DRIVERS.length];
2134
+ const port = next === "mysql" ? "3306" : next === "postgresql" ? "5432" : "0";
2135
+ return { ...prev, driver: next, port };
2136
+ });
2137
+ setFocus(0);
2138
+ }
2139
+ function moveFocus(next) {
2140
+ setFocus(next);
2141
+ if (fields.driver !== "sqlite" && next === 5 && !fields.password) {
2142
+ void getPassword(
2143
+ fields.driver,
2144
+ fields.user,
2145
+ fields.host,
2146
+ parseInt(fields.port) || (fields.driver === "mysql" ? 3306 : 5432)
2147
+ ).then((saved) => {
2148
+ if (saved) {
2149
+ setFields((prev) => ({ ...prev, password: saved }));
2150
+ setKeychainHint(true);
2151
+ }
2152
+ });
2153
+ } else {
2154
+ setKeychainHint(false);
2155
+ }
2156
+ }
2157
+ async function submit() {
2158
+ setConnecting(true);
2159
+ setError(null);
2160
+ setKeychainHint(false);
2161
+ const result = await connectParams({
2162
+ driver: fields.driver,
2163
+ host: fields.host || "localhost",
2164
+ port: parseInt(fields.port) || (fields.driver === "mysql" ? 3306 : 5432),
2165
+ database: fields.database,
2166
+ user: fields.user,
2167
+ password: fields.password
2168
+ });
2169
+ setConnecting(false);
2170
+ if (result.status === "error") {
2171
+ setError(result.message);
2172
+ } else {
2173
+ onConnect(result);
2174
+ }
2175
+ }
2176
+ useInput3(
2177
+ (input, key) => {
2178
+ if (connecting) return;
2179
+ if (key.tab && key.shift) {
2180
+ moveFocus((focus - 1 + fieldCount) % fieldCount);
2181
+ return;
2182
+ }
2183
+ if (key.tab || key.downArrow) {
2184
+ moveFocus((focus + 1) % fieldCount);
2185
+ return;
2186
+ }
2187
+ if (key.upArrow) {
2188
+ moveFocus((focus - 1 + fieldCount) % fieldCount);
2189
+ return;
2190
+ }
2191
+ if (focus === 0) {
2192
+ if (key.leftArrow) cycleDriver(-1);
2193
+ if (key.rightArrow || input === " ") cycleDriver(1);
2194
+ if (key.return) moveFocus(1);
2195
+ return;
2196
+ }
2197
+ if (key.return) {
2198
+ if (focus === fieldCount - 1) {
2199
+ void submit();
2200
+ } else {
2201
+ moveFocus(focus + 1);
2202
+ }
2203
+ return;
2204
+ }
2205
+ if (key.backspace || key.delete) {
2206
+ setKeychainHint(false);
2207
+ setTextValue(focus, getTextValue(focus).slice(0, -1));
2208
+ return;
2209
+ }
2210
+ if (input && !key.ctrl && !key.meta) {
2211
+ if (keychainHint) {
2212
+ setKeychainHint(false);
2213
+ setTextValue(focus, input);
2214
+ } else {
2215
+ setTextValue(focus, getTextValue(focus) + input);
2216
+ }
2217
+ }
2218
+ },
2219
+ { isActive: isRawModeSupported }
2220
+ );
2221
+ const isPassword = (idx) => fields.driver !== "sqlite" && idx === 5;
2222
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 2, paddingTop: 2, paddingBottom: 1, children: [
2223
+ /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx6(Text6, { bold: true, color: theme.accent, children: "Connect to a database" }) }),
2224
+ labels.map((label, idx) => {
2225
+ const isFocused = focus === idx;
2226
+ const isDriver = idx === 0;
2227
+ return /* @__PURE__ */ jsxs6(Box6, { children: [
2228
+ /* @__PURE__ */ jsx6(Text6, { color: isFocused ? theme.accent : void 0, bold: isFocused, children: label.padEnd(LABEL_WIDTH) }),
2229
+ isDriver ? /* @__PURE__ */ jsx6(Box6, { children: DRIVERS.map((d, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
2230
+ i > 0 && /* @__PURE__ */ jsx6(Text6, { children: " " }),
2231
+ /* @__PURE__ */ jsxs6(Text6, { color: fields.driver === d ? theme.accent : void 0, bold: fields.driver === d, children: [
2232
+ fields.driver === d ? "\u25CF " : "\u25CB ",
2233
+ d.charAt(0).toUpperCase() + d.slice(1)
2234
+ ] })
2235
+ ] }, d)) }) : /* @__PURE__ */ jsxs6(Box6, { children: [
2236
+ /* @__PURE__ */ jsx6(Text6, { children: isPassword(idx) ? "\u2022".repeat(getTextValue(idx).length) : getTextValue(idx) }),
2237
+ isFocused && /* @__PURE__ */ jsx6(Text6, { color: theme.accent, bold: true, children: "\u258C" }),
2238
+ isFocused && isPassword(idx) && keychainHint && /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " (from keychain)" })
2239
+ ] })
2240
+ ] }, label);
2241
+ }),
2242
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: connecting ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Connecting\u2026" }) : error ? /* @__PURE__ */ jsxs6(Text6, { color: theme.error, children: [
2243
+ "\u2717 ",
2244
+ error
2245
+ ] }) : null }),
2246
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Tab \xB7 \u2191\u2193 navigate Enter connect \u2190\u2192 cycle driver" }) })
2247
+ ] });
2248
+ }
2249
+
2250
+ // src/index.tsx
2251
+ import { jsx as jsx7 } from "react/jsx-runtime";
2252
+ var { values } = parseArgs({
2253
+ args: process.argv.slice(2).filter((a) => a !== "--"),
2254
+ options: {
2255
+ connection: { type: "string", short: "c" },
2256
+ "ai-url": { type: "string", default: "http://localhost:11434/v1" },
2257
+ "ai-model": { type: "string", default: "llama3.2" },
2258
+ "api-key": { type: "string", default: "" }
2259
+ },
2260
+ strict: false
2261
+ });
2262
+ var initialDsn = values.connection;
2263
+ var aiUrl = values["ai-url"];
2264
+ var aiModel = values["ai-model"];
2265
+ var aiKey = values["api-key"] || process.env.Q_CLI_API_KEY || "";
2266
+ function Root({ initialDsn: initialDsn2, aiUrl: aiUrl2, aiModel: aiModel2, aiKey: aiKey2 }) {
2267
+ const [connectionState, setConnectionState] = useState5(null);
2268
+ const [dsnError, setDsnError] = useState5(null);
2269
+ useEffect5(() => {
2270
+ if (initialDsn2) {
2271
+ connectDsn(initialDsn2).then((state) => {
2272
+ if (state.status === "error") {
2273
+ setDsnError(state.message);
2274
+ } else {
2275
+ setConnectionState(state);
2276
+ }
2277
+ });
2278
+ }
2279
+ }, []);
2280
+ if (initialDsn2 && !connectionState && !dsnError) {
2281
+ return /* @__PURE__ */ jsx7(Box7, { paddingX: 2, paddingTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Connecting\u2026" }) });
2282
+ }
2283
+ if (!connectionState) {
2284
+ return /* @__PURE__ */ jsx7(ConnectionWizard, { onConnect: setConnectionState, initialError: dsnError ?? void 0 });
2285
+ }
2286
+ async function handleChangeDatabase(database) {
2287
+ if (connectionState.status !== "connected") return;
2288
+ const current = connectionState;
2289
+ await current.client.end().catch(() => {
2290
+ });
2291
+ const next = await connectParams({ ...current.params, database });
2292
+ setConnectionState(next);
2293
+ }
2294
+ return /* @__PURE__ */ jsx7(
2295
+ App,
2296
+ {
2297
+ connectionState,
2298
+ aiUrl: aiUrl2,
2299
+ aiModel: aiModel2,
2300
+ aiKey: aiKey2,
2301
+ onChangeDatabase: (db) => {
2302
+ void handleChangeDatabase(db);
2303
+ }
2304
+ }
2305
+ );
2306
+ }
2307
+ render(
2308
+ /* @__PURE__ */ jsx7(Root, { initialDsn, aiUrl, aiModel, aiKey }),
2309
+ { exitOnCtrlC: true }
2310
+ );