oxlint-tui 1.0.6 → 1.0.8

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