oxlint-tui 1.0.5 → 1.0.7

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 +9 -9
  2. package/package.json +2 -2
  3. package/tui.js +94 -70
package/README.md CHANGED
@@ -1,8 +1,10 @@
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.
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
+ **NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
6
8
 
7
9
  ![screenshot](https://raw.githubusercontent.com/holoflash/oxlint-tui/refs/heads/main/screenshot.png)
8
10
 
@@ -17,11 +19,6 @@ Configuring linters often involves jumping between your editor, a massive JSON f
17
19
  * **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
18
20
  * **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
19
21
 
20
- ## Important note
21
-
22
- Documentation links and information about fixability are not available in older versions of oxlint.
23
- This tool was developed using oxlint version 1.41.0.
24
-
25
22
  ## Usage
26
23
 
27
24
  ### Quick Start (via npx)
@@ -57,7 +54,10 @@ oxlint-tui
57
54
  | **↑** / **k** | Move selection Up |
58
55
  | **↓** / **j** | Move selection Down |
59
56
  | **←** / **h** | Move focus Left (Categories <-> Rules) |
60
- | **→** / **l** | Move focus Right (Categories <-> Rules) |
57
+ | **→** / **l** | Move focus Right (Categories <-> Rules) ||
58
+ | **1** | Set selected rule to "off" |
59
+ | **2** | Set selected rule to "warn" |
60
+ | **3** | Set selected rule to "error" |
61
61
  | **Enter** | Open Rule Documentation in Browser |
62
62
  | **q** / **Esc** | Quit |
63
63
 
@@ -68,7 +68,7 @@ oxlint-tui
68
68
 
69
69
  ## Roadmap
70
70
 
71
- 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.
71
+ 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.
72
72
 
73
73
  If you're willing and able, please feel free to [contribute to this project](https://github.com/holoflash/oxlint-tui/blob/main/CONTRIBUTING.md).
74
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-tui",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "A Node TUI Oxlint rules and configuration browser",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "start": "node tui.js",
14
- "test:config": "node tui.js .oxlintrc.example.json"
14
+ "dev": "node tui.js .oxlintrc.example.json"
15
15
  },
