oxlint-tui 1.0.12 → 1.0.14

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 (2) hide show
  1. package/package.json +1 -1
  2. package/tui.js +509 -479
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-tui",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "A Node TUI Oxlint rules and configuration browser",
5
5
  "type": "module",
6
6
  "bin": {
package/tui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { execSync, exec } from "node:child_process";
3
+ import { execSync, exec, spawn } from "node:child_process";
4
4
  import { stdout, stdin, exit, platform, argv } from "node:process";
5
5
  import readline from "readline";
6
6
  import fs from "node:fs";
@@ -9,306 +9,336 @@ const OXLINT_VERSION = "1.41.0";
9
9
  const TSGOLINT_VERSION = "0.11.1";
10
10
 
11
11
  const COLORS = {
12
- reset: "\x1b[0m",
13
- dim: "\x1b[90m",
14
- highlight: "\x1b[38;5;110m",
15
- selectedBg: "\x1b[47m\x1b[30m",
16
- borderActive: "\x1b[36m",
17
- borderInactive: "\x1b[90m",
18
- error: "\x1b[31m",
19
- warn: "\x1b[33m",
20
- success: "\x1b[32m",
21
- info: "\x1b[34m",
12
+ reset: "\x1b[0m",
13
+ dim: "\x1b[90m",
14
+ highlight: "\x1b[38;5;110m",
15
+ selectedBg: "\x1b[47m\x1b[30m",
16
+ borderActive: "\x1b[36m",
17
+ borderInactive: "\x1b[90m",
18
+ error: "\x1b[31m",
19
+ warn: "\x1b[33m",
20
+ success: "\x1b[32m",
21
+ info: "\x1b[34m",
22
22
  };
23
23
 
24
24
  const KEY_MAP = {
25
- k: { type: "MOVE_UP" },
26
- up: { type: "MOVE_UP" },
27
- down: { type: "MOVE_DOWN" },
28
- j: { type: "MOVE_DOWN" },
29
- left: { type: "MOVE_LEFT" },
30
- h: { type: "MOVE_LEFT" },
31
- right: { type: "MOVE_RIGHT" },
32
- l: { type: "MOVE_RIGHT" },
33
- return: { type: "OPEN_DOCS" },
34
- enter: { type: "OPEN_DOCS" },
35
- 1: { type: "SET_STATUS", value: "off" },
36
- 2: { type: "SET_STATUS", value: "warn" },
37
- 3: { type: "SET_STATUS", value: "error" },
38
- q: { type: "EXIT" },
39
- r: { type: "RUN_LINT" },
40
- t: { type: "RUN_TYPE_AWARE_LINT" },
25
+ k: { type: "MOVE_UP" },
26
+ up: { type: "MOVE_UP" },
27
+ down: { type: "MOVE_DOWN" },
28
+ j: { type: "MOVE_DOWN" },
29
+ left: { type: "MOVE_LEFT" },
30
+ h: { type: "MOVE_LEFT" },
31
+ right: { type: "MOVE_RIGHT" },
32
+ l: { type: "MOVE_RIGHT" },
33
+ return: { type: "OPEN_DOCS" },
34
+ enter: { type: "OPEN_DOCS" },
35
+ 1: { type: "SET_STATUS", value: "off" },
36
+ 2: { type: "SET_STATUS", value: "warn" },
37
+ 3: { type: "SET_STATUS", value: "error" },
38
+ q: { type: "EXIT" },
39
+ r: { type: "RUN_LINT" },
40
+ t: { type: "RUN_TYPE_AWARE_LINT" },
41
41
  };
42
42
 
43
43
  let state = {
44
- activePane: 0,
45
- selectedCatIdx: 0,
46
- selectedRuleIdx: 0,
47
- scrollCat: 0,
48
- scrollRule: 0,
49
- isLinting: false,
50
- message: "oxlint-tui",
51
- messageType: "dim",
52
- ...loadRules(),
44
+ activePane: 0,
45
+ selectedCatIdx: 0,
46
+ selectedRuleIdx: 0,
47
+ scrollCat: 0,
48
+ scrollRule: 0,
49
+ isLinting: false,
50
+ message: "oxlint-tui",
51
+ messageType: "dim",
52
+ ...loadRules(),
53
53
  };
54
54
 
55
55
  // TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserving formatting
56
56
  function updateConfig(rule, newStatus) {
57
- if (!state.configPath) return;
58
- try {
59
- if (!state.config.rules) state.config.rules = {};
60
- const ruleName = rule.value;
61
- const canonicalKey =
62
- rule.scope === "oxc2" || rule.scope === "eslint"
63
- ? ruleName
64
- : `${rule.scope}/${ruleName}`;
65
- const existingKey = Object.keys(state.config.rules).find(
66
- (key) =>
67
- key === canonicalKey ||
68
- key === ruleName ||
69
- key.endsWith(`/${ruleName}`),
70
- );
71
- const targetKey = existingKey || canonicalKey;
72
- state.config.rules[targetKey] = newStatus;
73
- fs.writeFileSync(
74
- state.configPath,
75
- JSON.stringify(state.config, null, 2),
76
- "utf8",
77
- );
78
- } catch (e) {
79
- state.message = "Failed to write config file";
80
- state.messageType = "error";
81
- }
57
+ if (!state.configPath) return;
58
+ try {
59
+ if (!state.config.rules) state.config.rules = {};
60
+ const ruleName = rule.value;
61
+ const canonicalKey =
62
+ rule.scope === "oxc2" || rule.scope === "eslint"
63
+ ? ruleName
64
+ : `${rule.scope}/${ruleName}`;
65
+ const existingKey = Object.keys(state.config.rules).find(
66
+ (key) =>
67
+ key === canonicalKey ||
68
+ key === ruleName ||
69
+ key.endsWith(`/${ruleName}`),
70
+ );
71
+ const targetKey = existingKey || canonicalKey;
72
+ state.config.rules[targetKey] = newStatus;
73
+ fs.writeFileSync(
74
+ state.configPath,
75
+ JSON.stringify(state.config, null, 2),
76
+ "utf8",
77
+ );
78
+ } catch (e) {
79
+ state.message = "Failed to write config file";
80
+ state.messageType = "error";
81
+ }
82
82
  }
83
83
 
84
84
  function runLint(type_aware) {
85
- if (state.isLinting) return;
85
+ if (state.isLinting) return;
86
86
 
87
- state.isLinting = true;
88
- state.message = type_aware
89
- ? "Running linter with --type-aware..."
90
- : "Running linter...";
91
- state.messageType = "info";
92
- render();
87
+ state.isLinting = true;
88
+ state.message = type_aware ? "Linting with --type-aware)..." : "Linting...";
89
+ state.messageType = "info";
90
+ render();
93
91
 
94
- const cmd = type_aware
95
- ? `npx -q --yes --package oxlint@${OXLINT_VERSION} --package oxlint-tsgolint@${TSGOLINT_VERSION} -- oxlint --type-aware`
96
- : `npx -q --yes --package oxlint@${OXLINT_VERSION} -- oxlint`;
97
-
98
- exec(cmd, (error, stdout, stderr) => {
99
- const summaryMatch = stdout.match(/Found (\d+) warnings? and (\d+) errors?/i);
100
-
101
- state.isLinting = false;
102
-
103
- if (summaryMatch) {
104
- state.message = summaryMatch[0].trim();
105
- const errors = parseInt(summaryMatch[2]);
106
- state.messageType = errors > 0 ? "error" : "warn";
107
- } else if (!error || (error.code === 0)) {
108
- state.message = "Linting passed! 0 issues found.";
109
- state.messageType = "success";
110
- } else {
111
- state.message = `Error: ${stderr.split('\n')[0] || "Unknown issue"}`;
112
- state.messageType = "error";
113
- }
114
- render();
115
- });
92
+ const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
93
+
94
+ const args = type_aware
95
+ ? ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`, "--package", `oxlint-tsgolint@${TSGOLINT_VERSION}`, "--", "oxlint", "--type-aware",]
96
+ : ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`, "--", "oxlint"];
97
+
98
+ const child = spawn(npxCmd, args);
99
+
100
+ let stdoutData = "";
101
+ let stderrData = "";
102
+
103
+ child.stdout.on("data", (data) => {
104
+ stdoutData += data;
105
+ });
106
+ child.stderr.on("data", (data) => {
107
+ stderrData += data;
108
+ });
109
+
110
+ child.on("close", (code) => {
111
+ state.isLinting = false;
112
+
113
+ const fullOutput = stdoutData + stderrData;
114
+ const summaryMatch = fullOutput.match(
115
+ /Found (\d+) warnings? and (\d+) errors?/i,
116
+ );
117
+
118
+ if (summaryMatch) {
119
+ state.message = summaryMatch[0];
120
+ const errors = parseInt(summaryMatch[2]);
121
+ state.messageType = errors > 0 ? "error" : "warn";
122
+ } else if (
123
+ stdoutData.toLowerCase().includes("finished") ||
124
+ (code === 0 && stdoutData.length < 200)
125
+ ) {
126
+ state.message = "Linting passed! 0 issues found.";
127
+ state.messageType = "success";
128
+ } else {
129
+ const cleanError = stderrData
130
+ .split("\n")
131
+ .filter(
132
+ (l) =>
133
+ !l.includes("experimental") &&
134
+ !l.includes("Breaking changes") &&
135
+ l.trim() !== "",
136
+ )
137
+ .join(" ");
138
+
139
+ state.message = cleanError
140
+ ? `Error: ${cleanError.substring(0, 50)}...`
141
+ : "Lint failed";
142
+ state.messageType = "error";
143
+ }
144
+ render();
145
+ });
116
146
  }
