@nebulord/sickbay 0.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 +231 -0
- package/dist/DiffApp-YF2PYQZK.js +187 -0
- package/dist/DoctorApp-U465IMK7.js +447 -0
- package/dist/FixApp-RJPCWNXJ.js +344 -0
- package/dist/StatsApp-BI6COY7S.js +375 -0
- package/dist/TrendApp-XL77HKDR.js +118 -0
- package/dist/TuiApp-VJNV4FD3.js +982 -0
- package/dist/ai-7DGOLNJX.js +64 -0
- package/dist/badge-KQ73KEIN.js +41 -0
- package/dist/chunk-5KJOYSVJ.js +95 -0
- package/dist/chunk-BIK4EL4H.js +19 -0
- package/dist/chunk-BUD5BE6U.js +61 -0
- package/dist/chunk-D24FSOW4.js +22 -0
- package/dist/chunk-POUHUMJN.js +21 -0
- package/dist/chunk-SSUXSMGH.js +25 -0
- package/dist/history-DYFJ65XH.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +535 -0
- package/dist/init-J2NPRPDO.js +54 -0
- package/dist/resolve-package-PHPJWOLY.js +8 -0
- package/dist/web-EE2VYPEX.js +198 -0
- package/package.json +58 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LOADING_MESSAGES
|
|
3
|
+
} from "./chunk-POUHUMJN.js";
|
|
4
|
+
import {
|
|
5
|
+
sparkline,
|
|
6
|
+
trendArrow
|
|
7
|
+
} from "./chunk-SSUXSMGH.js";
|
|
8
|
+
import {
|
|
9
|
+
loadHistory
|
|
10
|
+
} from "./chunk-5KJOYSVJ.js";
|
|
11
|
+
|
|
12
|
+
// src/components/tui/TuiApp.tsx
|
|
13
|
+
import React11, { useState as useState8, useEffect as useEffect7, useCallback as useCallback2, useRef as useRef5 } from "react";
|
|
14
|
+
import { Box as Box11, Text as Text11, useInput } from "ink";
|
|
15
|
+
|
|
16
|
+
// src/components/tui/PanelBorder.tsx
|
|
17
|
+
import React from "react";
|
|
18
|
+
import { Box, Text } from "ink";
|
|
19
|
+
function PanelBorder({ title, color, focused, visible = true, flash, children }) {
|
|
20
|
+
const borderColor = flash ?? (focused ? color : "gray");
|
|
21
|
+
const borderStyle = flash ? "double" : focused ? "double" : "single";
|
|
22
|
+
return /* @__PURE__ */ React.createElement(
|
|
23
|
+
Box,
|
|
24
|
+
{
|
|
25
|
+
flexDirection: "column",
|
|
26
|
+
borderStyle,
|
|
27
|
+
borderColor,
|
|
28
|
+
paddingX: 1,
|
|
29
|
+
flexGrow: 1
|
|
30
|
+
},
|
|
31
|
+
/* @__PURE__ */ React.createElement(Text, { bold: true, color: flash ?? color }, title),
|
|
32
|
+
visible ? children : /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\xB7\xB7\xB7")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/components/tui/HotkeyBar.tsx
|
|
37
|
+
import React2 from "react";
|
|
38
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
39
|
+
var HOTKEYS = [
|
|
40
|
+
{ key: "h", label: "health", panel: "health" },
|
|
41
|
+
{ key: "g", label: "git", panel: "git" },
|
|
42
|
+
{ key: "t", label: "trend", panel: "trend" },
|
|
43
|
+
{ key: "q", label: "quick wins", panel: "quickwins" },
|
|
44
|
+
{ key: "a", label: "activity", panel: "activity" },
|
|
45
|
+
{ key: "r", label: "re-run" },
|
|
46
|
+
{ key: "w", label: "web" },
|
|
47
|
+
{ key: "W", label: "web+AI" },
|
|
48
|
+
{ key: "?", label: "help" }
|
|
49
|
+
];
|
|
50
|
+
function HotkeyBar({ activePanel }) {
|
|
51
|
+
return /* @__PURE__ */ React2.createElement(Box2, null, HOTKEYS.map((hk) => {
|
|
52
|
+
const isActive = hk.panel !== void 0 && hk.panel === activePanel;
|
|
53
|
+
return /* @__PURE__ */ React2.createElement(Box2, { key: hk.key, marginRight: 2 }, /* @__PURE__ */ React2.createElement(Text2, { bold: isActive, color: isActive ? "cyan" : void 0 }, "[", hk.key, "]"), /* @__PURE__ */ React2.createElement(Text2, { bold: isActive, color: isActive ? "cyan" : "gray" }, " ", hk.label));
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/components/tui/HealthPanel.tsx
|
|
58
|
+
import React3, { useState, useEffect } from "react";
|
|
59
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
60
|
+
function scoreColor(score) {
|
|
61
|
+
if (score >= 80) return "green";
|
|
62
|
+
if (score >= 60) return "yellow";
|
|
63
|
+
return "red";
|
|
64
|
+
}
|
|
65
|
+
function scoreBar(score, width = 10) {
|
|
66
|
+
const filled = Math.round(score / 100 * width);
|
|
67
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
68
|
+
}
|
|
69
|
+
function truncate(str, maxLen) {
|
|
70
|
+
return str.length > maxLen ? str.slice(0, maxLen - 1) + "\u2026" : str;
|
|
71
|
+
}
|
|
72
|
+
function statusIcon(status) {
|
|
73
|
+
switch (status) {
|
|
74
|
+
case "pass":
|
|
75
|
+
return "\u2713";
|
|
76
|
+
case "fail":
|
|
77
|
+
return "\u2717";
|
|
78
|
+
case "warning":
|
|
79
|
+
return "\u26A0";
|
|
80
|
+
case "skipped":
|
|
81
|
+
return "\u25CB";
|
|
82
|
+
default:
|
|
83
|
+
return "?";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function HealthPanel({
|
|
87
|
+
checks,
|
|
88
|
+
isScanning,
|
|
89
|
+
progress,
|
|
90
|
+
scrollOffset,
|
|
91
|
+
availableHeight
|
|
92
|
+
}) {
|
|
93
|
+
const [msgIdx, setMsgIdx] = useState(0);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!isScanning || progress.length > 0) return;
|
|
96
|
+
const id = setInterval(() => {
|
|
97
|
+
setMsgIdx((i) => (i + 1) % LOADING_MESSAGES.length);
|
|
98
|
+
}, 4e3);
|
|
99
|
+
return () => clearInterval(id);
|
|
100
|
+
}, [isScanning, progress.length]);
|
|
101
|
+
if (isScanning && checks.length === 0) {
|
|
102
|
+
if (progress.length === 0) {
|
|
103
|
+
return /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, LOADING_MESSAGES[msgIdx]));
|
|
104
|
+
}
|
|
105
|
+
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, progress.map((p) => /* @__PURE__ */ React3.createElement(Box3, { key: p.name }, /* @__PURE__ */ React3.createElement(
|
|
106
|
+
Text3,
|
|
107
|
+
{
|
|
108
|
+
color: p.status === "done" ? "green" : p.status === "running" ? "yellow" : "gray"
|
|
109
|
+
},
|
|
110
|
+
p.status === "done" ? "\u2713" : p.status === "running" ? "\u25CC" : "\u25CB"
|
|
111
|
+
), /* @__PURE__ */ React3.createElement(Text3, null, " ", p.name))));
|
|
112
|
+
}
|
|
113
|
+
if (checks.length === 0) {
|
|
114
|
+
return /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "No results yet. Press [r] to scan."));
|
|
115
|
+
}
|
|
116
|
+
const maxVisible = Math.max(1, availableHeight);
|
|
117
|
+
const visible = checks.slice(scrollOffset, scrollOffset + maxVisible);
|
|
118
|
+
const hasMore = scrollOffset + maxVisible < checks.length;
|
|
119
|
+
const hasLess = scrollOffset > 0;
|
|
120
|
+
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, hasLess && /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " \\u25B2 ", scrollOffset, " more above"), visible.map((check) => /* @__PURE__ */ React3.createElement(Box3, { key: check.id }, /* @__PURE__ */ React3.createElement(
|
|
121
|
+
Text3,
|
|
122
|
+
{
|
|
123
|
+
color: check.status === "pass" ? "green" : check.status === "fail" ? "red" : "yellow"
|
|
124
|
+
},
|
|
125
|
+
statusIcon(check.status)
|
|
126
|
+
), /* @__PURE__ */ React3.createElement(Text3, null, " "), /* @__PURE__ */ React3.createElement(Box3, { width: 26 }, /* @__PURE__ */ React3.createElement(Text3, { wrap: "truncate" }, truncate(check.name, 26))), /* @__PURE__ */ React3.createElement(Text3, null, " "), /* @__PURE__ */ React3.createElement(Text3, { color: scoreColor(check.score) }, scoreBar(check.score)), /* @__PURE__ */ React3.createElement(Text3, { color: scoreColor(check.score) }, " ", String(check.score).padStart(3)))), hasMore && /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " \u25BC ", checks.length - scrollOffset - maxVisible, " more below"), isScanning && /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, " \u25CC Re-scanning..."));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/components/tui/ScorePanel.tsx
|
|
130
|
+
import React4, { useState as useState2, useEffect as useEffect2, useRef } from "react";
|
|
131
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
132
|
+
function scoreColor2(score) {
|
|
133
|
+
if (score >= 80) return "green";
|
|
134
|
+
if (score >= 60) return "yellow";
|
|
135
|
+
return "red";
|
|
136
|
+
}
|
|
137
|
+
function scoreBar2(score, width = 15) {
|
|
138
|
+
const filled = Math.round(score / 100 * width);
|
|
139
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
140
|
+
}
|
|
141
|
+
function ScorePanel({ report, previousScore, animate = true }) {
|
|
142
|
+
const [animatedScore, setAnimatedScore] = useState2(0);
|
|
143
|
+
const prevTargetRef = useRef(0);
|
|
144
|
+
useEffect2(() => {
|
|
145
|
+
if (!animate) return;
|
|
146
|
+
if (!report) {
|
|
147
|
+
setAnimatedScore(0);
|
|
148
|
+
prevTargetRef.current = 0;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const target = report.overallScore;
|
|
152
|
+
const start = prevTargetRef.current;
|
|
153
|
+
prevTargetRef.current = target;
|
|
154
|
+
if (start === target) return;
|
|
155
|
+
let current = start;
|
|
156
|
+
const step = target > start ? 1 : -1;
|
|
157
|
+
const id = setInterval(() => {
|
|
158
|
+
current += step;
|
|
159
|
+
setAnimatedScore(current);
|
|
160
|
+
if (current === target) clearInterval(id);
|
|
161
|
+
}, 20);
|
|
162
|
+
return () => clearInterval(id);
|
|
163
|
+
}, [report, animate]);
|
|
164
|
+
const displayScore = animate ? animatedScore : report?.overallScore ?? 0;
|
|
165
|
+
if (!report) {
|
|
166
|
+
return /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "Waiting for scan..."));
|
|
167
|
+
}
|
|
168
|
+
const delta = previousScore !== null ? report.overallScore - previousScore : null;
|
|
169
|
+
return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Text4, { color: scoreColor2(displayScore), bold: true }, displayScore, "/100"), /* @__PURE__ */ React4.createElement(Text4, null, " "), /* @__PURE__ */ React4.createElement(Text4, { color: scoreColor2(displayScore) }, scoreBar2(displayScore))), delta !== null && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, delta > 0 ? `+${delta}` : delta === 0 ? "\xB10" : `${delta}`, " since last scan"), /* @__PURE__ */ React4.createElement(Box4, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Text4, { color: "red" }, "\u2717", " ", report.summary.critical, " critical"), /* @__PURE__ */ React4.createElement(Text4, null, " "), /* @__PURE__ */ React4.createElement(Text4, { color: "yellow" }, "\u26A0", " ", report.summary.warnings, " warn"), /* @__PURE__ */ React4.createElement(Text4, null, " "), /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "i ", report.summary.info, " info"))), report.quote && /* @__PURE__ */ React4.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React4.createElement(Text4, { italic: true, dimColor: true }, '"', report.quote.text, '" \u2014 ', report.quote.source)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/components/tui/TrendPanel.tsx
|
|
173
|
+
import React5, { useState as useState3, useEffect as useEffect3 } from "react";
|
|
174
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
175
|
+
var CATEGORY_COLORS = {
|
|
176
|
+
overall: "white",
|
|
177
|
+
dependencies: "blue",
|
|
178
|
+
security: "green",
|
|
179
|
+
"code-quality": "yellow",
|
|
180
|
+
performance: "magenta",
|
|
181
|
+
git: "cyan"
|
|
182
|
+
};
|
|
183
|
+
var SHORT_LABELS = {
|
|
184
|
+
dependencies: "Deps",
|
|
185
|
+
security: "Security",
|
|
186
|
+
"code-quality": "Quality",
|
|
187
|
+
performance: "Perf",
|
|
188
|
+
git: "Git"
|
|
189
|
+
};
|
|
190
|
+
function TrendPanel({
|
|
191
|
+
projectPath,
|
|
192
|
+
lastScanTime,
|
|
193
|
+
availableHeight
|
|
194
|
+
}) {
|
|
195
|
+
const [trends, setTrends] = useState3([]);
|
|
196
|
+
useEffect3(() => {
|
|
197
|
+
const history = loadHistory(projectPath);
|
|
198
|
+
if (!history || history.entries.length === 0) {
|
|
199
|
+
setTrends([]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const entries = history.entries.slice(-10);
|
|
203
|
+
const overallValues = entries.map((e) => e.overallScore);
|
|
204
|
+
const overallArrow = trendArrow(overallValues);
|
|
205
|
+
const result = [
|
|
206
|
+
{
|
|
207
|
+
label: "Overall",
|
|
208
|
+
spark: sparkline(overallValues),
|
|
209
|
+
latest: overallValues[overallValues.length - 1],
|
|
210
|
+
arrow: overallArrow.label,
|
|
211
|
+
color: "white"
|
|
212
|
+
}
|
|
213
|
+
];
|
|
214
|
+
const categories = Object.keys(entries[entries.length - 1].categoryScores);
|
|
215
|
+
for (const cat of categories) {
|
|
216
|
+
const values = entries.map((e) => e.categoryScores[cat]).filter((v) => v !== void 0);
|
|
217
|
+
if (values.length === 0) continue;
|
|
218
|
+
const arrow = trendArrow(values);
|
|
219
|
+
result.push({
|
|
220
|
+
label: SHORT_LABELS[cat] || cat.charAt(0).toUpperCase() + cat.slice(1),
|
|
221
|
+
spark: sparkline(values),
|
|
222
|
+
latest: values[values.length - 1],
|
|
223
|
+
arrow: arrow.label,
|
|
224
|
+
color: CATEGORY_COLORS[cat] || "white"
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
setTrends(result);
|
|
228
|
+
}, [projectPath, lastScanTime]);
|
|
229
|
+
if (trends.length === 0) {
|
|
230
|
+
return /* @__PURE__ */ React5.createElement(Box5, null, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "No trend data yet."));
|
|
231
|
+
}
|
|
232
|
+
const maxRows = availableHeight ?? trends.length;
|
|
233
|
+
const visible = trends.slice(0, maxRows);
|
|
234
|
+
return /* @__PURE__ */ React5.createElement(Box5, { flexDirection: "column" }, visible.map((t) => /* @__PURE__ */ React5.createElement(Box5, { key: t.label }, /* @__PURE__ */ React5.createElement(Box5, { width: 10 }, /* @__PURE__ */ React5.createElement(Text5, { color: t.color }, t.label)), /* @__PURE__ */ React5.createElement(Text5, null, t.spark), /* @__PURE__ */ React5.createElement(Text5, { bold: true }, " ", String(t.latest).padStart(3)), /* @__PURE__ */ React5.createElement(Text5, null, " ", t.arrow))));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/components/tui/GitPanel.tsx
|
|
238
|
+
import React6 from "react";
|
|
239
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
240
|
+
|
|
241
|
+
// src/components/tui/hooks/useGitStatus.ts
|
|
242
|
+
import { useState as useState4, useEffect as useEffect4, useRef as useRef2 } from "react";
|
|
243
|
+
import { execFile } from "child_process";
|
|
244
|
+
import { promisify } from "util";
|
|
245
|
+
var exec = promisify(execFile);
|
|
246
|
+
async function git(args, cwd) {
|
|
247
|
+
try {
|
|
248
|
+
const { stdout } = await exec("git", args, { cwd, timeout: 5e3 });
|
|
249
|
+
return stdout.trim();
|
|
250
|
+
} catch {
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function fetchGitStatus(projectPath) {
|
|
255
|
+
const [branch, porcelain, revList, stashList, logLine] = await Promise.all([
|
|
256
|
+
git(["branch", "--show-current"], projectPath),
|
|
257
|
+
git(["status", "--porcelain"], projectPath),
|
|
258
|
+
git(
|
|
259
|
+
["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
|
|
260
|
+
projectPath
|
|
261
|
+
),
|
|
262
|
+
git(["stash", "list"], projectPath),
|
|
263
|
+
git(["log", "-1", "--format=%s|%cr"], projectPath)
|
|
264
|
+
]);
|
|
265
|
+
const lines = porcelain ? porcelain.split("\n") : [];
|
|
266
|
+
const staged = lines.filter((l) => /^[MADRC]/.test(l)).length;
|
|
267
|
+
const modified = lines.filter((l) => /^.[MD]/.test(l)).length;
|
|
268
|
+
const untracked = lines.filter((l) => l.startsWith("??")).length;
|
|
269
|
+
let ahead = 0;
|
|
270
|
+
let behind = 0;
|
|
271
|
+
if (revList) {
|
|
272
|
+
const parts = revList.split(/\s+/);
|
|
273
|
+
ahead = parseInt(parts[0], 10) || 0;
|
|
274
|
+
behind = parseInt(parts[1], 10) || 0;
|
|
275
|
+
}
|
|
276
|
+
const stashes = stashList ? stashList.split("\n").filter(Boolean).length : 0;
|
|
277
|
+
const [lastCommit, lastCommitTime] = logLine.split("|");
|
|
278
|
+
return {
|
|
279
|
+
branch: branch || "unknown",
|
|
280
|
+
modified,
|
|
281
|
+
staged,
|
|
282
|
+
untracked,
|
|
283
|
+
ahead,
|
|
284
|
+
behind,
|
|
285
|
+
stashes,
|
|
286
|
+
lastCommit: lastCommit || "",
|
|
287
|
+
lastCommitTime: lastCommitTime || ""
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function useGitStatus(projectPath, pollInterval = 1e4) {
|
|
291
|
+
const [status, setStatus] = useState4(null);
|
|
292
|
+
const intervalRef = useRef2(
|
|
293
|
+
void 0
|
|
294
|
+
);
|
|
295
|
+
useEffect4(() => {
|
|
296
|
+
fetchGitStatus(projectPath).then(setStatus);
|
|
297
|
+
intervalRef.current = setInterval(() => {
|
|
298
|
+
fetchGitStatus(projectPath).then(setStatus);
|
|
299
|
+
}, pollInterval);
|
|
300
|
+
return () => {
|
|
301
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
302
|
+
};
|
|
303
|
+
}, [projectPath, pollInterval]);
|
|
304
|
+
return status;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/components/tui/GitPanel.tsx
|
|
308
|
+
function GitPanel({ projectPath, availableWidth = 30 }) {
|
|
309
|
+
const status = useGitStatus(projectPath);
|
|
310
|
+
if (!status) {
|
|
311
|
+
return /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Loading git info..."));
|
|
312
|
+
}
|
|
313
|
+
return /* @__PURE__ */ React6.createElement(Box6, { flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Branch: "), /* @__PURE__ */ React6.createElement(Text6, { bold: true, color: "green" }, status.branch)), (status.ahead > 0 || status.behind > 0) && /* @__PURE__ */ React6.createElement(Box6, null, status.ahead > 0 && /* @__PURE__ */ React6.createElement(Text6, { color: "green" }, "\u2191", status.ahead, " "), status.behind > 0 && /* @__PURE__ */ React6.createElement(Text6, { color: "red" }, "\u2193", status.behind)), /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Modified: "), /* @__PURE__ */ React6.createElement(Text6, { color: status.modified > 0 ? "yellow" : "green" }, status.modified)), /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Staged: "), /* @__PURE__ */ React6.createElement(Text6, { color: status.staged > 0 ? "cyan" : "green" }, status.staged)), /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Untracked: "), /* @__PURE__ */ React6.createElement(Text6, null, status.untracked)), status.stashes > 0 && /* @__PURE__ */ React6.createElement(Box6, null, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Stashes: "), /* @__PURE__ */ React6.createElement(Text6, null, status.stashes)), status.lastCommit && /* @__PURE__ */ React6.createElement(Box6, { marginTop: 1, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, "Last commit:"), /* @__PURE__ */ React6.createElement(Text6, null, status.lastCommit.length > availableWidth ? status.lastCommit.slice(0, availableWidth - 3) + "..." : status.lastCommit), /* @__PURE__ */ React6.createElement(Text6, { dimColor: true }, status.lastCommitTime)));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/components/tui/QuickWinsPanel.tsx
|
|
317
|
+
import React7 from "react";
|
|
318
|
+
import { Box as Box7, Text as Text7 } from "ink";
|
|
319
|
+
function replacePackageManager(cmd, pm) {
|
|
320
|
+
if (pm === "npm") return cmd;
|
|
321
|
+
const install = pm === "pnpm" ? "pnpm add" : pm === "yarn" ? "yarn add" : "bun add";
|
|
322
|
+
const uninstall = pm === "pnpm" ? "pnpm remove" : pm === "yarn" ? "yarn remove" : "bun remove";
|
|
323
|
+
const update = pm === "pnpm" ? "pnpm update" : pm === "yarn" ? "yarn upgrade" : "bun update";
|
|
324
|
+
const auditFix = pm === "pnpm" ? "pnpm audit --fix" : pm === "yarn" ? "yarn npm audit --fix" : "bun audit";
|
|
325
|
+
return cmd.replace(/^npm install(?=\s)/, install).replace(/^npm uninstall(?=\s)/, uninstall).replace(/^npm update(?=\s)/, update).replace(/^npm audit fix/, auditFix);
|
|
326
|
+
}
|
|
327
|
+
function shortenPath(filepath) {
|
|
328
|
+
const parts = filepath.split("/");
|
|
329
|
+
if (parts.length <= 2) return filepath;
|
|
330
|
+
return "\u2026/" + parts.slice(-2).join("/");
|
|
331
|
+
}
|
|
332
|
+
function smartTruncate(str, maxLen) {
|
|
333
|
+
if (str.length <= maxLen) return str;
|
|
334
|
+
const pathMatch = str.match(/(\S*\/\S+)/);
|
|
335
|
+
if (pathMatch) {
|
|
336
|
+
const shortened = str.replace(pathMatch[1], shortenPath(pathMatch[1]));
|
|
337
|
+
if (shortened.length <= maxLen) return shortened;
|
|
338
|
+
return shortened.slice(0, maxLen - 1) + "\u2026";
|
|
339
|
+
}
|
|
340
|
+
return str.slice(0, maxLen - 1) + "\u2026";
|
|
341
|
+
}
|
|
342
|
+
function shortenCommand(cmd, maxLen) {
|
|
343
|
+
if (cmd.length <= maxLen) return cmd;
|
|
344
|
+
const shortened = cmd.replace(/(\S*\/\S+)/g, (match) => shortenPath(match));
|
|
345
|
+
if (shortened.length <= maxLen) return shortened;
|
|
346
|
+
return shortened.slice(0, maxLen - 1) + "\u2026";
|
|
347
|
+
}
|
|
348
|
+
function QuickWinsPanel({ report, availableWidth }) {
|
|
349
|
+
if (!report) {
|
|
350
|
+
return /* @__PURE__ */ React7.createElement(Box7, null, /* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, "Waiting for scan..."));
|
|
351
|
+
}
|
|
352
|
+
const fixes = [];
|
|
353
|
+
for (const check of report.checks) {
|
|
354
|
+
for (const issue of check.issues) {
|
|
355
|
+
if (issue.fix?.command) {
|
|
356
|
+
fixes.push({
|
|
357
|
+
description: issue.fix.description,
|
|
358
|
+
command: issue.fix.command,
|
|
359
|
+
severity: issue.severity
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const order = { critical: 0, warning: 1, info: 2 };
|
|
365
|
+
fixes.sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3));
|
|
366
|
+
const top = fixes.slice(0, 5);
|
|
367
|
+
if (top.length === 0) {
|
|
368
|
+
return /* @__PURE__ */ React7.createElement(Box7, null, /* @__PURE__ */ React7.createElement(Text7, { color: "green" }, "Looking good!"));
|
|
369
|
+
}
|
|
370
|
+
const pm = report.projectInfo?.packageManager ?? "npm";
|
|
371
|
+
const maxTextLen = Math.max(16, (availableWidth ?? 26) - 4);
|
|
372
|
+
return /* @__PURE__ */ React7.createElement(Box7, { flexDirection: "column" }, top.map((fix, i) => /* @__PURE__ */ React7.createElement(
|
|
373
|
+
Box7,
|
|
374
|
+
{
|
|
375
|
+
key: i,
|
|
376
|
+
flexDirection: "column",
|
|
377
|
+
marginBottom: i < top.length - 1 ? 1 : 0
|
|
378
|
+
},
|
|
379
|
+
/* @__PURE__ */ React7.createElement(Text7, null, /* @__PURE__ */ React7.createElement(
|
|
380
|
+
Text7,
|
|
381
|
+
{
|
|
382
|
+
color: fix.severity === "critical" ? "red" : fix.severity === "warning" ? "yellow" : "gray"
|
|
383
|
+
},
|
|
384
|
+
"\u2192",
|
|
385
|
+
" "
|
|
386
|
+
), smartTruncate(fix.description, maxTextLen)),
|
|
387
|
+
/* @__PURE__ */ React7.createElement(Text7, { dimColor: true }, " ", shortenCommand(replacePackageManager(fix.command, pm), maxTextLen))
|
|
388
|
+
)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/components/tui/MonorepoPanel.tsx
|
|
392
|
+
import React8 from "react";
|
|
393
|
+
import { Box as Box8, Text as Text8 } from "ink";
|
|
394
|
+
function scoreBar3(score, width = 8) {
|
|
395
|
+
const filled = Math.round(score / 100 * width);
|
|
396
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
397
|
+
}
|
|
398
|
+
function scoreColor3(score) {
|
|
399
|
+
if (score >= 80) return "green";
|
|
400
|
+
if (score >= 60) return "yellow";
|
|
401
|
+
return "red";
|
|
402
|
+
}
|
|
403
|
+
function MonorepoPanel({ report, availableWidth }) {
|
|
404
|
+
if (!report) {
|
|
405
|
+
return /* @__PURE__ */ React8.createElement(Box8, null, /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, "Waiting for scan..."));
|
|
406
|
+
}
|
|
407
|
+
const nameWidth = Math.max(
|
|
408
|
+
12,
|
|
409
|
+
Math.min(20, (availableWidth ?? 36) - 22)
|
|
410
|
+
);
|
|
411
|
+
return /* @__PURE__ */ React8.createElement(Box8, { flexDirection: "column" }, /* @__PURE__ */ React8.createElement(Box8, { marginBottom: 1 }, /* @__PURE__ */ React8.createElement(Text8, { bold: true }, report.packages.length, " packages"), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, " \xB7 "), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, report.monorepoType, " workspaces"), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, " \xB7 "), /* @__PURE__ */ React8.createElement(Text8, { color: scoreColor3(report.overallScore), bold: true }, report.overallScore), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, " avg")), report.packages.map((pkg) => {
|
|
412
|
+
const name = pkg.name.includes("/") ? pkg.name.split("/").pop() ?? pkg.name : pkg.name;
|
|
413
|
+
const truncatedName = name.length > nameWidth ? name.slice(0, nameWidth - 1) + "\u2026" : name.padEnd(nameWidth);
|
|
414
|
+
return /* @__PURE__ */ React8.createElement(Box8, { key: pkg.path, gap: 1 }, /* @__PURE__ */ React8.createElement(Text8, { color: scoreColor3(pkg.score) }, scoreBar3(pkg.score)), /* @__PURE__ */ React8.createElement(Text8, { color: scoreColor3(pkg.score), bold: true }, String(pkg.score).padStart(3)), /* @__PURE__ */ React8.createElement(Text8, null, truncatedName), pkg.summary.critical > 0 && /* @__PURE__ */ React8.createElement(Text8, { color: "red" }, " \u26A0"));
|
|
415
|
+
}), /* @__PURE__ */ React8.createElement(Box8, { marginTop: 1 }, /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, "Press "), /* @__PURE__ */ React8.createElement(Text8, { color: "cyan" }, "[w]"), /* @__PURE__ */ React8.createElement(Text8, { dimColor: true }, " for per-package web view")));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/components/tui/ActivityPanel.tsx
|
|
419
|
+
import React9 from "react";
|
|
420
|
+
import { Box as Box9, Text as Text9 } from "ink";
|
|
421
|
+
var TYPE_COLOR = {
|
|
422
|
+
"scan-start": "cyan",
|
|
423
|
+
"scan-complete": "green",
|
|
424
|
+
"file-change": "yellow",
|
|
425
|
+
"git-change": "magenta",
|
|
426
|
+
regression: "red",
|
|
427
|
+
info: "gray"
|
|
428
|
+
};
|
|
429
|
+
function formatTime(date) {
|
|
430
|
+
return date.toLocaleTimeString("en-US", { hour12: false });
|
|
431
|
+
}
|
|
432
|
+
function ActivityPanel({ entries, availableHeight }) {
|
|
433
|
+
if (entries.length === 0) {
|
|
434
|
+
return /* @__PURE__ */ React9.createElement(Box9, null, /* @__PURE__ */ React9.createElement(Text9, { dimColor: true }, "No activity yet."));
|
|
435
|
+
}
|
|
436
|
+
const visible = entries.slice(-Math.max(1, availableHeight));
|
|
437
|
+
return /* @__PURE__ */ React9.createElement(Box9, { flexDirection: "column" }, visible.map((entry) => /* @__PURE__ */ React9.createElement(Box9, { key: `${entry.timestamp.getTime()}-${entry.type}` }, /* @__PURE__ */ React9.createElement(Text9, { dimColor: true }, formatTime(entry.timestamp), " "), /* @__PURE__ */ React9.createElement(Text9, { color: TYPE_COLOR[entry.type] || "white" }, entry.message))));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/components/tui/HelpPanel.tsx
|
|
441
|
+
import React10 from "react";
|
|
442
|
+
import { Box as Box10, Text as Text10 } from "ink";
|
|
443
|
+
var SECTIONS = [
|
|
444
|
+
{
|
|
445
|
+
title: "PANELS",
|
|
446
|
+
rows: [
|
|
447
|
+
{ keys: ["h"], description: "Focus / unfocus Health Checks" },
|
|
448
|
+
{ keys: ["g"], description: "Focus / unfocus Git Status" },
|
|
449
|
+
{ keys: ["t"], description: "Focus / unfocus Trend" },
|
|
450
|
+
{ keys: ["q"], description: "Focus / unfocus Quick Wins" },
|
|
451
|
+
{ keys: ["a"], description: "Focus / unfocus Activity" },
|
|
452
|
+
{ keys: ["f"], description: "Expand focused panel to full screen" },
|
|
453
|
+
{ keys: ["Esc"], description: "Unfocus panel / exit full screen" }
|
|
454
|
+
]
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
title: "SCROLLING",
|
|
458
|
+
rows: [
|
|
459
|
+
{ keys: ["\u2191", "\u2193"], description: "Scroll Health Checks (when focused)" }
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
title: "ACTIONS",
|
|
464
|
+
rows: [
|
|
465
|
+
{ keys: ["r"], description: "Re-run scan" },
|
|
466
|
+
{ keys: ["w"], description: "Open web dashboard in browser" },
|
|
467
|
+
{ keys: ["W"], description: "Open web dashboard with AI insights" },
|
|
468
|
+
{ keys: ["?"], description: "Toggle this help screen" }
|
|
469
|
+
]
|
|
470
|
+
}
|
|
471
|
+
];
|
|
472
|
+
function HelpPanel() {
|
|
473
|
+
return /* @__PURE__ */ React10.createElement(Box10, { flexDirection: "column", paddingX: 2, paddingY: 1 }, /* @__PURE__ */ React10.createElement(Box10, { marginBottom: 1 }, /* @__PURE__ */ React10.createElement(Text10, { bold: true, color: "cyan" }, "SICKBAY TUI \u2014 KEYBOARD SHORTCUTS")), SECTIONS.map((section) => /* @__PURE__ */ React10.createElement(Box10, { key: section.title, flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React10.createElement(Box10, { marginBottom: 0 }, /* @__PURE__ */ React10.createElement(Text10, { bold: true, color: "white" }, section.title)), section.rows.map((row) => /* @__PURE__ */ React10.createElement(Box10, { key: row.description, marginLeft: 2 }, /* @__PURE__ */ React10.createElement(Box10, { width: 16 }, row.keys.map((k, i) => /* @__PURE__ */ React10.createElement(Text10, { key: k }, /* @__PURE__ */ React10.createElement(Text10, { color: "cyan", bold: true }, "[", k, "]"), i < row.keys.length - 1 ? /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, " / ") : null))), /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, row.description))))), /* @__PURE__ */ React10.createElement(Box10, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1 }, /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, "Pro tip: focus a panel with its hotkey, then press", " "), /* @__PURE__ */ React10.createElement(Text10, { color: "cyan", bold: true }, "[f]"), /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, " to expand it full screen for more detail.")), /* @__PURE__ */ React10.createElement(Box10, { marginTop: 1 }, /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, "Press "), /* @__PURE__ */ React10.createElement(Text10, { color: "cyan", bold: true }, "[?]"), /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, " or "), /* @__PURE__ */ React10.createElement(Text10, { color: "cyan", bold: true }, "[Esc]"), /* @__PURE__ */ React10.createElement(Text10, { dimColor: true }, " to close")));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/components/tui/hooks/useSickbayRunner.ts
|
|
477
|
+
import { useState as useState5, useCallback, useRef as useRef3 } from "react";
|
|
478
|
+
import { runSickbay, runSickbayMonorepo, detectMonorepo, buildSummary } from "@nebulord/sickbay-core";
|
|
479
|
+
function rollUpMonorepoReport(monorepo) {
|
|
480
|
+
const byId = /* @__PURE__ */ new Map();
|
|
481
|
+
for (const pkg of monorepo.packages) {
|
|
482
|
+
for (const check of pkg.checks) {
|
|
483
|
+
const existing = byId.get(check.id);
|
|
484
|
+
if (!existing || check.score < existing.score) {
|
|
485
|
+
byId.set(check.id, check);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const checks = Array.from(byId.values());
|
|
490
|
+
return {
|
|
491
|
+
timestamp: monorepo.timestamp,
|
|
492
|
+
projectPath: monorepo.rootPath,
|
|
493
|
+
projectInfo: {
|
|
494
|
+
name: `monorepo (${monorepo.packages.length} packages)`,
|
|
495
|
+
version: "0.0.0",
|
|
496
|
+
hasTypeScript: false,
|
|
497
|
+
hasESLint: false,
|
|
498
|
+
hasPrettier: false,
|
|
499
|
+
framework: "node",
|
|
500
|
+
packageManager: monorepo.packageManager,
|
|
501
|
+
totalDependencies: 0,
|
|
502
|
+
dependencies: {},
|
|
503
|
+
devDependencies: {}
|
|
504
|
+
},
|
|
505
|
+
checks,
|
|
506
|
+
overallScore: monorepo.overallScore,
|
|
507
|
+
summary: buildSummary(checks)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function useSickbayRunner({ projectPath, checks, quotes }) {
|
|
511
|
+
const [report, setReport] = useState5(null);
|
|
512
|
+
const [monorepoReport, setMonorepoReport] = useState5(null);
|
|
513
|
+
const [isScanning, setIsScanning] = useState5(false);
|
|
514
|
+
const [progress, setProgress] = useState5([]);
|
|
515
|
+
const [error, setError] = useState5(null);
|
|
516
|
+
const scanningRef = useRef3(false);
|
|
517
|
+
const scan = useCallback(async () => {
|
|
518
|
+
if (scanningRef.current) return null;
|
|
519
|
+
scanningRef.current = true;
|
|
520
|
+
setIsScanning(true);
|
|
521
|
+
setError(null);
|
|
522
|
+
try {
|
|
523
|
+
const monorepoInfo = await detectMonorepo(projectPath);
|
|
524
|
+
if (monorepoInfo.isMonorepo) {
|
|
525
|
+
const result2 = await runSickbayMonorepo({
|
|
526
|
+
projectPath,
|
|
527
|
+
checks,
|
|
528
|
+
quotes
|
|
529
|
+
});
|
|
530
|
+
setMonorepoReport(result2);
|
|
531
|
+
const rolledUp = rollUpMonorepoReport(result2);
|
|
532
|
+
setReport(rolledUp);
|
|
533
|
+
setIsScanning(false);
|
|
534
|
+
scanningRef.current = false;
|
|
535
|
+
return rolledUp;
|
|
536
|
+
}
|
|
537
|
+
const result = await runSickbay({
|
|
538
|
+
projectPath,
|
|
539
|
+
checks,
|
|
540
|
+
quotes,
|
|
541
|
+
onRunnersReady: (names) => {
|
|
542
|
+
setProgress(names.map((name) => ({ name, status: "pending" })));
|
|
543
|
+
},
|
|
544
|
+
onCheckStart: (name) => {
|
|
545
|
+
setProgress(
|
|
546
|
+
(prev) => prev.map((p) => p.name === name ? { ...p, status: "running" } : p)
|
|
547
|
+
);
|
|
548
|
+
},
|
|
549
|
+
onCheckComplete: (check) => {
|
|
550
|
+
setProgress(
|
|
551
|
+
(prev) => prev.map((p) => p.name === check.id ? { ...p, status: "done" } : p)
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
setMonorepoReport(null);
|
|
556
|
+
setReport(result);
|
|
557
|
+
try {
|
|
558
|
+
const { saveEntry } = await import("./history-DYFJ65XH.js");
|
|
559
|
+
saveEntry(result);
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
setIsScanning(false);
|
|
563
|
+
scanningRef.current = false;
|
|
564
|
+
return result;
|
|
565
|
+
} catch (err) {
|
|
566
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
567
|
+
setIsScanning(false);
|
|
568
|
+
scanningRef.current = false;
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}, [projectPath, checks, quotes]);
|
|
572
|
+
return { report, monorepoReport, isScanning, progress, error, scan };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/components/tui/hooks/useFileWatcher.ts
|
|
576
|
+
import { useState as useState6, useEffect as useEffect5, useRef as useRef4 } from "react";
|
|
577
|
+
function useFileWatcher({
|
|
578
|
+
projectPath,
|
|
579
|
+
enabled,
|
|
580
|
+
debounceMs = 2e3,
|
|
581
|
+
onFilesChanged
|
|
582
|
+
}) {
|
|
583
|
+
const [changedFiles, setChangedFiles] = useState6([]);
|
|
584
|
+
const debounceRef = useRef4(
|
|
585
|
+
void 0
|
|
586
|
+
);
|
|
587
|
+
const pendingRef = useRef4([]);
|
|
588
|
+
const watcherRef = useRef4(null);
|
|
589
|
+
useEffect5(() => {
|
|
590
|
+
if (!enabled) return;
|
|
591
|
+
let mounted = true;
|
|
592
|
+
(async () => {
|
|
593
|
+
try {
|
|
594
|
+
const { watch } = await import("chokidar");
|
|
595
|
+
if (!mounted) return;
|
|
596
|
+
const watcher = watch(["**/*.{ts,tsx,js,jsx,json}"], {
|
|
597
|
+
cwd: projectPath,
|
|
598
|
+
ignored: [
|
|
599
|
+
"**/node_modules/**",
|
|
600
|
+
"**/dist/**",
|
|
601
|
+
"**/.git/**",
|
|
602
|
+
"**/build/**",
|
|
603
|
+
"**/.next/**",
|
|
604
|
+
"**/.turbo/**",
|
|
605
|
+
"**/coverage/**"
|
|
606
|
+
],
|
|
607
|
+
ignoreInitial: true,
|
|
608
|
+
persistent: true
|
|
609
|
+
});
|
|
610
|
+
watcherRef.current = watcher;
|
|
611
|
+
watcher.on("change", (path) => {
|
|
612
|
+
pendingRef.current.push(path);
|
|
613
|
+
setChangedFiles((prev) => [...prev, path]);
|
|
614
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
615
|
+
debounceRef.current = setTimeout(() => {
|
|
616
|
+
const files = [...pendingRef.current];
|
|
617
|
+
pendingRef.current = [];
|
|
618
|
+
onFilesChanged?.(files);
|
|
619
|
+
}, debounceMs);
|
|
620
|
+
});
|
|
621
|
+
} catch {
|
|
622
|
+
}
|
|
623
|
+
})();
|
|
624
|
+
return () => {
|
|
625
|
+
mounted = false;
|
|
626
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
627
|
+
if (watcherRef.current) watcherRef.current.close();
|
|
628
|
+
};
|
|
629
|
+
}, [projectPath, enabled, debounceMs]);
|
|
630
|
+
return changedFiles;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/components/tui/hooks/useTerminalSize.ts
|
|
634
|
+
import { useState as useState7, useEffect as useEffect6 } from "react";
|
|
635
|
+
function useTerminalSize() {
|
|
636
|
+
const [size, setSize] = useState7({
|
|
637
|
+
columns: process.stdout.columns || 80,
|
|
638
|
+
rows: process.stdout.rows || 24
|
|
639
|
+
});
|
|
640
|
+
useEffect6(() => {
|
|
641
|
+
const handler = () => {
|
|
642
|
+
setSize({
|
|
643
|
+
columns: process.stdout.columns || 80,
|
|
644
|
+
rows: process.stdout.rows || 24
|
|
645
|
+
});
|
|
646
|
+
};
|
|
647
|
+
process.stdout.on("resize", handler);
|
|
648
|
+
return () => {
|
|
649
|
+
process.stdout.off("resize", handler);
|
|
650
|
+
};
|
|
651
|
+
}, []);
|
|
652
|
+
return size;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/components/tui/TuiApp.tsx
|
|
656
|
+
function TuiApp({
|
|
657
|
+
projectPath,
|
|
658
|
+
checks,
|
|
659
|
+
watchEnabled,
|
|
660
|
+
refreshInterval,
|
|
661
|
+
quotes,
|
|
662
|
+
animateOnMount = true
|
|
663
|
+
}) {
|
|
664
|
+
const { rows, columns } = useTerminalSize();
|
|
665
|
+
const { report, monorepoReport, isScanning, progress, scan } = useSickbayRunner({
|
|
666
|
+
projectPath,
|
|
667
|
+
checks,
|
|
668
|
+
quotes
|
|
669
|
+
});
|
|
670
|
+
const [focusedPanel, setFocusedPanel] = useState8(null);
|
|
671
|
+
const [expandedPanel, setExpandedPanel] = useState8(null);
|
|
672
|
+
const [showHelp, setShowHelp] = useState8(false);
|
|
673
|
+
const [lastScanTime, setLastScanTime] = useState8(null);
|
|
674
|
+
const [previousScore, setPreviousScore] = useState8(null);
|
|
675
|
+
const [activityLog, setActivityLog] = useState8([]);
|
|
676
|
+
const [healthScrollOffset, setHealthScrollOffset] = useState8(0);
|
|
677
|
+
const [scoreFlash, setScoreFlash] = useState8();
|
|
678
|
+
const ALL_PANELS = /* @__PURE__ */ new Set(["health", "score", "trend", "git", "quickwins", "activity"]);
|
|
679
|
+
const [visiblePanels, setVisiblePanels] = useState8(
|
|
680
|
+
animateOnMount ? /* @__PURE__ */ new Set() : ALL_PANELS
|
|
681
|
+
);
|
|
682
|
+
const refreshRef = useRef5(void 0);
|
|
683
|
+
useEffect7(() => {
|
|
684
|
+
const schedule = [
|
|
685
|
+
["health", 100],
|
|
686
|
+
["score", 300],
|
|
687
|
+
["trend", 500],
|
|
688
|
+
["git", 700],
|
|
689
|
+
["quickwins", 900],
|
|
690
|
+
["activity", 1100]
|
|
691
|
+
];
|
|
692
|
+
const timers = schedule.map(
|
|
693
|
+
([panel, delay]) => setTimeout(() => {
|
|
694
|
+
setVisiblePanels((prev) => /* @__PURE__ */ new Set([...prev, panel]));
|
|
695
|
+
}, delay)
|
|
696
|
+
);
|
|
697
|
+
return () => timers.forEach(clearTimeout);
|
|
698
|
+
}, []);
|
|
699
|
+
const reportRef = useRef5(null);
|
|
700
|
+
const monorepoReportRef = useRef5(null);
|
|
701
|
+
useEffect7(() => {
|
|
702
|
+
reportRef.current = report;
|
|
703
|
+
}, [report]);
|
|
704
|
+
useEffect7(() => {
|
|
705
|
+
monorepoReportRef.current = monorepoReport;
|
|
706
|
+
}, [monorepoReport]);
|
|
707
|
+
const addActivity = useCallback2(
|
|
708
|
+
(type, message) => {
|
|
709
|
+
setActivityLog((prev) => [
|
|
710
|
+
...prev,
|
|
711
|
+
{ timestamp: /* @__PURE__ */ new Date(), type, message }
|
|
712
|
+
]);
|
|
713
|
+
},
|
|
714
|
+
[]
|
|
715
|
+
);
|
|
716
|
+
const handleScanComplete = useCallback2(
|
|
717
|
+
async (result) => {
|
|
718
|
+
const prevScore = reportRef.current?.overallScore ?? null;
|
|
719
|
+
if (prevScore !== null) setPreviousScore(prevScore);
|
|
720
|
+
setLastScanTime(/* @__PURE__ */ new Date());
|
|
721
|
+
try {
|
|
722
|
+
const { saveLastReport } = await import("./history-DYFJ65XH.js");
|
|
723
|
+
saveLastReport(result);
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
const { getDependencyTree } = await import("@nebulord/sickbay-core");
|
|
728
|
+
const { saveDepTree } = await import("./history-DYFJ65XH.js");
|
|
729
|
+
const tree = await getDependencyTree(projectPath, result.projectInfo.packageManager);
|
|
730
|
+
saveDepTree(projectPath, tree);
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
const delta = prevScore !== null ? result.overallScore - prevScore : null;
|
|
734
|
+
addActivity(
|
|
735
|
+
"scan-complete",
|
|
736
|
+
`Scan complete: ${result.overallScore}/100${delta !== null ? ` (${delta >= 0 ? "+" : ""}${delta})` : ""}`
|
|
737
|
+
);
|
|
738
|
+
if (delta !== null && delta !== 0) {
|
|
739
|
+
const flash = delta > 0 ? "green" : "red";
|
|
740
|
+
setScoreFlash(flash);
|
|
741
|
+
setTimeout(() => setScoreFlash(void 0), 600);
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
const { loadHistory: loadHistory2, detectRegressions } = await import("./history-DYFJ65XH.js");
|
|
745
|
+
const history = loadHistory2(projectPath);
|
|
746
|
+
if (history) {
|
|
747
|
+
const regressions = detectRegressions(history.entries);
|
|
748
|
+
for (const reg of regressions) {
|
|
749
|
+
addActivity(
|
|
750
|
+
"regression",
|
|
751
|
+
`\u26A0 ${reg.category} regressed: ${reg.from} \u2192 ${reg.to}`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
[addActivity, projectPath]
|
|
759
|
+
);
|
|
760
|
+
useEffect7(() => {
|
|
761
|
+
addActivity("info", "TUI started");
|
|
762
|
+
addActivity("scan-start", "Starting initial health scan...");
|
|
763
|
+
scan().then((result) => {
|
|
764
|
+
if (result) handleScanComplete(result);
|
|
765
|
+
});
|
|
766
|
+
}, []);
|
|
767
|
+
useEffect7(() => {
|
|
768
|
+
if (refreshInterval <= 0) return;
|
|
769
|
+
refreshRef.current = setInterval(async () => {
|
|
770
|
+
addActivity("scan-start", "Auto-scan triggered");
|
|
771
|
+
const result = await scan();
|
|
772
|
+
if (result) handleScanComplete(result);
|
|
773
|
+
}, refreshInterval * 1e3);
|
|
774
|
+
return () => {
|
|
775
|
+
if (refreshRef.current) clearInterval(refreshRef.current);
|
|
776
|
+
};
|
|
777
|
+
}, [refreshInterval, scan, handleScanComplete, addActivity]);
|
|
778
|
+
useFileWatcher({
|
|
779
|
+
projectPath,
|
|
780
|
+
enabled: watchEnabled,
|
|
781
|
+
onFilesChanged: async (files) => {
|
|
782
|
+
for (const f of files.slice(0, 3)) {
|
|
783
|
+
addActivity("file-change", `File changed: ${f}`);
|
|
784
|
+
}
|
|
785
|
+
if (files.length > 3) {
|
|
786
|
+
addActivity("file-change", `...and ${files.length - 3} more files`);
|
|
787
|
+
}
|
|
788
|
+
addActivity("scan-start", "Re-scan triggered (file change)");
|
|
789
|
+
const result = await scan();
|
|
790
|
+
if (result) handleScanComplete(result);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
const triggerRescan = useCallback2(async () => {
|
|
794
|
+
addActivity("scan-start", "Manual re-scan triggered");
|
|
795
|
+
const result = await scan();
|
|
796
|
+
if (result) handleScanComplete(result);
|
|
797
|
+
}, [addActivity, scan, handleScanComplete]);
|
|
798
|
+
const isTTY = !!(process.stdin.isTTY && process.stdin.setRawMode);
|
|
799
|
+
useInput((input, key) => {
|
|
800
|
+
if (showHelp) {
|
|
801
|
+
if (input === "?" || key.escape) {
|
|
802
|
+
setShowHelp(false);
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (expandedPanel) {
|
|
807
|
+
if (key.escape || input === "f") {
|
|
808
|
+
setExpandedPanel(null);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (input === "?") {
|
|
813
|
+
setShowHelp(true);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (input === "h")
|
|
817
|
+
setFocusedPanel((prev) => prev === "health" ? null : "health");
|
|
818
|
+
else if (input === "g")
|
|
819
|
+
setFocusedPanel((prev) => prev === "git" ? null : "git");
|
|
820
|
+
else if (input === "t")
|
|
821
|
+
setFocusedPanel((prev) => prev === "trend" ? null : "trend");
|
|
822
|
+
else if (input === "q")
|
|
823
|
+
setFocusedPanel((prev) => prev === "quickwins" ? null : "quickwins");
|
|
824
|
+
else if (input === "a")
|
|
825
|
+
setFocusedPanel((prev) => prev === "activity" ? null : "activity");
|
|
826
|
+
else if (input === "r") triggerRescan();
|
|
827
|
+
else if (input === "f" && focusedPanel) setExpandedPanel(focusedPanel);
|
|
828
|
+
else if (input === "w" || input === "W") {
|
|
829
|
+
const withAI = input === "W";
|
|
830
|
+
(async () => {
|
|
831
|
+
const webReport = monorepoReportRef.current ?? reportRef.current;
|
|
832
|
+
if (!webReport) return;
|
|
833
|
+
try {
|
|
834
|
+
const { serveWeb } = await import("./web-EE2VYPEX.js");
|
|
835
|
+
const { default: openBrowser } = await import("open");
|
|
836
|
+
let aiService;
|
|
837
|
+
if (withAI && process.env.ANTHROPIC_API_KEY && !monorepoReportRef.current) {
|
|
838
|
+
const { createAIService } = await import("./ai-7DGOLNJX.js");
|
|
839
|
+
aiService = createAIService(process.env.ANTHROPIC_API_KEY);
|
|
840
|
+
addActivity("info", "Launching web dashboard with AI...");
|
|
841
|
+
} else {
|
|
842
|
+
addActivity("info", "Launching web dashboard...");
|
|
843
|
+
}
|
|
844
|
+
const url = await serveWeb(webReport, 3030, aiService);
|
|
845
|
+
await openBrowser(url);
|
|
846
|
+
addActivity("info", `Web dashboard at ${url}${withAI && aiService ? " (AI enabled)" : ""}`);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
addActivity(
|
|
849
|
+
"info",
|
|
850
|
+
`Failed to open web: ${err instanceof Error ? err.message : err}`
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
})();
|
|
854
|
+
} else if (key.escape) {
|
|
855
|
+
setFocusedPanel(null);
|
|
856
|
+
}
|
|
857
|
+
if (focusedPanel === "health") {
|
|
858
|
+
if (key.upArrow)
|
|
859
|
+
setHealthScrollOffset((prev) => Math.max(0, prev - 1));
|
|
860
|
+
if (key.downArrow) {
|
|
861
|
+
const maxOffset = Math.max(0, visibleChecks.length - 5);
|
|
862
|
+
setHealthScrollOffset((prev) => Math.min(maxOffset, prev + 1));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}, { isActive: isTTY });
|
|
866
|
+
const visibleChecks = report?.checks.filter((c) => c.status !== "skipped") ?? [];
|
|
867
|
+
const topHeight = Math.floor((rows - 2) * 0.6);
|
|
868
|
+
const bottomHeight = rows - 2 - topHeight;
|
|
869
|
+
if (showHelp) {
|
|
870
|
+
return /* @__PURE__ */ React11.createElement(Box11, { flexDirection: "column", width: columns, height: rows }, /* @__PURE__ */ React11.createElement(Box11, { flexGrow: 1 }, /* @__PURE__ */ React11.createElement(PanelBorder, { title: "HELP", color: "cyan", focused: true }, /* @__PURE__ */ React11.createElement(HelpPanel, null))), /* @__PURE__ */ React11.createElement(HotkeyBar, { activePanel: focusedPanel }));
|
|
871
|
+
}
|
|
872
|
+
if (expandedPanel) {
|
|
873
|
+
return /* @__PURE__ */ React11.createElement(Box11, { flexDirection: "column", width: columns, height: rows }, /* @__PURE__ */ React11.createElement(Box11, { flexGrow: 1 }, expandedPanel === "health" && /* @__PURE__ */ React11.createElement(PanelBorder, { title: "HEALTH CHECKS", color: "green", focused: true }, /* @__PURE__ */ React11.createElement(
|
|
874
|
+
HealthPanel,
|
|
875
|
+
{
|
|
876
|
+
checks: visibleChecks,
|
|
877
|
+
isScanning,
|
|
878
|
+
progress,
|
|
879
|
+
scrollOffset: healthScrollOffset,
|
|
880
|
+
availableHeight: rows - 4
|
|
881
|
+
}
|
|
882
|
+
)), expandedPanel === "git" && /* @__PURE__ */ React11.createElement(PanelBorder, { title: "GIT STATUS", color: "yellow", focused: true }, /* @__PURE__ */ React11.createElement(GitPanel, { projectPath, availableWidth: columns - 4 })), expandedPanel === "trend" && /* @__PURE__ */ React11.createElement(PanelBorder, { title: "TREND", color: "magenta", focused: true }, /* @__PURE__ */ React11.createElement(
|
|
883
|
+
TrendPanel,
|
|
884
|
+
{
|
|
885
|
+
projectPath,
|
|
886
|
+
lastScanTime
|
|
887
|
+
}
|
|
888
|
+
)), expandedPanel === "quickwins" && /* @__PURE__ */ React11.createElement(PanelBorder, { title: monorepoReport ? "MONOREPO" : "QUICK WINS", color: "red", focused: true }, monorepoReport ? /* @__PURE__ */ React11.createElement(MonorepoPanel, { report: monorepoReport }) : /* @__PURE__ */ React11.createElement(QuickWinsPanel, { report })), expandedPanel === "activity" && /* @__PURE__ */ React11.createElement(PanelBorder, { title: "ACTIVITY", color: "cyan", focused: true }, /* @__PURE__ */ React11.createElement(
|
|
889
|
+
ActivityPanel,
|
|
890
|
+
{
|
|
891
|
+
entries: activityLog,
|
|
892
|
+
availableHeight: rows - 4
|
|
893
|
+
}
|
|
894
|
+
))), /* @__PURE__ */ React11.createElement(HotkeyBar, { activePanel: expandedPanel }));
|
|
895
|
+
}
|
|
896
|
+
const projectName = monorepoReport ? `${monorepoReport.rootPath.split("/").pop()} (monorepo)` : report?.projectInfo?.name ?? "\u2014";
|
|
897
|
+
const projectVersion = report?.projectInfo?.version;
|
|
898
|
+
const scanLabel = lastScanTime ? `Last scan ${lastScanTime.toLocaleTimeString()}` : isScanning ? "Scanning\u2026" : "Not yet scanned";
|
|
899
|
+
return /* @__PURE__ */ React11.createElement(Box11, { flexDirection: "column", width: columns, height: rows }, /* @__PURE__ */ React11.createElement(Box11, { paddingX: 1, justifyContent: "space-between" }, /* @__PURE__ */ React11.createElement(Box11, { gap: 1 }, /* @__PURE__ */ React11.createElement(Text11, { bold: true, color: "cyan" }, "SICKBAY"), /* @__PURE__ */ React11.createElement(Text11, { bold: true }, projectName), projectVersion && /* @__PURE__ */ React11.createElement(Text11, { dimColor: true }, "v", projectVersion)), /* @__PURE__ */ React11.createElement(Text11, { dimColor: true }, scanLabel)), /* @__PURE__ */ React11.createElement(Box11, { height: topHeight }, /* @__PURE__ */ React11.createElement(Box11, { width: "55%" }, /* @__PURE__ */ React11.createElement(
|
|
900
|
+
PanelBorder,
|
|
901
|
+
{
|
|
902
|
+
title: "HEALTH CHECKS",
|
|
903
|
+
color: "green",
|
|
904
|
+
focused: focusedPanel === "health",
|
|
905
|
+
visible: visiblePanels.has("health")
|
|
906
|
+
},
|
|
907
|
+
/* @__PURE__ */ React11.createElement(
|
|
908
|
+
HealthPanel,
|
|
909
|
+
{
|
|
910
|
+
checks: visibleChecks,
|
|
911
|
+
isScanning,
|
|
912
|
+
progress,
|
|
913
|
+
scrollOffset: healthScrollOffset,
|
|
914
|
+
availableHeight: topHeight - 4
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
)), /* @__PURE__ */ React11.createElement(Box11, { width: "45%", flexDirection: "column" }, /* @__PURE__ */ React11.createElement(Box11, { height: "50%" }, /* @__PURE__ */ React11.createElement(PanelBorder, { title: "SCORE", color: "blue", visible: visiblePanels.has("score"), flash: scoreFlash }, /* @__PURE__ */ React11.createElement(ScorePanel, { report, previousScore, animate: animateOnMount }))), /* @__PURE__ */ React11.createElement(Box11, { height: "50%" }, /* @__PURE__ */ React11.createElement(
|
|
918
|
+
PanelBorder,
|
|
919
|
+
{
|
|
920
|
+
title: "TREND",
|
|
921
|
+
color: "magenta",
|
|
922
|
+
focused: focusedPanel === "trend",
|
|
923
|
+
visible: visiblePanels.has("trend")
|
|
924
|
+
},
|
|
925
|
+
/* @__PURE__ */ React11.createElement(
|
|
926
|
+
TrendPanel,
|
|
927
|
+
{
|
|
928
|
+
projectPath,
|
|
929
|
+
lastScanTime,
|
|
930
|
+
availableHeight: Math.floor(topHeight / 2) - 3
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
)))), /* @__PURE__ */ React11.createElement(Box11, { height: bottomHeight }, /* @__PURE__ */ React11.createElement(Box11, { width: "25%" }, /* @__PURE__ */ React11.createElement(
|
|
934
|
+
PanelBorder,
|
|
935
|
+
{
|
|
936
|
+
title: "GIT STATUS",
|
|
937
|
+
color: "yellow",
|
|
938
|
+
focused: focusedPanel === "git",
|
|
939
|
+
visible: visiblePanels.has("git")
|
|
940
|
+
},
|
|
941
|
+
/* @__PURE__ */ React11.createElement(GitPanel, { projectPath, availableWidth: Math.floor(columns * 0.25) - 4 })
|
|
942
|
+
)), /* @__PURE__ */ React11.createElement(Box11, { width: "30%" }, /* @__PURE__ */ React11.createElement(
|
|
943
|
+
PanelBorder,
|
|
944
|
+
{
|
|
945
|
+
title: monorepoReport ? "MONOREPO" : "QUICK WINS",
|
|
946
|
+
color: "red",
|
|
947
|
+
focused: focusedPanel === "quickwins",
|
|
948
|
+
visible: visiblePanels.has("quickwins")
|
|
949
|
+
},
|
|
950
|
+
monorepoReport ? /* @__PURE__ */ React11.createElement(
|
|
951
|
+
MonorepoPanel,
|
|
952
|
+
{
|
|
953
|
+
report: monorepoReport,
|
|
954
|
+
availableWidth: Math.floor(columns * 0.3) - 4
|
|
955
|
+
}
|
|
956
|
+
) : /* @__PURE__ */ React11.createElement(
|
|
957
|
+
QuickWinsPanel,
|
|
958
|
+
{
|
|
959
|
+
report,
|
|
960
|
+
availableWidth: Math.floor(columns * 0.3) - 4
|
|
961
|
+
}
|
|
962
|
+
)
|
|
963
|
+
)), /* @__PURE__ */ React11.createElement(Box11, { width: "45%" }, /* @__PURE__ */ React11.createElement(
|
|
964
|
+
PanelBorder,
|
|
965
|
+
{
|
|
966
|
+
title: "ACTIVITY",
|
|
967
|
+
color: "cyan",
|
|
968
|
+
focused: focusedPanel === "activity",
|
|
969
|
+
visible: visiblePanels.has("activity")
|
|
970
|
+
},
|
|
971
|
+
/* @__PURE__ */ React11.createElement(
|
|
972
|
+
ActivityPanel,
|
|
973
|
+
{
|
|
974
|
+
entries: activityLog,
|
|
975
|
+
availableHeight: bottomHeight - 4
|
|
976
|
+
}
|
|
977
|
+
)
|
|
978
|
+
))), /* @__PURE__ */ React11.createElement(HotkeyBar, { activePanel: focusedPanel }));
|
|
979
|
+
}
|
|
980
|
+
export {
|
|
981
|
+
TuiApp
|
|
982
|
+
};
|