oxlint-tui 1.0.13 → 1.0.15

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