oxlint-tui 1.0.18 → 1.1.0

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.
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
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
- ![oxlint-tui-demo](https://github.com/user-attachments/assets/cefd05a6-8bda-4622-8adf-008402f856b4)
6
-
7
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.
8
6
 
9
7
  **NOTE**: At the moment, comments will be erased from your configuration file when adding or toggling rules.
@@ -16,10 +14,10 @@ Configuring linters often involves jumping between your editor, a massive JSON f
16
14
 
17
15
  ## Features
18
16
 
19
- * **Config Aware**: provides information about the rules used in your project by loading `.oxlintrc.json`.
20
- * **Details**: View category, scope, fix, default and type-aware rule parameters at a glance.
21
- * **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
22
- * **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
17
+ - **Config Aware**: provides information about the rules used in your project by loading `.oxlintrc.json`.
18
+ - **Details**: View category, scope, fix, default and type-aware rule parameters at a glance.
19
+ - **View Docs**: Press <kbd>ENTER</kbd> on any rule to open its official documentation in your browser.
20
+ - **Zero Dependencies**: Written in pure Node.js without any heavy TUI libraries.
23
21
 
24
22
  ## Usage
25
23
 
@@ -51,28 +49,28 @@ oxlint-tui
51
49
 
52
50
  ## Keyboard Controls
53
51
 
54
- | Key | Action |
55
- | :--- | :--- |
56
- | **↑** / **k** | Move selection Up |
57
- | **↓** / **j** | Move selection Down |
58
- | **←** / **h** | Move focus Left (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
- | **x** | Run "oxlint" with the selected rule |
64
- | **r** | Run "oxlint with all enabled rules |
65
- | **Enter** | Open Rule Documentation in Browser |
66
- | **q** / **Esc** | Quit |
52
+ | Key | Action |
53
+ | :-------------- | :-------------------------------------- | --- |
54
+ | **↑** / **k** | Move selection Up |
55
+ | **↓** / **j** | Move selection Down |
56
+ | **←** / **h** | Move focus Left (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
+ | **x** | Run "oxlint" with the selected rule |
62
+ | **r** | Run "oxlint with all enabled rules |
63
+ | **Enter** | Open Rule Documentation in Browser |
64
+ | **q** / **Esc** | Quit |
67
65
 
68
66
  ## Requirements
69
67
 
70
- * Node.js >= 16
71
- * `oxlint` (The tool runs `npx oxlint --rules --format=json` internally to fetch definitions)
68
+ - Node.js >= 16
69
+ - `oxlint` (The tool runs `npx oxlint --rules --format=json` internally to fetch definitions)
72
70
 
73
71
  ## Roadmap
74
72
 
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.
73
+ 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.
76
74
 
77
75
  If you're willing and able, please feel free to [contribute to this project](https://github.com/holoflash/oxlint-tui/blob/main/CONTRIBUTING.md).
78
76
 
package/dist/index.js ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import readline from "readline";
4
+ import { execSync, exec, spawn } from "node:child_process";
5
+ import { stdout, stdin, exit, platform, argv } from "node:process";
6
+ import {} from "./types.js";
7
+ import { render } from "./rendering.js";
8
+ const OXLINT_VERSION = "1.41.0";
9
+ const TSGOLINT_VERSION = "0.11.1";
10
+ const KEY_MAP = {
11
+ k: { type: "MOVE_UP" },
12
+ up: { type: "MOVE_UP" },
13
+ down: { type: "MOVE_DOWN" },
14
+ j: { type: "MOVE_DOWN" },
15
+ left: { type: "MOVE_LEFT" },
16
+ h: { type: "MOVE_LEFT" },
17
+ right: { type: "MOVE_RIGHT" },
18
+ l: { type: "MOVE_RIGHT" },
19
+ return: { type: "OPEN_DOCS" },
20
+ enter: { type: "OPEN_DOCS" },
21
+ "1": { type: "SET_STATUS", value: "off" },
22
+ "2": { type: "SET_STATUS", value: "warn" },
23
+ "3": { type: "SET_STATUS", value: "error" },
24
+ q: { type: "EXIT" },
25
+ r: { type: "RUN_LINT" },
26
+ x: { type: "RUN_SINGLE_RULE" },
27
+ };
28
+ let state = {
29
+ activePane: 0,
30
+ selectedCategoryIndex: 0,
31
+ selectedRuleIndex: 0,
32
+ categoryScroll: 0,
33
+ ruleScroll: 0,
34
+ isLintInProgress: false,
35
+ message: "oxlint-tui",
36
+ messageType: "dim",
37
+ ...loadRules(),
38
+ };
39
+ function updateConfig(rule, newStatus) {
40
+ if (!state.configPath || !state.config)
41
+ return;
42
+ try {
43
+ if (!state.config.rules)
44
+ state.config.rules = {};
45
+ const ruleName = rule.value;
46
+ const canonicalKey = rule.scope === "oxc2" || rule.scope === "eslint" ? ruleName : `${rule.scope}/${ruleName}`;
47
+ const rules = state.config.rules;
48
+ const existingKey = Object.keys(rules).find((key) => key === canonicalKey || key === ruleName || key.endsWith(`/${ruleName}`));
49
+ const targetKey = existingKey || canonicalKey;
50
+ rules[targetKey] = newStatus;
51
+ fs.writeFileSync(state.configPath, JSON.stringify(state.config, null, 2), "utf8");
52
+ }
53
+ catch {
54
+ state.message = "Failed to write config file";
55
+ state.messageType = "error";
56
+ }
57
+ }
58
+ function runLint({ rule = null } = {}) {
59
+ if (state.isLintInProgress)
60
+ return;
61
+ state.isLintInProgress = true;
62
+ let ruleName = rule ? `${rule.scope}/${rule.value}` : null;
63
+ const typeAware = rule
64
+ ? rule.type_aware
65
+ : Object.values(state.rulesByCategory)
66
+ .flat()
67
+ .some((ruleItem) => ruleItem.isActive && ruleItem.type_aware === true);
68
+ state.message = "Linting";
69
+ if (ruleName)
70
+ state.message += ` [${ruleName}]`;
71
+ if (typeAware)
72
+ state.message += " with --type-aware";
73
+ state.message += "...";
74
+ state.messageType = "info";
75
+ render(state);
76
+ const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
77
+ const args = ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`];
78
+ if (typeAware) {
79
+ args.push("--package", `oxlint-tsgolint@${TSGOLINT_VERSION}`);
80
+ }
81
+ args.push("--", "oxlint");
82
+ if (typeAware) {
83
+ args.push("--type-aware");
84
+ }
85
+ if (ruleName) {
86
+ args.push("-A", "all", "-D", ruleName);
87
+ }
88
+ const child = spawn(npxCmd, args);
89
+ let stdoutData = "";
90
+ let stderrData = "";
91
+ child.stdout.on("data", (data) => {
92
+ stdoutData += data;
93
+ });
94
+ child.stderr.on("data", (data) => {
95
+ stderrData += data;
96
+ });
97
+ child.on("close", (code) => {
98
+ state.isLintInProgress = false;
99
+ const fullOutput = stdoutData + stderrData;
100
+ const summaryMatch = fullOutput.match(/Found (\d+) warnings? and (\d+) errors?/i);
101
+ if (summaryMatch) {
102
+ const errors = parseInt(summaryMatch[2]);
103
+ state.message = ruleName
104
+ ? `[${ruleName}] Found ${errors} issue${errors === 1 ? "" : "s"}`
105
+ : summaryMatch[0];
106
+ state.messageType = errors > 0 ? "error" : "warn";
107
+ }
108
+ else if (stdoutData.toLowerCase().includes("finished") ||
109
+ (code === 0 && stdoutData.length < 200)) {
110
+ state.message = "Linting passed! 0 issues found.";
111
+ if (ruleName)
112
+ state.message = `[${ruleName}] ${state.message}`;
113
+ state.messageType = "success";
114
+ }
115
+ else {
116
+ const cleanError = stderrData
117
+ .split("\n")
118
+ .filter((l) => !l.includes("experimental") && !l.includes("Breaking changes") && l.trim() !== "")
119
+ .join(" ");
120
+ state.message = cleanError ? `Error: ${cleanError.substring(0, 50)}...` : "Lint failed";
121
+ state.messageType = "error";
122
+ }
123
+ render(state);
124
+ });
125
+ }
126
+ function execute(action) {
127
+ if (!action)
128
+ return;
129
+ const { categories, rulesByCategory, selectedCategoryIndex, selectedRuleIndex, activePane } = state;
130
+ const currentCategory = categories[selectedCategoryIndex];
131
+ const currentCategoryRules = rulesByCategory[currentCategory] || [];
132
+ const viewportHeight = stdout.rows - 8;
133
+ const statsBoxHeight = 7;
134
+ const categoryListHeight = viewportHeight - statsBoxHeight;
135
+ switch (action.type) {
136
+ case "EXIT":
137
+ exitAltScreen();
138
+ exit(0);
139
+ return;
140
+ case "RUN_LINT":
141
+ runLint();
142
+ return;
143
+ case "RUN_SINGLE_RULE": {
144
+ const rule = currentCategoryRules[selectedRuleIndex];
145
+ if (rule)
146
+ runLint({ rule });
147
+ return;
148
+ }
149
+ case "OPEN_DOCS": {
150
+ if (activePane === 1) {
151
+ const rule = currentCategoryRules[selectedRuleIndex];
152
+ if (rule)
153
+ openUrl(rule.docs_url || rule.url);
154
+ }
155
+ return;
156
+ }
157
+ case "SET_STATUS": {
158
+ if (activePane !== 1 || !action.value)
159
+ return;
160
+ const rule = currentCategoryRules[selectedRuleIndex];
161
+ if (!rule)
162
+ return;
163
+ updateConfig(rule, action.value);
164
+ const updatedRules = [...currentCategoryRules];
165
+ updatedRules[selectedRuleIndex] = {
166
+ ...rule,
167
+ configStatus: action.value,
168
+ isActive: action.value === "error" || action.value === "warn",
169
+ };
170
+ state = {
171
+ ...state,
172
+ message: `Rule '${rule.value}' set to: ${action.value}`,
173
+ messageType: "info",
174
+ rulesByCategory: {
175
+ ...rulesByCategory,
176
+ [currentCategory]: updatedRules,
177
+ },
178
+ };
179
+ render(state);
180
+ return;
181
+ }
182
+ case "MOVE_RIGHT":
183
+ if (activePane !== 1) {
184
+ state = { ...state, activePane: activePane + 1 };
185
+ render(state);
186
+ }
187
+ return;
188
+ case "MOVE_LEFT":
189
+ if (activePane !== 0) {
190
+ state = { ...state, activePane: activePane - 1 };
191
+ render(state);
192
+ }
193
+ return;
194
+ case "MOVE_UP":
195
+ if (activePane === 0) {
196
+ const nextIndex = selectedCategoryIndex === 0 ? categories.length - 1 : selectedCategoryIndex - 1;
197
+ state = {
198
+ ...state,
199
+ selectedCategoryIndex: nextIndex,
200
+ selectedRuleIndex: 0,
201
+ ruleScroll: 0,
202
+ categoryScroll: updateScroll(nextIndex, state.categoryScroll, categoryListHeight),
203
+ };
204
+ }
205
+ else if (activePane === 1) {
206
+ const nextIndex = selectedRuleIndex === 0 ? currentCategoryRules.length - 1 : selectedRuleIndex - 1;
207
+ state = {
208
+ ...state,
209
+ selectedRuleIndex: nextIndex,
210
+ ruleScroll: updateScroll(nextIndex, state.ruleScroll, viewportHeight),
211
+ };
212
+ }
213
+ render(state);
214
+ return;
215
+ case "MOVE_DOWN":
216
+ if (activePane === 0) {
217
+ const nextIndex = selectedCategoryIndex === categories.length - 1 ? 0 : selectedCategoryIndex + 1;
218
+ state = {
219
+ ...state,
220
+ selectedCategoryIndex: nextIndex,
221
+ selectedRuleIndex: 0,
222
+ ruleScroll: 0,
223
+ categoryScroll: updateScroll(nextIndex, state.categoryScroll, categoryListHeight),
224
+ };
225
+ }
226
+ else if (activePane === 1) {
227
+ const nextIndex = selectedRuleIndex === currentCategoryRules.length - 1 ? 0 : selectedRuleIndex + 1;
228
+ state = {
229
+ ...state,
230
+ selectedRuleIndex: nextIndex,
231
+ ruleScroll: updateScroll(nextIndex, state.ruleScroll, viewportHeight),
232
+ };
233
+ }
234
+ render(state);
235
+ return;
236
+ }
237
+ }
238
+ function getRuleStatus(ruleName, category, config) {
239
+ if (config.rules) {
240
+ let val = config.rules[ruleName];
241
+ if (val === undefined) {
242
+ const foundKey = Object.keys(config.rules).find((key) => key.endsWith(`/${ruleName}`));
243
+ if (foundKey)
244
+ val = config.rules[foundKey];
245
+ }
246
+ if (val !== undefined) {
247
+ const status = Array.isArray(val) ? val[0] : val;
248
+ return status;
249
+ }
250
+ }
251
+ if (config.categories && config.categories[category]) {
252
+ return config.categories[category];
253
+ }
254
+ return "off";
255
+ }
256
+ function stripJsonComments(json) {
257
+ return json.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m));
258
+ }
259
+ function loadRules() {
260
+ let rulesData = [];
261
+ let config = {
262
+ rules: {},
263
+ categories: {},
264
+ };
265
+ let configPath = null;
266
+ try {
267
+ const raw = execSync(`npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`, {
268
+ encoding: "utf8",
269
+ stdio: ["ignore", "pipe", "ignore"],
270
+ });
271
+ rulesData = JSON.parse(raw);
272
+ }
273
+ catch {
274
+ state.message = "Error: Couldn't run 'npx oxlint'.";
275
+ state.messageType = "error";
276
+ exit(1);
277
+ }
278
+ const userConfigPath = argv[2];
279
+ if (userConfigPath && fs.existsSync(userConfigPath)) {
280
+ configPath = userConfigPath;
281
+ }
282
+ else if (fs.existsSync(".oxlintrc.json")) {
283
+ configPath = ".oxlintrc.json";
284
+ }
285
+ if (configPath) {
286
+ try {
287
+ config = JSON.parse(stripJsonComments(fs.readFileSync(configPath, "utf8")));
288
+ }
289
+ catch {
290
+ state.message = "Error: Couldn't parse config.";
291
+ state.messageType = "error";
292
+ }
293
+ }
294
+ const map = {};
295
+ rulesData.forEach((rule) => {
296
+ const cat = rule.category || "Uncategorized";
297
+ if (!map[cat])
298
+ map[cat] = [];
299
+ const status = getRuleStatus(rule.value, cat, config);
300
+ map[cat].push({
301
+ ...rule,
302
+ configStatus: status,
303
+ isActive: status === "error" || status === "warn",
304
+ });
305
+ });
306
+ const categories = Object.keys(map).toSorted();
307
+ return {
308
+ categories,
309
+ rulesByCategory: map,
310
+ config,
311
+ configPath,
312
+ };
313
+ }
314
+ function updateScroll(idx, currentScroll, viewHeight) {
315
+ if (idx < currentScroll)
316
+ return idx;
317
+ if (idx >= currentScroll + viewHeight)
318
+ return idx - viewHeight + 1;
319
+ return currentScroll;
320
+ }
321
+ function openUrl(url) {
322
+ if (!url)
323
+ return;
324
+ const openCmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
325
+ exec(`${openCmd} "${url}"`);
326
+ }
327
+ const write = (str) => stdout.write(str);
328
+ const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
329
+ const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
330
+ readline.emitKeypressEvents(stdin);
331
+ if (stdin.isTTY)
332
+ stdin.setRawMode(true);
333
+ stdin.on("keypress", (_, key) => {
334
+ const action = KEY_MAP[key.name] ||
335
+ (key.ctrl && key.name === "c" ? { type: "EXIT" } : KEY_MAP[key.sequence] || null);
336
+ execute(action);
337
+ });
338
+ stdout.on("resize", render);
339
+ enterAltScreen();
340
+ render(state);
@@ -0,0 +1,153 @@
1
+ import { stdout } from "node:process";
2
+ export const COLORS = {
3
+ reset: "\x1b[0m",
4
+ dim: "\x1b[90m",
5
+ highlight: "\x1b[38;5;110m",
6
+ selectedBg: "\x1b[47m\x1b[30m",
7
+ borderActive: "\x1b[36m",
8
+ borderInactive: "\x1b[90m",
9
+ error: "\x1b[31m",
10
+ warn: "\x1b[33m",
11
+ success: "\x1b[32m",
12
+ info: "\x1b[34m",
13
+ };
14
+ function chunkString(str, len) {
15
+ if (!str)
16
+ return [];
17
+ const size = Math.ceil(str.length / len);
18
+ const r = Array(size);
19
+ for (let i = 0; i < size; i++)
20
+ r[i] = str.substring(i * len, (i + 1) * len);
21
+ return r;
22
+ }
23
+ function drawBox(buffer, x, y, width, height, title, items, selectedIndex, scrollOffset, isActive) {
24
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
25
+ const titleClean = title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
26
+ const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(width + borderColor.length - 1, "─");
27
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
28
+ for (let i = 1; i < height - 1; i++) {
29
+ buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
30
+ }
31
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
32
+ const innerHeight = height - 2;
33
+ items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
34
+ const absoluteIndex = scrollOffset + i;
35
+ const isRule = typeof item !== "string";
36
+ const rawText = isRule ? item.value : item;
37
+ let display = rawText.length > width - 4
38
+ ? rawText.substring(0, width - 5) + "…"
39
+ : rawText.padEnd(width - 4);
40
+ let itemColor = COLORS.dim;
41
+ if (isRule) {
42
+ const ruleItem = item;
43
+ if (ruleItem.configStatus === "error")
44
+ itemColor = COLORS.error;
45
+ else if (ruleItem.configStatus === "warn")
46
+ itemColor = COLORS.warn;
47
+ else if (ruleItem.isActive)
48
+ itemColor = COLORS.success;
49
+ }
50
+ buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
51
+ if (absoluteIndex === selectedIndex) {
52
+ buffer.push(isActive
53
+ ? `${COLORS.selectedBg}${display}${COLORS.reset}`
54
+ : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`);
55
+ }
56
+ else {
57
+ buffer.push(`${itemColor}${display}${COLORS.reset}`);
58
+ }
59
+ });
60
+ }
61
+ function drawStats(buffer, x, y, width, height, rules) {
62
+ const borderColor = COLORS.borderInactive;
63
+ const topBorder = `${borderColor}┌─ STATS `.padEnd(width + borderColor.length - 1, "─");
64
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
65
+ for (let i = 1; i < height - 1; i++)
66
+ buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
67
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
68
+ let counts = { error: 0, warn: 0, off: 0 };
69
+ rules.forEach((ruleItem) => {
70
+ if (ruleItem.configStatus === "error")
71
+ counts.error++;
72
+ else if (ruleItem.configStatus === "warn")
73
+ counts.warn++;
74
+ else
75
+ counts.off++;
76
+ });
77
+ const lines = [
78
+ { label: "Error", count: counts.error, color: COLORS.error },
79
+ { label: "Warn", count: counts.warn, color: COLORS.warn },
80
+ { label: "Off", count: counts.off, color: COLORS.dim },
81
+ ];
82
+ lines.forEach((line, i) => {
83
+ if (i < height - 2) {
84
+ const numStr = String(line.count).padStart(3);
85
+ const labelStr = line.label.padEnd(width - 8);
86
+ buffer.push(`\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`);
87
+ }
88
+ });
89
+ }
90
+ function drawDetails(buffer, x, y, width, height, rule, isActive) {
91
+ const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
92
+ const topBorder = `${borderColor}┌─ DETAILS `.padEnd(width + borderColor.length - 1, "─");
93
+ buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
94
+ for (let i = 1; i < height - 1; i++)
95
+ buffer.push(`\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`);
96
+ buffer.push(`\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`);
97
+ if (!rule)
98
+ return;
99
+ let statusDisplay = rule.configStatus.toUpperCase();
100
+ if (rule.configStatus === "error")
101
+ statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
102
+ else if (rule.configStatus === "warn")
103
+ statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
104
+ else
105
+ statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
106
+ const labels = [
107
+ ["Name", rule.value],
108
+ ["Status", statusDisplay],
109
+ ["Category", rule.category],
110
+ ["Scope", rule.scope],
111
+ ["Fix", rule.fix || "N/A"],
112
+ ["Default", rule.default ? "Yes" : "No"],
113
+ ["Type-aware", rule.type_aware ? "Yes" : "No"],
114
+ ["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
115
+ ];
116
+ let line = 0;
117
+ labels.forEach(([lbl, val]) => {
118
+ if (lbl === "Status" && line < height - 2) {
119
+ buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`);
120
+ line++;
121
+ return;
122
+ }
123
+ const chunks = chunkString(String(val || "N/A"), width - 15);
124
+ chunks.forEach((chunk) => {
125
+ if (line < height - 2) {
126
+ buffer.push(`\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`);
127
+ line++;
128
+ }
129
+ });
130
+ });
131
+ }
132
+ export function render(state) {
133
+ const { columns = 80, rows = 24 } = stdout;
134
+ const currentCategory = state.categories[state.selectedCategoryIndex];
135
+ const rules = state.rulesByCategory[currentCategory] || [];
136
+ const rule = rules[state.selectedRuleIndex];
137
+ const boxHeight = rows - 5;
138
+ const categoriesColumnWidth = Math.floor(columns * 0.2);
139
+ const rulesColumnWidth = Math.floor(columns * 0.3);
140
+ const detailsColumnWidth = columns - categoriesColumnWidth - rulesColumnWidth - 2;
141
+ const statsHeight = 6;
142
+ const categoryListHeight = boxHeight - statsHeight;
143
+ const buffer = ["\x1b[H\x1b[J"];
144
+ drawBox(buffer, 1, 1, categoriesColumnWidth, categoryListHeight, "CATEGORIES", state.categories, state.selectedCategoryIndex, state.categoryScroll, state.activePane === 0);
145
+ drawStats(buffer, 1, 1 + categoryListHeight, categoriesColumnWidth, statsHeight, rules);
146
+ drawBox(buffer, categoriesColumnWidth + 1, 1, rulesColumnWidth, boxHeight, `RULES (${rules.length})`, rules, state.selectedRuleIndex, state.ruleScroll, state.activePane === 1);
147
+ drawDetails(buffer, categoriesColumnWidth + rulesColumnWidth + 1, 1, detailsColumnWidth, boxHeight, rule, state.activePane === 2);
148
+ const msgColor = COLORS[state.messageType] || COLORS.reset;
149
+ buffer.push(`\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`);
150
+ const footerConfig = state.configPath ? `Config: ${state.configPath}` : "No config loaded";
151
+ buffer.push(`\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | X: Run rule | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`);
152
+ stdout.write(buffer.join(""));
153
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,29 +1,44 @@
1
1
  {
2
2
  "name": "oxlint-tui",
3
- "version": "1.0.18",
3
+ "version": "1.1.0",
4
4
  "description": "A Node TUI Oxlint rules and configuration browser",
5
- "type": "module",
5
+ "keywords": [
6
+ "cli",
7
+ "configuration",
8
+ "lint",
9
+ "linter",
10
+ "oxlint",
11
+ "tui"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "holoflash",
6
15
  "bin": {
7
- "oxlint-rules-tui": "./tui.js"
16
+ "oxlint-tui": "dist/index.js"
8
17
  },
9
18
  "files": [
10
- "tui.js"
19
+ "dist"
11
20
  ],
21
+ "type": "module",
12
22
  "scripts": {
13
- "start": "node tui.js",
14
- "dev": "node tui.js .oxlintrc.example.json"
23
+ "prepare": "husky",
24
+ "dev": "tsx src/index.ts src/.oxlintrc.test.json",
25
+ "lint": "oxlint --type-aware --format=stylish",
26
+ "type-check": "tsgo --noEmit",
27
+ "format": "oxfmt",
28
+ "format-check": "oxfmt --check",
29
+ "build": "rm -rf dist && tsc",
30
+ "build-run": "npm run build && node dist/index.js src/.oxlintrc.test.json"
15
31
  },
16
- "keywords": [
17
- "oxlint",
18
- "lint",
19
- "tui",
20
- "cli"
21
- ],
22
- "dependencies": {
23
- "oxlint-tsgolint": "0.11.1"
32
+ "devDependencies": {
33
+ "@types/node": "25.0.10",
34
+ "@typescript/native-preview": "7.0.0-dev.20260124.1",
35
+ "husky": "9.1.7",
36
+ "oxfmt": "0.26.0",
37
+ "oxlint": "1.41.0",
38
+ "oxlint-tsgolint": "0.11.1",
39
+ "tsx": "4.21.0",
40
+ "typescript": "5.9.3"
24
41
  },
25
- "author": "holoflash",
26
- "license": "MIT",
27
42
  "engines": {
28
43
  "node": ">=16"
29
44
  }
package/tui.js DELETED
@@ -1,640 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { execSync, exec, spawn } 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
-
8
- const OXLINT_VERSION = "1.41.0";
9
- const TSGOLINT_VERSION = "0.11.1";
10
-
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",
22
- };
23
-
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
- x: { type: "RUN_SINGLE_RULE" },
41
- };
42
-
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(),
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({ typeAware = false, rule = null } = {}) {
85
- if (state.isLinting) return;
86
-
87
- state.isLinting = true;
88
-
89
- let ruleName = rule ? `${rule.scope}/${rule.value}` : null;
90
- typeAware = typeAware || rule?.type_aware;
91
-
92
- state.message = "Linting";
93
- if (ruleName) state.message += ` [${ruleName}]`;
94
- if (typeAware) state.message += " with --type-aware";
95
- state.message += "...";
96
-
97
- state.messageType = "info";
98
-
99
- render();
100
-
101
- const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
102
-
103
- const args = ["-q", "--yes", "--package", `oxlint@${OXLINT_VERSION}`];
104
-
105
- if (typeAware) {
106
- args.push("--package", `oxlint-tsgolint@${TSGOLINT_VERSION}`);
107
- }
108
-
109
- args.push("--", "oxlint");
110
-
111
- if (typeAware) {
112
- args.push("--type-aware");
113
- }
114
-
115
- if (ruleName) {
116
- args.push("-A", "all", "-D", ruleName);
117
- }
118
-
119
- const child = spawn(npxCmd, args);
120
-
121
- let stdoutData = "";
122
- let stderrData = "";
123
-
124
- child.stdout.on("data", (data) => {
125
- stdoutData += data;
126
- });
127
- child.stderr.on("data", (data) => {
128
- stderrData += data;
129
- });
130
-
131
- child.on("close", (code) => {
132
- state.isLinting = false;
133
-
134
- const fullOutput = stdoutData + stderrData;
135
- const summaryMatch = fullOutput.match(
136
- /Found (\d+) warnings? and (\d+) errors?/i,
137
- );
138
-
139
- if (summaryMatch) {
140
- const errors = parseInt(summaryMatch[2]);
141
- state.message = ruleName
142
- ? `[${ruleName}] Found ${errors} issue${errors === 1 ? "" : "s"}`
143
- : (state.message = summaryMatch[0]);
144
- state.messageType = errors > 0 ? "error" : "warn";
145
- } else if (
146
- stdoutData.toLowerCase().includes("finished") ||
147
- (code === 0 && stdoutData.length < 200)
148
- ) {
149
- state.message = "Linting passed! 0 issues found.";
150
- if (ruleName) state.message = `[${ruleName}] ${state.message}`;
151
- state.messageType = "success";
152
- } else {
153
- const cleanError = stderrData
154
- .split("\n")
155
- .filter(
156
- (l) =>
157
- !l.includes("experimental") &&
158
- !l.includes("Breaking changes") &&
159
- l.trim() !== "",
160
- )
161
- .join(" ");
162
-
163
- state.message = cleanError
164
- ? `Error: ${cleanError.substring(0, 50)}...`
165
- : "Lint failed";
166
- state.messageType = "error";
167
- }
168
- render();
169
- });
170
- }
171
-
172
- function execute(action) {
173
- if (!action) return;
174
-
175
- const {
176
- categories,
177
- rulesByCategory,
178
- selectedCatIdx,
179
- selectedRuleIdx,
180
- activePane,
181
- } = state;
182
- const currentCat = categories[selectedCatIdx];
183
- const currentRules = rulesByCategory[currentCat] || [];
184
- const viewHeight = stdout.rows - 8;
185
- const statsHeight = 7;
186
- const catViewHeight = viewHeight - statsHeight;
187
-
188
- switch (action.type) {
189
- case "EXIT":
190
- exitAltScreen();
191
- exit(0);
192
- return;
193
-
194
- case "RUN_LINT":
195
- const allRules = Object.values(state.rulesByCategory).flat();
196
- const hasActiveTypeAwareRule = allRules.some(
197
- (rule) => rule.isActive && rule.type_aware === true
198
- );
199
-
200
- runLint({ typeAware: hasActiveTypeAwareRule });
201
- return;
202
-
203
- case "RUN_SINGLE_RULE": {
204
- const rule = currentRules[selectedRuleIdx];
205
- if (rule) runLint({ rule });
206
- return;
207
- }
208
-
209
- case "OPEN_DOCS": {
210
- if (activePane === 1) {
211
- const rule = currentRules[selectedRuleIdx];
212
- if (rule) openUrl(rule.docs_url || rule.url);
213
- }
214
- return;
215
- }
216
-
217
- case "SET_STATUS": {
218
- if (activePane !== 1) return;
219
- const rule = currentRules[selectedRuleIdx];
220
- if (!rule) return;
221
- updateConfig(rule, action.value);
222
- const updatedRules = [...currentRules];
223
- updatedRules[selectedRuleIdx] = {
224
- ...rule,
225
- configStatus: action.value,
226
- isActive: action.value === "error" || action.value === "warn",
227
- };
228
- state = {
229
- ...state,
230
- message: `Rule '${rule.value}' set to: ${action.value}`,
231
- messageType: "info",
232
- rulesByCategory: {
233
- ...rulesByCategory,
234
- [currentCat]: updatedRules,
235
- },
236
- };
237
- render();
238
- return;
239
- }
240
-
241
- case "MOVE_RIGHT":
242
- if (activePane !== 1) {
243
- state = { ...state, activePane: activePane + 1 };
244
- render();
245
- }
246
- return;
247
-
248
- case "MOVE_LEFT":
249
- if (activePane !== 0) {
250
- state = { ...state, activePane: activePane - 1 };
251
- render();
252
- }
253
- return;
254
-
255
- case "MOVE_UP":
256
- if (activePane === 0) {
257
- const nextIdx =
258
- selectedCatIdx === 0 ? categories.length - 1 : selectedCatIdx - 1;
259
- state = {
260
- ...state,
261
- selectedCatIdx: nextIdx,
262
- selectedRuleIdx: 0,
263
- scrollRule: 0,
264
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
265
- };
266
- } else if (activePane === 1) {
267
- const nextIdx =
268
- selectedRuleIdx === 0 ? currentRules.length - 1 : selectedRuleIdx - 1;
269
- state = {
270
- ...state,
271
- selectedRuleIdx: nextIdx,
272
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
273
- };
274
- }
275
- render();
276
- return;
277
-
278
- case "MOVE_DOWN":
279
- if (activePane === 0) {
280
- const nextIdx =
281
- selectedCatIdx === categories.length - 1 ? 0 : selectedCatIdx + 1;
282
- state = {
283
- ...state,
284
- selectedCatIdx: nextIdx,
285
- selectedRuleIdx: 0,
286
- scrollRule: 0,
287
- scrollCat: updateScroll(nextIdx, state.scrollCat, catViewHeight),
288
- };
289
- } else if (activePane === 1) {
290
- const nextIdx =
291
- selectedRuleIdx === currentRules.length - 1 ? 0 : selectedRuleIdx + 1;
292
- state = {
293
- ...state,
294
- selectedRuleIdx: nextIdx,
295
- scrollRule: updateScroll(nextIdx, state.scrollRule, viewHeight),
296
- };
297
- }
298
- render();
299
- return;
300
- }
301
- }
302
-
303
- function getRuleStatus(ruleName, category, config) {
304
- if (config.rules) {
305
- let val = config.rules[ruleName];
306
- if (val === undefined) {
307
- const foundKey = Object.keys(config.rules).find((key) =>
308
- key.endsWith(`/${ruleName}`),
309
- );
310
- if (foundKey) val = config.rules[foundKey];
311
- }
312
- if (val !== undefined) return Array.isArray(val) ? val[0] : val;
313
- }
314
- return (config.categories && config.categories[category]) || "off";
315
- }
316
-
317
- function stripJsonComments(json) {
318
- return json.replace(
319
- /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g,
320
- (m, g) => (g ? "" : m),
321
- );
322
- }
323
-
324
- function loadRules() {
325
- let rulesData;
326
- let config = {
327
- rules: {},
328
- categories: {},
329
- };
330
- let configPath = null;
331
-
332
- try {
333
- const raw = execSync(
334
- `npx -q --yes oxlint@${OXLINT_VERSION} --rules --format=json`,
335
- {
336
- encoding: "utf8",
337
- stdio: ["ignore", "pipe", "ignore"],
338
- },
339
- );
340
- rulesData = JSON.parse(raw);
341
- } catch (e) {
342
- console.error(
343
- `${COLORS.error}Error: Could not run 'npx oxlint'.${COLORS.reset}`,
344
- );
345
- exit(1);
346
- }
347
-
348
- const userConfigPath = argv[2];
349
- if (userConfigPath && fs.existsSync(userConfigPath)) {
350
- configPath = userConfigPath;
351
- } else if (fs.existsSync(".oxlintrc.json")) {
352
- configPath = ".oxlintrc.json";
353
- }
354
-
355
- if (configPath) {
356
- try {
357
- config = JSON.parse(
358
- stripJsonComments(fs.readFileSync(configPath, "utf8")),
359
- );
360
- } catch (e) { }
361
- }
362
-
363
- const map = {};
364
- rulesData.forEach((rule) => {
365
- const cat = rule.category || "Uncategorized";
366
- if (!map[cat]) map[cat] = [];
367
- const status = getRuleStatus(rule.value, cat, config);
368
- map[cat].push({
369
- ...rule,
370
- configStatus: status,
371
- isActive: status === "error" || status === "warn",
372
- });
373
- });
374
-
375
- const categories = Object.keys(map).sort();
376
- return {
377
- categories,
378
- rulesByCategory: map,
379
- config,
380
- configPath,
381
- };
382
- }
383
-
384
- function updateScroll(idx, currentScroll, viewHeight) {
385
- if (idx < currentScroll) return idx;
386
- if (idx >= currentScroll + viewHeight) return idx - viewHeight + 1;
387
- return currentScroll;
388
- }
389
-
390
- function openUrl(url) {
391
- if (!url) return;
392
- const cmd =
393
- platform === "darwin"
394
- ? "open"
395
- : platform === "win32"
396
- ? "start"
397
- : "xdg-open";
398
- exec(`${cmd} "${url}"`);
399
- }
400
-
401
- function chunkString(str, len) {
402
- if (!str) return [];
403
- const size = Math.ceil(str.length / len);
404
- const r = Array(size);
405
- for (let i = 0; i < size; i++) r[i] = str.substring(i * len, (i + 1) * len);
406
- return r;
407
- }
408
-
409
- const write = (str) => stdout.write(str);
410
- const enterAltScreen = () => write("\x1b[?1049h\x1b[?25l");
411
- const exitAltScreen = () => write("\x1b[?1049l\x1b[?25h");
412
-
413
- function drawBox(
414
- buffer,
415
- x,
416
- y,
417
- width,
418
- height,
419
- title,
420
- items,
421
- selectedIdx,
422
- scrollOffset,
423
- isActive,
424
- ) {
425
- const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
426
- const titleClean =
427
- title.length > width - 6 ? title.substring(0, width - 7) + "…" : title;
428
- const topBorder = `${borderColor}┌─ ${titleClean} `.padEnd(
429
- width + borderColor.length - 1,
430
- "─",
431
- );
432
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
433
-
434
- for (let i = 1; i < height - 1; i++) {
435
- buffer.push(
436
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
437
- );
438
- }
439
- buffer.push(
440
- `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
441
- );
442
-
443
- const innerHeight = height - 2;
444
- items.slice(scrollOffset, scrollOffset + innerHeight).forEach((item, i) => {
445
- const absIdx = scrollOffset + i;
446
- const rawText = (item.value || item).toString();
447
- let display =
448
- rawText.length > width - 4
449
- ? rawText.substring(0, width - 5) + "…"
450
- : rawText.padEnd(width - 4);
451
- let itemColor = COLORS.dim;
452
- if (item.configStatus === "error") itemColor = COLORS.error;
453
- else if (item.configStatus === "warn") itemColor = COLORS.warn;
454
- else if (item.isActive) itemColor = COLORS.success;
455
-
456
- buffer.push(`\x1b[${y + 1 + i};${x + 2}H`);
457
- if (absIdx === selectedIdx) {
458
- buffer.push(
459
- isActive
460
- ? `${COLORS.selectedBg}${display}${COLORS.reset}`
461
- : `${COLORS.dim}\x1b[7m${display}${COLORS.reset}`,
462
- );
463
- } else {
464
- buffer.push(`${itemColor}${display}${COLORS.reset}`);
465
- }
466
- });
467
- }
468
-
469
- function drawStats(buffer, x, y, width, height, rules) {
470
- const borderColor = COLORS.borderInactive;
471
- const topBorder = `${borderColor}┌─ STATS `.padEnd(
472
- width + borderColor.length - 1,
473
- "─",
474
- );
475
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
476
- for (let i = 1; i < height - 1; i++)
477
- buffer.push(
478
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
479
- );
480
- buffer.push(
481
- `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
482
- );
483
-
484
- let counts = { error: 0, warn: 0, off: 0 };
485
- rules.forEach((r) => {
486
- if (r.configStatus === "error") counts.error++;
487
- else if (r.configStatus === "warn") counts.warn++;
488
- else counts.off++;
489
- });
490
-
491
- const lines = [
492
- { label: "Error", count: counts.error, color: COLORS.error },
493
- { label: "Warn", count: counts.warn, color: COLORS.warn },
494
- { label: "Off", count: counts.off, color: COLORS.dim },
495
- ];
496
-
497
- lines.forEach((line, i) => {
498
- if (i < height - 2) {
499
- const numStr = String(line.count).padStart(3);
500
- const labelStr = line.label.padEnd(width - 8);
501
- buffer.push(
502
- `\x1b[${y + 1 + i};${x + 2}H${line.color}${labelStr}${numStr}${COLORS.reset}`,
503
- );
504
- }
505
- });
506
- }
507
-
508
- function drawDetails(buffer, x, y, width, height, rule, isActive) {
509
- const borderColor = isActive ? COLORS.borderActive : COLORS.borderInactive;
510
- const topBorder = `${borderColor}┌─ DETAILS `.padEnd(
511
- width + borderColor.length - 1,
512
- "─",
513
- );
514
- buffer.push(`\x1b[${y};${x}H${topBorder}┐${COLORS.reset}`);
515
- for (let i = 1; i < height - 1; i++)
516
- buffer.push(
517
- `\x1b[${y + i};${x}H${borderColor}│${" ".repeat(width - 2)}│${COLORS.reset}`,
518
- );
519
- buffer.push(
520
- `\x1b[${y + height - 1};${x}H${borderColor}└${"─".repeat(width - 2)}┘${COLORS.reset}`,
521
- );
522
-
523
- if (!rule) return;
524
-
525
- let statusDisplay = rule.configStatus.toUpperCase();
526
- if (rule.configStatus === "error")
527
- statusDisplay = `${COLORS.error}${statusDisplay}${COLORS.reset}`;
528
- else if (rule.configStatus === "warn")
529
- statusDisplay = `${COLORS.warn}${statusDisplay}${COLORS.reset}`;
530
- else statusDisplay = `${COLORS.dim}${statusDisplay}${COLORS.reset}`;
531
-
532
- const labels = [
533
- ["Name", rule.value],
534
- ["Status", statusDisplay],
535
- ["Category", rule.category],
536
- ["Scope", rule.scope],
537
- ["Fix", rule.fix],
538
- ["Default", rule.default ? "Yes" : "No"],
539
- ["Type-aware", rule.type_aware ? "Yes" : "No"],
540
- ["Docs", `Hit ${COLORS.highlight}ENTER${COLORS.reset} to open docs`],
541
- ];
542
-
543
- let line = 0;
544
- labels.forEach(([lbl, val]) => {
545
- if (lbl === "Status" && line < height - 2) {
546
- buffer.push(
547
- `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${val}`,
548
- );
549
- line++;
550
- return;
551
- }
552
- const chunks = chunkString(String(val || "N/A"), width - 15);
553
- chunks.forEach((chunk) => {
554
- if (line < height - 2) {
555
- buffer.push(
556
- `\x1b[${y + 1 + line};${x + 2}H${COLORS.highlight}${lbl.padEnd(10)} ${COLORS.reset}${chunk}`,
557
- );
558
- line++;
559
- }
560
- });
561
- });
562
- }
563
-
564
- function render() {
565
- const { columns, rows } = stdout;
566
- const currentCat = state.categories[state.selectedCatIdx];
567
- const rules = state.rulesByCategory[currentCat] || [];
568
- const rule = rules[state.selectedRuleIdx];
569
- const boxHeight = rows - 5;
570
- const col1W = Math.floor(columns * 0.2);
571
- const col2W = Math.floor(columns * 0.3);
572
- const col3W = columns - col1W - col2W - 2;
573
- const statsHeight = 6;
574
- const catListHeight = boxHeight - statsHeight;
575
-
576
- const buffer = ["\x1b[H\x1b[J"];
577
- drawBox(
578
- buffer,
579
- 1,
580
- 1,
581
- col1W,
582
- catListHeight,
583
- "CATEGORIES",
584
- state.categories,
585
- state.selectedCatIdx,
586
- state.scrollCat,
587
- state.activePane === 0,
588
- );
589
- drawStats(buffer, 1, 1 + catListHeight, col1W, statsHeight, rules);
590
- drawBox(
591
- buffer,
592
- col1W + 1,
593
- 1,
594
- col2W,
595
- boxHeight,
596
- `RULES (${rules.length})`,
597
- rules,
598
- state.selectedRuleIdx,
599
- state.scrollRule,
600
- state.activePane === 1,
601
- );
602
- drawDetails(
603
- buffer,
604
- col1W + col2W + 1,
605
- 1,
606
- col3W,
607
- boxHeight,
608
- rule,
609
- state.activePane === 2,
610
- );
611
-
612
- const msgColor = COLORS[state.messageType] || COLORS.reset;
613
- buffer.push(
614
- `\x1b[${rows - 3};2H${msgColor}● ${state.message}${COLORS.reset}`,
615
- );
616
-
617
- const footerConfig = state.configPath
618
- ? `Config: ${state.configPath}`
619
- : "No config loaded";
620
- buffer.push(
621
- `\x1b[${rows - 1};2H${COLORS.dim}Arrows/HJKL: Nav | 1-3: Status | R: Lint | X: Run rule | Enter: Docs | Q: Quit | ${footerConfig}${COLORS.reset}`,
622
- );
623
- write(buffer.join(""));
624
- }
625
-
626
- readline.emitKeypressEvents(stdin);
627
- if (stdin.isTTY) stdin.setRawMode(true);
628
-
629
- stdin.on("keypress", (_, key) => {
630
- const action =
631
- KEY_MAP[key.name] ||
632
- (key.ctrl && key.name === "c"
633
- ? { type: "EXIT" }
634
- : KEY_MAP[key.sequence] || null);
635
- execute(action);
636
- });
637
-
638
- stdout.on("resize", render);
639
- enterAltScreen();
640
- render();