117
147
 
118
148
  function reducer(state, action) {
119
- const {
120
- categories,
121
- rulesByCategory,
122
- selectedCatIdx,
123
- selectedRuleIdx,
124
- activePane,
125
- } = state;
126
- const currentCat = categories[selectedCatIdx];
127
- const currentRules = rulesByCategory[currentCat] || [];
128
- const viewHeight = stdout.rows - 8;
129
- const statsHeight = 7;
130
- const catViewHeight = viewHeight - statsHeight;
131
-
132
- switch (action.type) {
133
- case "SET_STATUS": {
134
- if (activePane !== 1) return state;
135
- const rule = currentRules[selectedRuleIdx];
136
- if (!rule) return state;
137
- updateConfig(rule, action.value);
138
- const updatedRules = [...currentRules];
139
- updatedRules[selectedRuleIdx] = {
140
- ...rule,
141
- configStatus: action.value,
142
- isActive: action.value === "error" || action.value === "warn",
143
- };
144
- return {
145
- ...state,
146
- message: `Rule '${rule.value}' set to: ${action.value}`,
147
- messageType: "info",
148
- rulesByCategory: {
149
- ...rulesByCategory,
150
- [currentCat]: updatedRules,
151
- },
152
- };
153
- }
154
- case "MOVE_RIGHT":
155
- if (activePane !== 1) return { ...state, activePane: activePane + 1 };
156
- return state;
157
-
158
- case "MOVE_LEFT":
159
- if (activePane !== 0) return { ...state, activePane: activePane - 1 };
160
- return state;
161
- case "MOVE_UP":
162
- if (activePane === 0) {
163
- const nextIdx =
164
- selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
165
- return {
166
- ...state,
167
- selectedCatIdx: nextIdx,
168
- selectedRuleIdx: 0,
169
- scrollRule: 0,
170
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
171
- };
172
- } else if (activePane === 1) {
173
- const nextIdx =
174
- selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
175
- return {
176
- ...state,
177
- selectedRuleIdx: nextIdx,
178
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
179
- };
180
- }
181
- return state;
182
- case "MOVE_DOWN":
183
- if (activePane === 0) {
184
- const nextIdx =
185
- selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
186
- return {
187
- ...state,
188
- selectedCatIdx: nextIdx,
189
- selectedRuleIdx: 0,
190
- scrollRule: 0,
191
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
192
- };
193
- } else if (activePane === 1) {
194
- const nextIdx =
195
- selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
196
- return {
197
- ...state,
198
- selectedRuleIdx: nextIdx,
199
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
200
- };
201
- }
202
- return state;
203
- default:
204
- return state;
149
+ const {
150
+ categories,
151
+ rulesByCategory,
152
+ selectedCatIdx,
153
+ selectedRuleIdx,
154
+ activePane,
155
+ } = state;
156
+ const currentCat = categories[selectedCatIdx];
157
+ const currentRules = rulesByCategory[currentCat] || [];
158
+ const viewHeight = stdout.rows - 8;
159
+ const statsHeight = 7;
160
+ const catViewHeight = viewHeight - statsHeight;
161
+
162
+ switch (action.type) {
163
+ case "SET_STATUS": {
164
+ if (activePane !== 1) return state;
165
+ const rule = currentRules[selectedRuleIdx];
166
+ if (!rule) return state;
167
+ updateConfig(rule, action.value);
168
+ const updatedRules = [...currentRules];
169
+ updatedRules[selectedRuleIdx] = {
170
+ ...rule,
171
+ configStatus: action.value,
172
+ isActive: action.value === "error" || action.value === "warn",
173
+ };
174
+ return {
175
+ ...state,
176
+ message: `Rule '${rule.value}' set to: ${action.value}`,
177
+ messageType: "info",
178
+ rulesByCategory: {
179
+ ...rulesByCategory,
180
+ [currentCat]: updatedRules,
181
+ },
182
+ };
205
183
  }
184
+ case "MOVE_RIGHT":
185
+ if (activePane !== 1) return { ...state, activePane: activePane + 1 };
186
+ return state;
187
+
188
+ case "MOVE_LEFT":
189
+ if (activePane !== 0) return { ...state, activePane: activePane - 1 };
190
+ return state;
191
+ case "MOVE_UP":
192
+ if (activePane === 0) {
193
+ const nextIdx =
194
+ selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
195
+ return {
196
+ ...state,
197
+ selectedCatIdx: nextIdx,
198
+ selectedRuleIdx: 0,
199
+ scrollRule: 0,
200
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
201
+ };
202
+ } else if (activePane === 1) {
203
+ const nextIdx =
204
+ selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
205
+ return {
206
+ ...state,
207
+ selectedRuleIdx: nextIdx,
208
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
209
+ };
210
+ }
211
+ return state;
212
+ case "MOVE_DOWN":
213
+ if (activePane === 0) {
214
+ const nextIdx =
215
+ selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
216
+ return {
217
+ ...state,
218
+ selectedCatIdx: nextIdx,
219
+ selectedRuleIdx: 0,
220
+ scrollRule: 0,
221
+ scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
222
+ };
223
+ } else if (activePane === 1) {
224
+ const nextIdx =
225
+ selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
226
+ return {
227
+ ...state,
228
+ selectedRuleIdx: nextIdx,
229
+ scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
230
+ };
231
+ }
232
+ return state;
233
+ default:
234
+ return state;
235
+ }
206
236
  }
207
237
 
208
238
  function getRuleStatus(ruleName, category, config) {
209
- if (config.rules) {
210
- let val = config.rules[ruleName];
211
- if (val === undefined) {
212
- const foundKey = Object.keys(config.rules).find((key) =>
213
- key.endsWith(`/${ruleName}`),
214
- );
215
- if (foundKey) val = config.rules[foundKey];
216
- }
217
- if (val !== undefined) return Array.isArray(val) ? val[0] : val;
239
+ if (config.rules) {
240
+ let val = config.rules[ruleName];
241
+ if (val === undefined) {
242
+ const foundKey = Object.keys(config.rules).find((key) =>
243
+ key.endsWith(`/${ruleName}`),
244
+ );
245
+ if (foundKey) val = config.rules[foundKey];
218
246
  }
219
- return (config.categories && config.categories[category]) || "off";
247
+ if (val !== undefined) return Array.isArray(val) ? val[0] : val;
248
+ }
249
+ return (config.categories && config.categories[category]) || "off";
220
250
  }
221
251
 
222
252
  function stripJsonComments(json) {
223
- return json.replace(
224
- /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
225
- (m, g) => (g ? "" : m),
226
- );
253
+ return json.replace(
254
+ /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
255
+ (m, g) => (g ? "" : m),
256
+ );
227
257
  }
228
258
 
229
259
  function loadRules() {
230
- let rulesData;
231
- let config = {
232
- rules: {},
233
- categories: {},
234
- };
235
- let configPath = null;
236
-
237
- try {
238
- const raw = execSync(
239
- `npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
240
- {
241
- encoding: "utf8",
242
- stdio: ["ignore", "pipe", "ignore"],
243
- },
244
- );
245
- rulesData = JSON.parse(raw);
246
- } catch (e) {
247
- console.error(
248
- `${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
249
- );
250
- exit(1);
251
- }
252
-
253
- const userConfigPath = argv[2];
254
- if (userConfigPath && fs.existsSync(userConfigPath)) {
255
- configPath = userConfigPath;
256
- } else if (fs.existsSync(".oxlintrc.json")) {
257
- configPath = ".oxlintrc.json";
258
- }
260
+ let rulesData;
261
+ let config = {
262
+ rules: {},
263
+ categories: {},
264
+ };
265
+ let configPath = null;
266
+
267
+ try {
268
+ const raw = execSync(
269
+ `npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
270
+ {
271
+ encoding: "utf8",
272
+ stdio: ["ignore", "pipe", "ignore"],
273
+ },
274
+ );
275
+ rulesData = JSON.parse(raw);
276
+ } catch (e) {
277
+ console.error(
278
+ `${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
279
+ );
280
+ exit(1);
281
+ }
259
282
 
260
- if (configPath) {
261
- try {
262
- config = JSON.parse(
263
- stripJsonComments(fs.readFileSync(configPath, "utf8")),
264
- );
265
- } catch (e) { }
266
- }
283
+ const userConfigPath = argv[2];
284
+ if (userConfigPath && fs.existsSync(userConfigPath)) {
285
+ configPath = userConfigPath;
286
+ } else if (fs.existsSync(".oxlintrc.json")) {
287
+ configPath = ".oxlintrc.json";
288
+ }
267
289
 
268
- const map = {};
269
- rulesData.forEach((rule) => {
270
- const cat = rule.category || "Uncategorized";
271
- if (!map[cat]) map[cat] = [];
272
- const status = getRuleStatus(rule.value, cat, config);
273
- map[cat].push({
274
- ...rule,
275
- configStatus: status,
276
- isActive: status === "error" || status === "warn",
277
- });
290
+ if (configPath) {
291
+ try {
292
+ config = JSON.parse(
293
+ stripJsonComments(fs.readFileSync(configPath, "utf8")),
294
+ );
295
+ } catch (e) { }
296
+ }
297
+
298
+ const map = {};
299
+ rulesData.forEach((rule) => {
300
+ const cat = rule.category || "Uncategorized";
301
+ if (!map[cat]) map[cat] = [];
302
+ const status = getRuleStatus(rule.value, cat, config);
303
+ map[cat].push({
304
+ ...rule,
305
+ configStatus: status,
306
+ isActive: status === "error" || status === "warn",
278
307
  });
279
-
280
- const categories = Object.keys(map).sort();
281
- return {
282
- categories,
283
- rulesByCategory: map,
284
- config,
285
- configPath,
286
- };
308
+ });
309
+
310
+ const categories = Object.keys(map).sort();
311
+ return {
312
+ categories,
313
+ rulesByCategory: map,
314
+ config,
315
+ configPath,
316
+ };
287
317
  }
288
318
 
289
319
  function updateScroll(idx, currentScroll, viewHeight) {
290
- if (idx < currentScroll) return idx;
291
- if (idx >= currentScroll + viewHeight) return idx - viewHeight + 1;
292
- return currentScroll;
320
+ if (idx < currentScroll) return idx;
321
+ if (idx >= currentScroll + viewHeight) return idx - viewHeight + 1;
322
+ return currentScroll;
293
323
  }
294
324
 
295
325
  function openUrl(url) {
296
- if (!url) return;
297
- const cmd =
298
- platform === "darwin"
299
- ? "open"
300
- : platform === "win32"
301
- ? "start"
302
- : "xdg-open";
303
- exec(`${cmd} "${url}"`);
326
+ if (!url) return;
327
+ const cmd =
328
+ platform === "darwin"
329
+ ? "open"
330
+ : platform === "win32"
331
+ ? "start"
332
+ : "xdg-open";
333
+ exec(`${cmd} "${url}"`);
304
334
  }
305
335
 
306
336
  function chunkString(str, len) {
307
- if (!str) return [];
308
- const size = Math.ceil(str.length / len);
309
- const r = Array(size);
310
- for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
311
- return r;
337
+ if (!str) return [];
338
+ const size = Math.ceil(str.length / len);
339
+ const r = Array(size);
340
+ for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
341
+ return r;
312
342
  }
313
343
 
314
344
  const write = (str) => stdout.write(str);
@@ -316,250 +346,250 @@ const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
316
346
  const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
317
347
 
318
348
  function drawBox(
319
- buffer,
320
- x,
321
- y,
322
- width,
323
- height,
324
- title,
325
- items,
326
- selectedIdx,
327
- scrollOffset,
328
- isActive,
349
+ buffer,
350
+ x,
351
+ y,
352
+ width,
353
+ height,
354
+ title,
355
+ items,
356
+ selectedIdx,
357
+ scrollOffset,
358
+ isActive,
329
359
  ) {
330
- const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
331
- const titleClean =
332
- title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
333
- const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
334
- width + borderColor.length - 1,
335
- "─",
336
- );
337
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
338
-
339
- for (let i = 1; i < height - 1; i++) {
340
- buffer.push(
341
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
342
- );
343
- }
360
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
361
+ const titleClean =
362
+ title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
363
+ const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
364
+ width + borderColor.length - 1,
365
+ "─",
366
+ );
367
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
368
+
369
+ for (let i = 1; i < height - 1; i++) {
344
370
  buffer.push(
345
- `\x1b[${y + height - 1};${x}H${borderColor}└${"".repeat(width - 2)}┘${COLORS.reset}`,
371
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
346
372
  );
347
-
348
- const innerHeight = height - 2;
349
- items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
350
- const absIdx = scrollOffset + i;
351
- const rawText = (item.value || item).toString();
352
- let display =
353
- rawText.length > width - 4
354
- ? rawText.substring(0, width - 5) + "…"
355
- : rawText.padEnd(width - 4);
356
- let itemColor = COLORS.dim;
357
- if (item.configStatus === "error") itemColor = COLORS.error;
358
- else if (item.configStatus === "warn") itemColor = COLORS.warn;
359
- else if (item.isActive) itemColor = COLORS.success;
360
-
361
- buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
362
- if (absIdx === selectedIdx) {
363
- buffer.push(
364
- isActive
365
- ? `${COLORS.selectedBg}${display}${COLORS.reset}`
366
- : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
367
- );
368
- } else {
369
- buffer.push(`${itemColor}${display}${COLORS.reset}`);
370
- }
371
- });
373
+ }
374
+ buffer.push(
375
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
376
+ );
377
+
378
+ const innerHeight = height - 2;
379
+ items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
380
+ const absIdx = scrollOffset + i;
381
+ const rawText = (item.value || item).toString();
382
+ let display =
383
+ rawText.length > width - 4
384
+ ? rawText.substring(0, width - 5) + "…"
385
+ : rawText.padEnd(width - 4);
386
+ let itemColor = COLORS.dim;
387
+ if (item.configStatus === "error") itemColor = COLORS.error;
388
+ else if (item.configStatus === "warn") itemColor = COLORS.warn;
389
+ else if (item.isActive) itemColor = COLORS.success;
390
+
391
+ buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
392
+ if (absIdx === selectedIdx) {
393
+ buffer.push(
394
+ isActive
395
+ ? `${COLORS.selectedBg}${display}${COLORS.reset}`
396
+ : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
397
+ );
398
+ } else {
399
+ buffer.push(`${itemColor}${display}${COLORS.reset}`);
400
+ }
401
+ });
372
402
  }