16
16
  "keywords": [
17
17
  "oxlint",
package/tui.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { execSync, exec } from 'node:child_process';
4
4
  import { stdout, stdin, exit, platform, argv } from 'node:process';
5
- import readline from 'node:readline';
5
+ import readline from 'readline';
6
6
  import fs from 'node:fs';
7
7
 
8
+ const OXLINT_VERSION = "1.41.0"
9
+
8
10
  const COLORS = {
9
11
  reset: '\x1b[0m',
10
12
  dim: '\x1b[90m',
@@ -28,10 +30,38 @@ const KEY_MAP = {
28
30
  'l': { type: 'MOVE_RIGHT' },
29
31
  'return': { type: 'OPEN_DOCS' },
30
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' },
31
36
  'q': { type: 'EXIT' },
32
37
  'escape': { type: 'EXIT' }
33
38
  };
34
39
 
40
+ // TODO: Nukes comments in the json file. Find the most minimal way to avoid this, while preserveing formatting
41
+ function updateConfig(rule, newStatus) {
42
+ if (!state.configPath) return;
43
+
44
+ try {
45
+ if (!state.config.rules) state.config.rules = {};
46
+ 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}`)
54
+ );
55
+
56
+ const targetKey = existingKey || canonicalKey;
57
+ 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
62
+ }
63
+ }
64
+
35
65
  function reducer(state, action) {
36
66
  const { categories, rulesByCategory, selectedCatIdx, selectedRuleIdx, activePane } = state;
37
67
  const currentCat = categories[selectedCatIdx];
@@ -42,11 +72,38 @@ function reducer(state, action) {
42
72
  const catViewHeight = viewHeight - statsHeight;
43
73
 
44
74
  switch (action.type) {
75
+ case 'SET_STATUS': {
76
+ if (activePane !== 1) return state;
77
+ const rule = currentRules[selectedRuleIdx];
78
+ if (!rule) return state;
79
+
80
+ updateConfig(rule, action.value);
81
+
82
+ const updatedRules = [...currentRules];
83
+ updatedRules[selectedRuleIdx] = {
84
+ ...rule,
85
+ configStatus: action.value,
86
+ isActive: action.value === 'error' || action.value === 'warn'
87
+ };
88
+
89
+ return {
90
+ ...state,
91
+ rulesByCategory: {
92
+ ...rulesByCategory,
93
+ [currentCat]: updatedRules
94
+ }
95
+ };
96
+ }
97
+
45
98
  case 'MOVE_RIGHT':
46
- return { ...state, activePane: Math.min(2, activePane + 1) };
99
+ if (activePane !== 1)
100
+ return { ...state, activePane: activePane + 1 };
101
+ return state;
47
102
 
48
103
  case 'MOVE_LEFT':
49
- return { ...state, activePane: Math.max(0, activePane - 1) };
104
+ if (activePane !== 0)
105
+ return { ...state, activePane: activePane - 1 };
106
+ return state;
50
107
 
51
108
  case 'MOVE_UP':
52
109
  if (activePane === 0) {
@@ -97,7 +154,6 @@ function getRuleStatus(ruleName, category, config) {
97
154
  if (config.rules) {
98
155
  let val = config.rules[ruleName];
99
156
 
100
- // Ignore the prefix to match format in --rules
101
157
  if (val === undefined) {
102
158
  const foundKey = Object.keys(config.rules).find(key => key.endsWith(`/${ruleName}`));
103
159
  if (foundKey) {
@@ -124,73 +180,56 @@ function stripJsonComments(json) {
124
180
  function loadRules() {
125
181
  let rulesData;
126
182
  let config = { rules: {}, categories: {} };
183
+ let configPath = null;
127
184
 
128
185
  try {
129
- const raw = execSync('npx oxlint --rules --format=json', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
186
+ const raw = execSync(`npx --yes oxlint@${OXLINT_VERSION} --rules --format=json`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
130
187
  rulesData = JSON.parse(raw);
131
188
  } catch (e) {
132
- console.error(`${COLORS.error}Error: Could not run 'npx oxlint'. Ensure oxlint is installed.${COLORS.reset}`);
189
+ console.error(`${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`);
133
190
  exit(1);
134
191
  }
135
192
 
136
193
  const userConfigPath = argv[2];
137
- let configPathToLoad = null;
138
-
139
194
  if (userConfigPath) {
140
195
  if (!fs.existsSync(userConfigPath)) {
141
196
  console.error(`${COLORS.error}Error: Config file '${userConfigPath}' not found.${COLORS.reset}`);
142
197
  exit(1);
143
198
  }
144
- configPathToLoad = userConfigPath;
199
+ configPath = userConfigPath;
145
200
  } else if (fs.existsSync('.oxlintrc.json')) {
146
- configPathToLoad = '.oxlintrc.json';
201
+ configPath = '.oxlintrc.json';
147
202
  }
148
203
 
149
- if (configPathToLoad) {
204
+ if (configPath) {
150
205
  try {
151
- const configFile = fs.readFileSync(configPathToLoad, 'utf8');
206
+ const configFile = fs.readFileSync(configPath, 'utf8');
152
207
  const cleanConfig = stripJsonComments(configFile);
153
208
  config = JSON.parse(cleanConfig);
154
-
155
209
  } catch (e) {
156
- console.error(`${COLORS.error}Error: Failed to parse '${configPathToLoad}'.${COLORS.reset}`);
157
- console.error(`${COLORS.warn}${e.message}${COLORS.reset}`);
210
+ console.error(`${COLORS.error}Error: Failed to parse '${configPath}'.${COLORS.reset}`);
158
211
  exit(1);
159
212
  }
160
213
  }
161
214
 
162
- try {
163
- const map = {};
164
-
165
- rulesData.forEach(rule => {
166
- const cat = rule.category || 'Uncategorized';
167
- if (!map[cat]) map[cat] = [];
168
-
169
- const status = getRuleStatus(rule.value, cat, config);
170
-
171
- map[cat].push({
172
- ...rule,
173
- configStatus: status,
174
- isActive: status === 'error' || status === 'warn'
175
- });
176
- });
177
-
178
- const categories = Object.keys(map).sort();
215
+ const map = {};
216
+ rulesData.forEach(rule => {
217
+ const cat = rule.category || 'Uncategorized';
218
+ if (!map[cat]) map[cat] = [];
219
+ const status = getRuleStatus(rule.value, cat, config);
220
+ map[cat].push({ ...rule, configStatus: status, isActive: status === 'error' || status === 'warn' });
221
+ });
179
222
 
180
- categories.forEach(c => {
181
- map[c].sort((a, b) => {
182
- if (a.isActive && !b.isActive) return -1;
183
- if (!a.isActive && b.isActive) return 1;
184
- return a.value.localeCompare(b.value);
185
- });
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);
186
229
  });
230
+ });
187
231
 
188
- return { categories, rulesByCategory: map };
189
- } catch (e) {
190
- console.error(`${COLORS.error}Error: Something went wrong processing rules.${COLORS.reset}`);
191
- console.error(e);
192
- exit(1);
193
- }
232
+ return { categories, rulesByCategory: map, config, configPath };
194
233
  }
195
234
 
196
235
  function updateScroll(idx, currentScroll, viewHeight) {
@@ -221,7 +260,6 @@ const exitAltScreen = () => write('\x1b[?1049l\x1b[?25h');
221
260
  function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollOffset, isActive) {
222
261
  const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
223
262
  const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + '…' : title;
224
-
225
263
  const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, '─');
226
264
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
227
265
 
@@ -231,23 +269,18 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
231
269
  buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
232
270
 
233
271
  const innerHeight = height - 2;
234
-
235
272
  items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
236
273
  const absIdx = scrollOffset + i;
237
274
  const rawText = (item.value || item).toString();
238
275
  let display = rawText.length > width - 4 ? rawText.substring(0, width - 5) + '…' : rawText.padEnd(width - 4);
239
-
240
276
  let itemColor = COLORS.dim;
241
277
  if (item.configStatus === 'error') itemColor = COLORS.error;
242
278
  else if (item.configStatus === 'warn') itemColor = COLORS.warn;
243
279
  else if (item.isActive) itemColor = COLORS.success;
244
280
 
245
281
  buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
246
-
247
282
  if (absIdx === selectedIdx) {
248
- buffer.push(isActive
249
- ? `${COLORS.selectedBg}${display}${COLORS.reset}`
250
- : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
283
+ buffer.push(isActive ? `${COLORS.selectedBg}${display}${COLORS.reset}` : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
251
284
  } else {
252
285
  buffer.push(`${itemColor}${display}${COLORS.reset}`);
253
286
  }
@@ -256,10 +289,8 @@ function drawBox(buffer, x, y, width, height, title, items, selectedIdx, scrollO
256
289
 
257
290
  function drawStats(buffer, x, y, width, height, rules) {
258
291
  const borderColor = COLORS.borderInactive;
259
-
260
292
  const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, '─');
261
293
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
262
-
263
294
  for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
264
295
  buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
265
296
 
@@ -287,10 +318,8 @@ function drawStats(buffer, x, y, width, height, rules) {
287
318
 
288
319
  function drawDetails(buffer, x, y, width, height, rule, isActive) {
289
320
  const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
290
-
291
321
  const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, '─');
292
322
  buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
293
-
294
323
  for (let i = 1; i < height - 1; i++) buffer.push(`\x1b[${y + i};${x}H${borderColor}│${' '.repeat(width - 2)}│${COLORS.reset}`);
295
324
  buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${'─'.repeat(width - 2)}┘${COLORS.reset}`);
296
325
 
@@ -314,18 +343,15 @@ function drawDetails(buffer, x, y, width, height, rule, isActive) {
314
343
 
315
344
  let line = 0;
316
345
  labels.forEach(([lbl, val]) => {
317
- if (lbl === 'Status') {
318
- if (line < height - 2) {
319
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
320
- line++;
321
- }
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}`);
348
+ line++;
322
349
  return;
323
350
  }
324
-
325
351
  const chunks = chunkString(String(val || 'N/A'), width - 15);
326
- chunks.forEach((chunk, i) => {
352
+ chunks.forEach((chunk) => {
327
353
  if (line < height - 2) {
328
- buffer.push(`\x1b[${y + 1 + line};${x + 2}H${i === 0 ? COLORS.highlight + lbl.padEnd(10) : ' '.repeat(10)} ${COLORS.reset}${chunk}`);
354
+ buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`);
329
355
  line++;
330
356
  }
331
357
  });
@@ -337,23 +363,21 @@ function render() {
337
363
  const currentCat = state.categories[state.selectedCatIdx];
338
364
  const rules = state.rulesByCategory[currentCat] || [];
339
365
  const rule = rules[state.selectedRuleIdx];
340
-
341
366
  const boxHeight = rows - 4;
342
-
343
367
  const col1W = Math.floor(columns * 0.2);
344
368
  const col2W = Math.floor(columns * 0.3);
345
369
  const col3W = columns - col1W - col2W - 2;
346
-
347
370
  const statsHeight = 6;
348
371
  const catListHeight = boxHeight - statsHeight;
349
372
 
350
373
  const buffer = ['\x1b[H\x1b[J'];
351
-
352
374
  drawBox(buffer, 1, 1, col1W, catListHeight, 'CATEGORIES', state.categories, state.selectedCatIdx, state.scrollCat, state.activePane === 0);
353
375
  drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
354
376
  drawBox(buffer, col1W + 1, 1, col2W, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIdx, state.scrollRule, state.activePane === 1);
355
377
  drawDetails(buffer, col1W + col2W + 1, 1, col3W, boxHeight, rule, state.activePane === 2);
356
- buffer.push(`\x1b[${rows - 2};2H${COLORS.dim}Nav: Arrows/HJKL | Enter: Docs | Q: Quit${COLORS.reset}`);
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}`);
357
381
  write(buffer.join(''));
358
382
  }
359
383
 
@@ -370,7 +394,7 @@ readline.emitKeypressEvents(stdin);
370
394
  if (stdin.isTTY) stdin.setRawMode(true);
371
395
 
372
396
  stdin.on('keypress', (_, key) => {
373
- const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : null);
397
+ const action = KEY_MAP[key.name] || (key.ctrl && key.name === 'c' ? { type: 'EXIT' } : (KEY_MAP[key.sequence] || null));
374
398
  if (!action) return;
375
399
 
376
400
  if (action.type === 'EXIT') {
@@ -378,7 +402,7 @@ stdin.on('keypress', (_, key) => {
378
402
  exit(0);
379
403
  }
380
404
 
381
- if (action.type === 'OPEN_DOCS') {
405
+ if (action.type === 'OPEN_DOCS' && state.activePane === 1) {
382
406
  const currentCat = state.categories[state.selectedCatIdx];
383
407
  const rule = state.rulesByCategory[currentCat]?.[state.selectedRuleIdx];
384
408
  if (rule) openUrl(rule.docs_url || rule.url);