oxlint-tui 1.0.7 → 1.0.9

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