373
403
 
374
404
  function drawStats(buffer, x, y, width, height, rules) {
375
- const borderColor = COLORS.borderInactive;
376
- const topBorder = `${borderColor}┌─ STATS `.padEnd(
377
- width + borderColor.length - 1,
378
- "─",
379
- );
380
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
381
- for (let i = 1; i < height - 1; i++)
382
- buffer.push(
383
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
384
- );
405
+ const borderColor = COLORS.borderInactive;
406
+ const topBorder = `${borderColor}┌─ STATS `.padEnd(
407
+ width + borderColor.length - 1,
408
+ "─",
409
+ );
410
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
411
+ for (let i = 1; i < height - 1; i++)
385
412
  buffer.push(
386
- `\x1b[${y + height - 1};${x}H${borderColor}└${"".repeat(width - 2)}┘${COLORS.reset}`,
413
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
387
414
  );
388
-
389
- let counts = { error: 0, warn: 0, off: 0 };
390
- rules.forEach((r) => {
391
- if (r.configStatus === "error") counts.error++;
392
- else if (r.configStatus === "warn") counts.warn++;
393
- else counts.off++;
394
- });
395
-
396
- const lines = [
397
- { label: "Error", count: counts.error, color: COLORS.error },
398
- { label: "Warn", count: counts.warn, color: COLORS.warn },
399
- { label: "Off", count: counts.off, color: COLORS.dim },
400
- ];
401
-
402
- lines.forEach((line, i) => {
403
- if (i < height - 2) {
404
- const numStr = String(line.count).padStart(3);
405
- const labelStr = line.label.padEnd(width - 8);
406
- buffer.push(
407
- `\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
408
- );
409
- }
410
- });
415
+ buffer.push(
416
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
417
+ );
418
+
419
+ let counts = { error: 0, warn: 0, off: 0 };
420
+ rules.forEach((r) => {
421
+ if (r.configStatus === "error") counts.error++;
422
+ else if (r.configStatus === "warn") counts.warn++;
423
+ else counts.off++;
424
+ });
425
+
426
+ const lines = [
427
+ { label: "Error", count: counts.error, color: COLORS.error },
428
+ { label: "Warn", count: counts.warn, color: COLORS.warn },
429
+ { label: "Off", count: counts.off, color: COLORS.dim },
430
+ ];
431
+
432
+ lines.forEach((line, i) => {
433
+ if (i < height - 2) {
434
+ const numStr = String(line.count).padStart(3);
435
+ const labelStr = line.label.padEnd(width - 8);
436
+ buffer.push(
437
+ `\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
438
+ );
439
+ }
440
+ });
411
441
  }
