triflux 8.4.0 → 8.5.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.ko.md +17 -0
- package/README.md +17 -0
- package/bin/triflux.mjs +3 -3
- package/hub/lib/process-utils.mjs +6 -6
- package/hub/team/ansi.mjs +2 -2
- package/hub/team/psmux.mjs +4 -4
- package/hub/team/tui.mjs +74 -17
- package/hud/utils.mjs +39 -23
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
|
|
62
62
|
## Tri-CLI 합의 엔진
|
|
63
63
|
|
|
64
|
+
<p align="center">
|
|
65
|
+
<img src="docs/assets/consensus-flow.svg" alt="Tri-CLI Consensus 플로우" width="680">
|
|
66
|
+
</p>
|
|
67
|
+
|
|
64
68
|
triflux의 핵심 혁신입니다. 단일 모델을 맹신하는 대신, 모든 Deep 스킬은 다음 과정을 거칩니다:
|
|
65
69
|
|
|
66
70
|
```
|
|
@@ -175,6 +179,10 @@ Phase 3: Resolution (합의율 < 70%일 경우)
|
|
|
175
179
|
|
|
176
180
|
모든 도메인에서 두 가지 모드를 제공합니다:
|
|
177
181
|
|
|
182
|
+
<p align="center">
|
|
183
|
+
<img src="docs/assets/deep-vs-light.svg" alt="Deep vs Light 비교" width="680">
|
|
184
|
+
</p>
|
|
185
|
+
|
|
178
186
|
| 항목 | Light | Deep |
|
|
179
187
|
|------|-------|------|
|
|
180
188
|
| **CLI** | 단일 (주로 Codex) | 3자 (Claude + Codex + Gemini) |
|
|
@@ -188,6 +196,13 @@ Phase 3: Resolution (합의율 < 70%일 경우)
|
|
|
188
196
|
|
|
189
197
|
## 아키텍처
|
|
190
198
|
|
|
199
|
+
<p align="center">
|
|
200
|
+
<img src="docs/assets/architecture.svg" alt="triflux 아키텍처" width="680">
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<details>
|
|
204
|
+
<summary>인터랙티브 다이어그램 (GitHub 전용)</summary>
|
|
205
|
+
|
|
191
206
|
```mermaid
|
|
192
207
|
graph TD
|
|
193
208
|
User([사용자 / Claude Code]) <-->|Skills & Slash Commands| TFX[tfx Skills Layer]
|
|
@@ -217,6 +232,8 @@ graph TD
|
|
|
217
232
|
HUB -.->|MCP Bridge| External[외부 MCP 클라이언트]
|
|
218
233
|
```
|
|
219
234
|
|
|
235
|
+
</details>
|
|
236
|
+
|
|
220
237
|
---
|
|
221
238
|
|
|
222
239
|
## 빠른 시작
|
package/README.md
CHANGED
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
|
|
62
62
|
## Tri-CLI Consensus
|
|
63
63
|
|
|
64
|
+
<p align="center">
|
|
65
|
+
<img src="docs/assets/consensus-flow.svg" alt="Tri-CLI Consensus Flow" width="680">
|
|
66
|
+
</p>
|
|
67
|
+
|
|
64
68
|
The core innovation of triflux. Instead of trusting a single model, every Deep skill runs:
|
|
65
69
|
|
|
66
70
|
```
|
|
@@ -175,6 +179,10 @@ Phase 3: Resolution (if consensus < 70%)
|
|
|
175
179
|
|
|
176
180
|
Every domain offers both modes:
|
|
177
181
|
|
|
182
|
+
<p align="center">
|
|
183
|
+
<img src="docs/assets/deep-vs-light.svg" alt="Deep vs Light comparison" width="680">
|
|
184
|
+
</p>
|
|
185
|
+
|
|
178
186
|
| Dimension | Light | Deep |
|
|
179
187
|
|-----------|-------|------|
|
|
180
188
|
| **CLIs** | Single (usually Codex) | 3-party (Claude + Codex + Gemini) |
|
|
@@ -188,6 +196,13 @@ Every domain offers both modes:
|
|
|
188
196
|
|
|
189
197
|
## Architecture
|
|
190
198
|
|
|
199
|
+
<p align="center">
|
|
200
|
+
<img src="docs/assets/architecture.svg" alt="triflux architecture" width="680">
|
|
201
|
+
</p>
|
|
202
|
+
|
|
203
|
+
<details>
|
|
204
|
+
<summary>Interactive diagram (GitHub only)</summary>
|
|
205
|
+
|
|
191
206
|
```mermaid
|
|
192
207
|
graph TD
|
|
193
208
|
User([User / Claude Code]) <-->|Skills & Slash Commands| TFX[tfx Skills Layer]
|
|
@@ -217,6 +232,8 @@ graph TD
|
|
|
217
232
|
HUB -.->|MCP Bridge| External[External MCP Clients]
|
|
218
233
|
```
|
|
219
234
|
|
|
235
|
+
</details>
|
|
236
|
+
|
|
220
237
|
---
|
|
221
238
|
|
|
222
239
|
## Quick Start
|
package/bin/triflux.mjs
CHANGED
|
@@ -1403,7 +1403,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1403
1403
|
// --fix 없이는 개수만 보고
|
|
1404
1404
|
const { execSync: execSyncDoctor } = await import("node:child_process");
|
|
1405
1405
|
const countStr = execSyncDoctor(
|
|
1406
|
-
`powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
|
|
1406
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
|
|
1407
1407
|
{ encoding: "utf8", timeout: 5000 },
|
|
1408
1408
|
).trim();
|
|
1409
1409
|
const count = Number.parseInt(countStr, 10) || 0;
|
|
@@ -1486,7 +1486,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1486
1486
|
// Claude Code 프로세스에서 세션 ID 검색
|
|
1487
1487
|
if (process.platform === "win32") {
|
|
1488
1488
|
const psOut = execSync(
|
|
1489
|
-
`powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
|
|
1489
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
|
|
1490
1490
|
{ encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
|
|
1491
1491
|
).trim();
|
|
1492
1492
|
if (psOut && psOut !== "null") {
|
|
@@ -1497,7 +1497,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1497
1497
|
} else {
|
|
1498
1498
|
const psOut = execSync(
|
|
1499
1499
|
`ps -ax -o pid=,command= | grep -i '${safeToken}' | grep -v grep`,
|
|
1500
|
-
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] },
|
|
1500
|
+
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
|
|
1501
1501
|
).trim();
|
|
1502
1502
|
hasActiveMember = psOut.length > 0;
|
|
1503
1503
|
}
|
|
@@ -155,8 +155,8 @@ export function cleanupOrphanNodeProcesses() {
|
|
|
155
155
|
|
|
156
156
|
try {
|
|
157
157
|
const treeOutput = execSync(
|
|
158
|
-
`powershell -NoProfile -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
|
|
159
|
-
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
|
|
158
|
+
`powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${TREE_SCRIPT_PATH}" -StartPid ${myPid}`,
|
|
159
|
+
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
160
160
|
);
|
|
161
161
|
for (const line of treeOutput.split(/\r?\n/)) {
|
|
162
162
|
const pid = Number.parseInt(line.trim(), 10);
|
|
@@ -168,8 +168,8 @@ export function cleanupOrphanNodeProcesses() {
|
|
|
168
168
|
const procMap = new Map();
|
|
169
169
|
try {
|
|
170
170
|
const output = execSync(
|
|
171
|
-
`powershell -NoProfile -ExecutionPolicy Bypass -File "${SCAN_SCRIPT_PATH}"`,
|
|
172
|
-
{ encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"] },
|
|
171
|
+
`powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "${SCAN_SCRIPT_PATH}"`,
|
|
172
|
+
{ encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
173
173
|
);
|
|
174
174
|
|
|
175
175
|
for (const line of output.split(/\r?\n/)) {
|
|
@@ -203,8 +203,8 @@ export function cleanupOrphanNodeProcesses() {
|
|
|
203
203
|
let remaining = 0;
|
|
204
204
|
try {
|
|
205
205
|
const countOutput = execSync(
|
|
206
|
-
`powershell -NoProfile -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
|
|
207
|
-
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
206
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
|
|
207
|
+
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
208
208
|
);
|
|
209
209
|
remaining = Number.parseInt(countOutput.trim(), 10) || 0;
|
|
210
210
|
} catch {}
|
package/hub/team/ansi.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export const FG = {
|
|
|
39
39
|
cyan: `${ESC}[36m`,
|
|
40
40
|
gray: `${ESC}[90m`,
|
|
41
41
|
// triflux 브랜드
|
|
42
|
-
codex: `${ESC}[
|
|
42
|
+
codex: `${ESC}[38;2;16;163;127m`, // #10a37f codex green
|
|
43
43
|
gemini: `${ESC}[38;5;39m`, // blue
|
|
44
44
|
claude: `${ESC}[38;2;232;112;64m`, // orange
|
|
45
45
|
triflux: `${ESC}[38;5;214m`, // amber
|
|
@@ -185,7 +185,7 @@ export function clip(str, width) {
|
|
|
185
185
|
acc += cw;
|
|
186
186
|
i += char.length;
|
|
187
187
|
}
|
|
188
|
-
return
|
|
188
|
+
return str + " ".repeat(width - acc);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
// ── Catppuccin Mocha 색상 상수 ──
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -514,8 +514,8 @@ function killOrphanPipeHelpers(sessionName) {
|
|
|
514
514
|
const safeSession = sanitizePathPart(sessionName);
|
|
515
515
|
try {
|
|
516
516
|
const output = childProcess.execSync(
|
|
517
|
-
`powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
518
|
-
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
|
|
517
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'pipe-pane-capture' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
518
|
+
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
519
519
|
);
|
|
520
520
|
const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0);
|
|
521
521
|
for (const pid of pids) {
|
|
@@ -549,8 +549,8 @@ function killOrphanMcpProcesses(sessionName) {
|
|
|
549
549
|
try {
|
|
550
550
|
// 세션 결과 디렉토리 패턴으로 MCP 서버 프로세스 식별
|
|
551
551
|
const output = childProcess.execSync(
|
|
552
|
-
`powershell -NoProfile -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
553
|
-
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] },
|
|
552
|
+
`powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match '${safeSession}' } | Select-Object -ExpandProperty ProcessId"`,
|
|
553
|
+
{ encoding: "utf8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
|
554
554
|
);
|
|
555
555
|
const pids = output.split(/\r?\n/).map((l) => Number.parseInt(l.trim(), 10)).filter((p) => Number.isFinite(p) && p > 0 && p !== hubPid);
|
|
556
556
|
for (const pid of pids) {
|
package/hub/team/tui.mjs
CHANGED
|
@@ -72,6 +72,13 @@ function heartbeat(status, shimmerIntensity = 0) {
|
|
|
72
72
|
: SPINNER_BASE_COLOR;
|
|
73
73
|
return `\x1b[38;2;${c.r};${c.g};${c.b}m${SPINNER_FRAMES[idx]}${RESET}`;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
function currentShimmer() {
|
|
77
|
+
const elapsed = Date.now() - spinnerStart;
|
|
78
|
+
const t = (elapsed % SPINNER_CYCLE_MS) / SPINNER_CYCLE_MS;
|
|
79
|
+
return 0.5 * (1 + Math.sin(t * Math.PI * 2));
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
const GRID_GAP = 2;
|
|
76
83
|
const DEFAULT_DETAIL_LINES = 10;
|
|
77
84
|
// Tier1 상단 고정 행 수
|
|
@@ -215,6 +222,16 @@ function fadeBorderColor(currentStatus, prevStatus, changedAt) {
|
|
|
215
222
|
return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
216
223
|
}
|
|
217
224
|
|
|
225
|
+
function dedupeRole(role, name, cli) {
|
|
226
|
+
if (!role) return "";
|
|
227
|
+
let r = role;
|
|
228
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
229
|
+
r = r.replace(new RegExp(esc(cli), "gi"), "").trim();
|
|
230
|
+
r = r.replace(new RegExp(esc(name), "gi"), "").trim();
|
|
231
|
+
r = r.replace(/\(\s*\)/g, "").replace(/^\s*[•·\-]\s*/, "").trim();
|
|
232
|
+
return r;
|
|
233
|
+
}
|
|
234
|
+
|
|
218
235
|
// ── 텍스트 래핑 ──────────────────────────────────────────────────────────
|
|
219
236
|
function wrapLine(text, width) {
|
|
220
237
|
const limit = Math.max(8, width);
|
|
@@ -310,7 +327,7 @@ function buildTier1(names, workers, pipeline, elapsed, width, version) {
|
|
|
310
327
|
const phase = pipeline.phase || "exec";
|
|
311
328
|
const row1 = truncate(
|
|
312
329
|
`${color("▲", FG.triflux)} v${version} ${dim("│")} ${color(phase, phaseColor(phase))} ${dim("│")} ${elapsed}s ${dim("│")} ` +
|
|
313
|
-
`${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab
|
|
330
|
+
`${color(`✓${ok}`, MOCHA.ok)} ${color(`◑${partial}`, MOCHA.partial)} ${color(`✗${failed}`, MOCHA.fail)} ${dim(`▶${running}`)} ${color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext)}`,
|
|
314
331
|
width,
|
|
315
332
|
);
|
|
316
333
|
return [row1];
|
|
@@ -357,22 +374,22 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
357
374
|
|
|
358
375
|
// Tier2 행 1: 이름 + CLI + role
|
|
359
376
|
const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
|
|
360
|
-
const hb = heartbeat(status);
|
|
377
|
+
const hb = heartbeat(status, status === "running" ? currentShimmer() : 0);
|
|
378
|
+
const displayRole = dedupeRole(role, name, cli);
|
|
361
379
|
const title = truncate(
|
|
362
|
-
`${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${
|
|
380
|
+
`${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
363
381
|
innerWidth,
|
|
364
382
|
);
|
|
365
383
|
|
|
366
|
-
// status-specific border:
|
|
384
|
+
// status-specific border: focused→mauve, selected→bright, non-selected→dimmed tint
|
|
367
385
|
const statusBorderColor = (() => {
|
|
368
386
|
if (focused) return MOCHA.thinking;
|
|
369
|
-
if (selected)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
return fadeBorderColor(status, st._prevStatus, st._statusChangedAt);
|
|
387
|
+
if (selected) return statusColor(status);
|
|
388
|
+
// Non-selected: status-tinted border (50% blend toward border gray)
|
|
389
|
+
const from = statusToRgb(status);
|
|
390
|
+
const to = MOCHA_RGB.border;
|
|
391
|
+
const c = lerpRgb(from, to, 0.5);
|
|
392
|
+
return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
376
393
|
})();
|
|
377
394
|
|
|
378
395
|
if (compact) {
|
|
@@ -443,9 +460,10 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
443
460
|
const status = runtimeStatus(st);
|
|
444
461
|
|
|
445
462
|
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
const
|
|
463
|
+
const activeTab = opts.activeTab || "log";
|
|
464
|
+
const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
|
|
465
|
+
const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
|
|
466
|
+
const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
|
|
449
467
|
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
450
468
|
|
|
451
469
|
const stickyLines = [
|
|
@@ -457,8 +475,27 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
457
475
|
];
|
|
458
476
|
|
|
459
477
|
// 본문 스크롤 영역
|
|
460
|
-
const bodyAvail = Math.max(0, height - stickyLines.length -
|
|
461
|
-
|
|
478
|
+
const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
|
|
479
|
+
|
|
480
|
+
let allBodyLines;
|
|
481
|
+
if (activeTab === "detail") {
|
|
482
|
+
const summaryLines = [];
|
|
483
|
+
for (const key of SUMMARY_KEYS) {
|
|
484
|
+
const value = st.handoff?.[key];
|
|
485
|
+
if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
486
|
+
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
487
|
+
}
|
|
488
|
+
allBodyLines = summaryLines.length > 0
|
|
489
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
490
|
+
: [dim("no structured data")];
|
|
491
|
+
} else if (activeTab === "files") {
|
|
492
|
+
const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
493
|
+
allBodyLines = filesList.length > 0
|
|
494
|
+
? filesList.map((f, i) => `${i + 1}. ${f}`)
|
|
495
|
+
: [dim("no files changed")];
|
|
496
|
+
} else {
|
|
497
|
+
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
498
|
+
}
|
|
462
499
|
|
|
463
500
|
let startIdx;
|
|
464
501
|
if (followTail) {
|
|
@@ -498,7 +535,7 @@ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, versio
|
|
|
498
535
|
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
499
536
|
});
|
|
500
537
|
const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
|
|
501
|
-
const keysLine = truncate(color("Tab:focus • j/k
|
|
538
|
+
const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext), width - 4);
|
|
502
539
|
const framed = box([chipsLine, keysLine], width);
|
|
503
540
|
return [framed.top, ...framed.body, framed.bot];
|
|
504
541
|
}
|
|
@@ -591,6 +628,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
591
628
|
let detailScrollOffset = 0;
|
|
592
629
|
let followTail = false;
|
|
593
630
|
let rawMode = false;
|
|
631
|
+
let focusTab = "log"; // "log" | "detail" | "files"
|
|
594
632
|
let inputAttached = false;
|
|
595
633
|
let rawModeEnabled = false;
|
|
596
634
|
|
|
@@ -683,6 +721,14 @@ export function createLogDashboard(opts = {}) {
|
|
|
683
721
|
if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
|
|
684
722
|
// r: raw mode 토글
|
|
685
723
|
if (key === "r") { rawMode = !rawMode; render(); return; }
|
|
724
|
+
// l: 탭 전환 (Log → Detail → Files)
|
|
725
|
+
if (key === "l") {
|
|
726
|
+
const tabs = ["log", "detail", "files"];
|
|
727
|
+
focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
|
|
728
|
+
detailScrollOffset = 0;
|
|
729
|
+
render();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
686
732
|
// 1-9: 워커 직접 선택
|
|
687
733
|
if (/^[1-9]$/.test(key)) {
|
|
688
734
|
const names = visibleWorkerNames();
|
|
@@ -748,6 +794,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
748
794
|
followTail,
|
|
749
795
|
rawMode,
|
|
750
796
|
focused: focus === "detail",
|
|
797
|
+
activeTab: focusTab,
|
|
751
798
|
});
|
|
752
799
|
return [...tier1, ...summaryBar, ...focusPane];
|
|
753
800
|
}
|
|
@@ -788,6 +835,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
788
835
|
followTail,
|
|
789
836
|
rawMode,
|
|
790
837
|
focused: focus === "detail",
|
|
838
|
+
activeTab: focusTab,
|
|
791
839
|
});
|
|
792
840
|
}
|
|
793
841
|
while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
|
|
@@ -928,6 +976,15 @@ export function createLogDashboard(opts = {}) {
|
|
|
928
976
|
return focus === "detail";
|
|
929
977
|
},
|
|
930
978
|
|
|
979
|
+
getFocusTab() {
|
|
980
|
+
return focusTab;
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
setFocusTab(tab) {
|
|
984
|
+
const valid = ["log", "detail", "files"];
|
|
985
|
+
if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
|
|
986
|
+
},
|
|
987
|
+
|
|
931
988
|
getLayout() {
|
|
932
989
|
return layoutHint;
|
|
933
990
|
},
|
package/hud/utils.mjs
CHANGED
|
@@ -6,9 +6,7 @@ import { dirname } from "node:path";
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import {
|
|
8
8
|
PERCENT_CELL_WIDTH, TIME_CELL_INNER_WIDTH, SV_CELL_WIDTH,
|
|
9
|
-
FIVE_HOUR_MS, SEVEN_DAY_MS,
|
|
10
9
|
} from "./constants.mjs";
|
|
11
|
-
import { dim } from "./colors.mjs";
|
|
12
10
|
|
|
13
11
|
export async function readStdinJson() {
|
|
14
12
|
if (process.stdin.isTTY) return {};
|
|
@@ -58,16 +56,23 @@ export function stripAnsi(text) {
|
|
|
58
56
|
return String(text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
function getVisibleLength(text) {
|
|
60
|
+
return stripAnsi(text).length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function padAnsi(text, width, align = "right") {
|
|
64
|
+
const len = getVisibleLength(text);
|
|
63
65
|
if (len >= width) return text;
|
|
64
|
-
|
|
66
|
+
const padding = " ".repeat(width - len);
|
|
67
|
+
return align === "left" ? padding + text : text + padding;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function padAnsiRight(text, width) {
|
|
71
|
+
return padAnsi(text, width, "right");
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
export function padAnsiLeft(text, width) {
|
|
68
|
-
|
|
69
|
-
if (len >= width) return text;
|
|
70
|
-
return " ".repeat(width - len) + text;
|
|
75
|
+
return padAnsi(text, width, "left");
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export function fitText(text, width) {
|
|
@@ -164,13 +169,29 @@ export function advanceToNextCycle(epochMs, cycleMs) {
|
|
|
164
169
|
return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
|
|
165
170
|
}
|
|
166
171
|
|
|
172
|
+
function parseResetDate(isoOrUnix) {
|
|
173
|
+
if (!isoOrUnix) return null;
|
|
174
|
+
const date = typeof isoOrUnix === "string"
|
|
175
|
+
? new Date(isoOrUnix)
|
|
176
|
+
: new Date(isoOrUnix * 1000);
|
|
177
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getResetTargetMs(isoOrUnix, cycleMs = 0) {
|
|
181
|
+
const date = parseResetDate(isoOrUnix);
|
|
182
|
+
if (!date) return null;
|
|
183
|
+
return advanceToNextCycle(date.getTime(), cycleMs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getRemainingResetMs(isoOrUnix, cycleMs = 0) {
|
|
187
|
+
const targetMs = getResetTargetMs(isoOrUnix, cycleMs);
|
|
188
|
+
if (targetMs == null) return null;
|
|
189
|
+
return targetMs - Date.now();
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (isNaN(d.getTime())) return "";
|
|
171
|
-
const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
|
|
172
|
-
const diffMs = targetMs - Date.now();
|
|
173
|
-
if (diffMs <= 0) return "";
|
|
193
|
+
const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
|
|
194
|
+
if (diffMs == null || diffMs <= 0) return "";
|
|
174
195
|
const totalMinutes = Math.floor(diffMs / 60000);
|
|
175
196
|
const totalHours = Math.floor(totalMinutes / 60);
|
|
176
197
|
const minutes = totalMinutes % 60;
|
|
@@ -178,18 +199,13 @@ export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
|
|
|
178
199
|
}
|
|
179
200
|
|
|
180
201
|
export function isResetPast(isoOrUnix) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return !isNaN(d.getTime()) && d.getTime() <= Date.now();
|
|
202
|
+
const date = parseResetDate(isoOrUnix);
|
|
203
|
+
return date != null && date.getTime() <= Date.now();
|
|
184
204
|
}
|
|
185
205
|
|
|
186
206
|
export function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (isNaN(d.getTime())) return "";
|
|
190
|
-
const targetMs = advanceToNextCycle(d.getTime(), cycleMs);
|
|
191
|
-
const diffMs = targetMs - Date.now();
|
|
192
|
-
if (diffMs <= 0) return "";
|
|
207
|
+
const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
|
|
208
|
+
if (diffMs == null || diffMs <= 0) return "";
|
|
193
209
|
const totalMinutes = Math.floor(diffMs / 60000);
|
|
194
210
|
const days = Math.floor(totalMinutes / (60 * 24));
|
|
195
211
|
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|