412
442
 
413
443
  function drawDetails(buffer, x, y, width, height, rule, isActive) {
414
- const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
415
- const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
416
- width + borderColor.length - 1,
417
- "─",
444
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
445
+ const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
446
+ width + borderColor.length - 1,
447
+ "─",
448
+ );
449
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
450
+ for (let i = 1; i < height - 1; i++)
451
+ buffer.push(
452
+ `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
418
453
  );
419
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
420
- for (let i = 1; i < height - 1; i++)
454
+ buffer.push(
455
+ `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
456
+ );
457
+
458
+ if (!rule) return;
459
+
460
+ let statusDisplay = rule.configStatus.toUpperCase();
461
+ if (rule.configStatus === "error")
462
+ statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
463
+ else if (rule.configStatus === "warn")
464
+ statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
465
+ else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
466
+
467
+ const labels = [
468
+ ["Name", rule.value],
469
+ ["Status", statusDisplay],
470
+ ["Category", rule.category],
471
+ ["Scope", rule.scope],
472
+ ["Fix", rule.fix],
473
+ ["Default", rule.default ? "Yes" : "No"],
474
+ ["Type-aware", rule.type_aware ? "Yes" : "No"],
475
+ ["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
476
+ ];
477
+
478
+ let line = 0;
479
+ labels.forEach(([lbl, val]) => {
480
+ if (lbl === "Status" && line < height - 2) {
481
+ buffer.push(
482
+ `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
483
+ );
484
+ line++;
485
+ return;
486
+ }
487
+ const chunks = chunkString(String(val || "N/A"), width - 15);
488
+ chunks.forEach((chunk) => {
489
+ if (line < height - 2) {
421
490
  buffer.push(
422
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
491
+ `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
423
492
  );
424
- buffer.push(
425
- `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
426
- );
427
-
428
- if (!rule) return;
429
-
430
- let statusDisplay = rule.configStatus.toUpperCase();
431
- if (rule.configStatus === "error")
432
- statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
433
- else if (rule.configStatus === "warn")
434
- statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
435
- else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
436
-
437
- const labels = [
438
- ["Name", rule.value],
439
- ["Status", statusDisplay],
440
- ["Category", rule.category],
441
- ["Scope", rule.scope],
442
- ["Fix", rule.fix],
443
- ["Default", rule.default ? "Yes" : "No"],
444
- ["Type-aware", rule.type_aware ? "Yes" : "No"],
445
- ["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
446
- ];
447
-
448
- let line = 0;
449
- labels.forEach(([lbl, val]) => {
450
- if (lbl === "Status" && line < height - 2) {
451
- buffer.push(
452
- `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
453
- );
454
- line++;
455
- return;
456
- }
457
- const chunks = chunkString(String(val || "N/A"), width - 15);
458
- chunks.forEach((chunk) => {
459
- if (line < height - 2) {
460
- buffer.push(
461
- `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
462
- );
463
- line++;
464
- }
465
- });
493
+ line++;
494
+ }
466
495
  });
496
+ });
467
497
  }
468
498
 
469
499
  function render() {
470
- const { columns, rows } = stdout;
471
- const currentCat = state.categories[state.selectedCatIdx];
472
- const rules = state.rulesByCategory[currentCat] || [];
473
- const rule = rules[state.selectedRuleIdx];
474
- const boxHeight = rows - 5;
475
- const col1W = Math.floor(columns * 0.2);
476
- const col2W = Math.floor(columns * 0.3);
477
- const col3W = columns - col1W - col2W - 2;
478
- const statsHeight = 6;
479
- const catListHeight = boxHeight - statsHeight;
480
-
481
- const buffer = ["\x1b[H\x1b[J"];
482
- drawBox(
483
- buffer,
484
- 1,
485
- 1,
486
- col1W,
487
- catListHeight,
488
- "CATEGORIES",
489
- state.categories,
490
- state.selectedCatIdx,
491
- state.scrollCat,
492
- state.activePane === 0,
493
- );
494
- drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
495
- drawBox(
496
- buffer,
497
- col1W + 1,
498
- 1,
499
- col2W,
500
- boxHeight,
501
- `RULES (${rules.length})`,
502
- rules,
503
- state.selectedRuleIdx,
504
- state.scrollRule,
505
- state.activePane === 1,
506
- );
507
- drawDetails(
508
- buffer,
509
- col1W + col2W + 1,
510
- 1,
511
- col3W,
512
- boxHeight,
513
- rule,
514
- state.activePane === 2,
515
- );
516
-
517
- const msgColor = COLORS[state.messageType] || COLORS.reset;
518
- buffer.push(
519
- `\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
520
- );
521
-
522
- const footerConfig = state.configPath
523
- ? `Config: ${state.configPath}`
524
- : "No config loaded";
525
- buffer.push(
526
- `\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | T: Lint with --type-aware | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
527
- );
528
- write(buffer.join(""));
500
+ const { columns, rows } = stdout;
501
+ const currentCat = state.categories[state.selectedCatIdx];
502
+ const rules = state.rulesByCategory[currentCat] || [];
503
+ const rule = rules[state.selectedRuleIdx];
504
+ const boxHeight = rows - 5;
505
+ const col1W = Math.floor(columns * 0.2);
506
+ const col2W = Math.floor(columns * 0.3);
507
+ const col3W = columns - col1W - col2W - 2;
508
+ const statsHeight = 6;
509
+ const catListHeight = boxHeight - statsHeight;
510
+
511
+ const buffer = ["\x1b[H\x1b[J"];
512
+ drawBox(
513
+ buffer,
514
+ 1,
515
+ 1,
516
+ col1W,
517
+ catListHeight,
518
+ "CATEGORIES",
519
+ state.categories,
520
+ state.selectedCatIdx,
521
+ state.scrollCat,
522
+ state.activePane === 0,
523
+ );
524
+ drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
525
+ drawBox(
526
+ buffer,
527
+ col1W + 1,
528
+ 1,
529
+ col2W,
530
+ boxHeight,
531
+ `RULES (${rules.length})`,
532
+ rules,
533
+ state.selectedRuleIdx,
534
+ state.scrollRule,
535
+ state.activePane === 1,
536
+ );
537
+ drawDetails(
538
+ buffer,
539
+ col1W + col2W + 1,
540
+ 1,
541
+ col3W,
542
+ boxHeight,
543
+ rule,
544
+ state.activePane === 2,
545
+ );
546
+
547
+ const msgColor = COLORS[state.messageType] || COLORS.reset;
548
+ buffer.push(
549
+ `\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
550
+ );
551
+
552
+ const footerConfig = state.configPath
553
+ ? `Config: ${state.configPath}`
554
+ : "No config loaded";
555
+ buffer.push(
556
+ `\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | T: Lint with --type-aware | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
557
+ );
558
+ write(buffer.join(""));
529
559
  }
530
560
 
531
561
  readline.emitKeypressEvents(stdin);
532
562
  if (stdin.isTTY) stdin.setRawMode(true);
533
563
 
534
564
  stdin.on("keypress", (_, key) => {
535
- const action =
536
- KEY_MAP[key.name] ||
537
- (key.ctrl && key.name === "c"
538
- ? { type: "EXIT" }
539
- : KEY_MAP[key.sequence] || null);
540
- if (!action) return;
541
-
542
- if (action.type === "EXIT") {
543
- exitAltScreen();
544
- exit(0);
545
- }
546
- if (action.type === "RUN_LINT") {
547
- runLint();
548
- return;
549
- }
550
- if (action.type === "RUN_TYPE_AWARE_LINT") {
551
- runLint(true);
552
- return;
553
- }
554
- if (action.type === "OPEN_DOCS" && state.activePane === 1) {
555
- const currentCat = state.categories[state.selectedCatIdx];
556
- const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
557
- if (rule) openUrl(rule.docs_url || rule.url);
558
- return;
559
- }
565
+ const action =
566
+ KEY_MAP[key.name] ||
567
+ (key.ctrl && key.name === "c"
568
+ ? { type: "EXIT" }
569
+ : KEY_MAP[key.sequence] || null);
570
+ if (!action) return;
571
+
572
+ if (action.type === "EXIT") {
573
+ exitAltScreen();
574
+ exit(0);
575
+ }
576
+ if (action.type === "RUN_LINT") {
577
+ runLint(false);
578
+ return;
579
+ }
580
+ if (action.type === "RUN_TYPE_AWARE_LINT") {
581
+ runLint(true);
582
+ return;
583
+ }
584
+ if (action.type === "OPEN_DOCS" && state.activePane === 1) {
585
+ const currentCat = state.categories[state.selectedCatIdx];
586
+ const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
587
+ if (rule) openUrl(rule.docs_url || rule.url);
588
+ return;
589
+ }
560
590
 
561
- state = reducer(state, action);
562
- render();
591
+ state = reducer(state, action);
592
+ render();
563
593
  });
564
594
 
565
595
  stdout.on("resize", render);