triflux 8.4.1 → 8.5.1
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/psmux.mjs +4 -4
- package/hub/team/tui-viewer.mjs +4 -1
- package/hub/team/tui.mjs +72 -9
- 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/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-viewer.mjs
CHANGED
|
@@ -388,9 +388,12 @@ function ingest() {
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
// ── tui.updateWorker 래퍼 — raw internal data 누출 방지 ──
|
|
391
|
-
function pushToTui(paneName, cli,
|
|
391
|
+
function pushToTui(paneName, cli, paneTitle, update) {
|
|
392
392
|
// _leadAction은 tui에 노출하지 않음 (내부용)
|
|
393
393
|
const { _leadAction: _ignored, ...safeUpdate } = update;
|
|
394
|
+
// pane title에서 실제 역할만 추출: "⚪ codex (executor)" → "executor"
|
|
395
|
+
const roleMatch = paneTitle.match(/\(([^)]+)\)$/);
|
|
396
|
+
const role = roleMatch ? roleMatch[1] : "";
|
|
394
397
|
tui.updateWorker(paneName, { cli, role, ...safeUpdate });
|
|
395
398
|
}
|
|
396
399
|
|
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,21 @@ 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
|
+
// CLI indicator emojis 제거
|
|
232
|
+
r = r.replace(/[⚪⚫🔴🟠🟡🟢🔵🟣🟤⭕🔘]/g, "").trim();
|
|
233
|
+
// 빈 괄호 제거 + 중첩 괄호 정리
|
|
234
|
+
r = r.replace(/\(\s*\)/g, "").trim();
|
|
235
|
+
r = r.replace(/^\(([^()]+)\)$/, "$1").trim();
|
|
236
|
+
r = r.replace(/^\s*[•·\-]\s*/, "").trim();
|
|
237
|
+
return r;
|
|
238
|
+
}
|
|
239
|
+
|
|
218
240
|
// ── 텍스트 래핑 ──────────────────────────────────────────────────────────
|
|
219
241
|
function wrapLine(text, width) {
|
|
220
242
|
const limit = Math.max(8, width);
|
|
@@ -310,7 +332,7 @@ function buildTier1(names, workers, pipeline, elapsed, width, version) {
|
|
|
310
332
|
const phase = pipeline.phase || "exec";
|
|
311
333
|
const row1 = truncate(
|
|
312
334
|
`${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
|
|
335
|
+
`${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
336
|
width,
|
|
315
337
|
);
|
|
316
338
|
return [row1];
|
|
@@ -357,9 +379,10 @@ function buildWorkerRail(name, st, opts = {}) {
|
|
|
357
379
|
|
|
358
380
|
// Tier2 행 1: 이름 + CLI + role
|
|
359
381
|
const selMark = selected ? (focused ? color("▶", MOCHA.blue) : color(">", FG.triflux)) : " ";
|
|
360
|
-
const hb = heartbeat(status);
|
|
382
|
+
const hb = heartbeat(status, status === "running" ? currentShimmer() : 0);
|
|
383
|
+
const displayRole = dedupeRole(role, name, cli);
|
|
361
384
|
const title = truncate(
|
|
362
|
-
`${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${
|
|
385
|
+
`${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
|
|
363
386
|
innerWidth,
|
|
364
387
|
);
|
|
365
388
|
|
|
@@ -442,9 +465,10 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
442
465
|
const status = runtimeStatus(st);
|
|
443
466
|
|
|
444
467
|
// Tab bar: 활성 탭은 MOCHA.blue + bold, 비활성은 MOCHA.overlay
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
const
|
|
468
|
+
const activeTab = opts.activeTab || "log";
|
|
469
|
+
const tabLog = activeTab === "log" ? `${MOCHA.blue}${bold("[Log]")}` : color("[Log]", MOCHA.overlay);
|
|
470
|
+
const tabDetail = activeTab === "detail" ? `${MOCHA.blue}${bold("[Detail]")}` : color("[Detail]", MOCHA.overlay);
|
|
471
|
+
const tabFiles = activeTab === "files" ? `${MOCHA.blue}${bold(`[Files ${files.length}]`)}` : color(`[Files ${files.length}]`, MOCHA.overlay);
|
|
448
472
|
const tabBar = truncate(`${tabLog} ${tabDetail} ${tabFiles}`, innerWidth);
|
|
449
473
|
|
|
450
474
|
const stickyLines = [
|
|
@@ -457,7 +481,26 @@ function buildFocusPane(name, st, opts = {}) {
|
|
|
457
481
|
|
|
458
482
|
// 본문 스크롤 영역
|
|
459
483
|
const bodyAvail = Math.max(0, height - stickyLines.length - 3); // top+bot border + scrollInfo
|
|
460
|
-
|
|
484
|
+
|
|
485
|
+
let allBodyLines;
|
|
486
|
+
if (activeTab === "detail") {
|
|
487
|
+
const summaryLines = [];
|
|
488
|
+
for (const key of SUMMARY_KEYS) {
|
|
489
|
+
const value = st.handoff?.[key];
|
|
490
|
+
if (Array.isArray(value) && value.length > 0) summaryLines.push(`${key}: ${value.join(", ")}`);
|
|
491
|
+
else if (value) summaryLines.push(`${key}: ${value}`);
|
|
492
|
+
}
|
|
493
|
+
allBodyLines = summaryLines.length > 0
|
|
494
|
+
? summaryLines.flatMap((l) => wrapLine(l, innerWidth))
|
|
495
|
+
: [dim("no structured data")];
|
|
496
|
+
} else if (activeTab === "files") {
|
|
497
|
+
const filesList = sanitizeFiles(st.handoff?.files_changed || st.files_changed);
|
|
498
|
+
allBodyLines = filesList.length > 0
|
|
499
|
+
? filesList.map((f, i) => `${i + 1}. ${f}`)
|
|
500
|
+
: [dim("no files changed")];
|
|
501
|
+
} else {
|
|
502
|
+
allBodyLines = wrapTextAll(detailText(st), innerWidth, rawMode);
|
|
503
|
+
}
|
|
461
504
|
|
|
462
505
|
let startIdx;
|
|
463
506
|
if (followTail) {
|
|
@@ -497,7 +540,7 @@ function buildSummaryBar(names, workers, selectedWorker, pipeline, width, versio
|
|
|
497
540
|
return padRight(truncate(label, maxChipWidth), maxChipWidth);
|
|
498
541
|
});
|
|
499
542
|
const chipsLine = truncate(chips.join(color(" │ ", MOCHA.overlay)), width - 4);
|
|
500
|
-
const keysLine = truncate(color("Tab:focus • j/k
|
|
543
|
+
const keysLine = truncate(color("Tab:focus • j/k/↑↓:nav • f:follow • r:raw • l:tab • 1-9:jump", MOCHA.subtext), width - 4);
|
|
501
544
|
const framed = box([chipsLine, keysLine], width);
|
|
502
545
|
return [framed.top, ...framed.body, framed.bot];
|
|
503
546
|
}
|
|
@@ -590,6 +633,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
590
633
|
let detailScrollOffset = 0;
|
|
591
634
|
let followTail = false;
|
|
592
635
|
let rawMode = false;
|
|
636
|
+
let focusTab = "log"; // "log" | "detail" | "files"
|
|
593
637
|
let inputAttached = false;
|
|
594
638
|
let rawModeEnabled = false;
|
|
595
639
|
|
|
@@ -682,6 +726,14 @@ export function createLogDashboard(opts = {}) {
|
|
|
682
726
|
if (key === "f") { followTail = !followTail; if (followTail) detailScrollOffset = 0; render(); return; }
|
|
683
727
|
// r: raw mode 토글
|
|
684
728
|
if (key === "r") { rawMode = !rawMode; render(); return; }
|
|
729
|
+
// l: 탭 전환 (Log → Detail → Files)
|
|
730
|
+
if (key === "l") {
|
|
731
|
+
const tabs = ["log", "detail", "files"];
|
|
732
|
+
focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
|
|
733
|
+
detailScrollOffset = 0;
|
|
734
|
+
render();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
685
737
|
// 1-9: 워커 직접 선택
|
|
686
738
|
if (/^[1-9]$/.test(key)) {
|
|
687
739
|
const names = visibleWorkerNames();
|
|
@@ -747,6 +799,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
747
799
|
followTail,
|
|
748
800
|
rawMode,
|
|
749
801
|
focused: focus === "detail",
|
|
802
|
+
activeTab: focusTab,
|
|
750
803
|
});
|
|
751
804
|
return [...tier1, ...summaryBar, ...focusPane];
|
|
752
805
|
}
|
|
@@ -765,7 +818,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
765
818
|
const railLines = [];
|
|
766
819
|
for (const name of names) {
|
|
767
820
|
const card = buildWorkerRail(name, workers.get(name), {
|
|
768
|
-
width: railWidth
|
|
821
|
+
width: railWidth,
|
|
769
822
|
selected: name === selectedWorker,
|
|
770
823
|
focused: focus === "rail" && name === selectedWorker,
|
|
771
824
|
rawMode,
|
|
@@ -787,6 +840,7 @@ export function createLogDashboard(opts = {}) {
|
|
|
787
840
|
followTail,
|
|
788
841
|
rawMode,
|
|
789
842
|
focused: focus === "detail",
|
|
843
|
+
activeTab: focusTab,
|
|
790
844
|
});
|
|
791
845
|
}
|
|
792
846
|
while (focusLines.length < bodyHeight) focusLines.push(padRight("", focusWidth));
|
|
@@ -927,6 +981,15 @@ export function createLogDashboard(opts = {}) {
|
|
|
927
981
|
return focus === "detail";
|
|
928
982
|
},
|
|
929
983
|
|
|
984
|
+
getFocusTab() {
|
|
985
|
+
return focusTab;
|
|
986
|
+
},
|
|
987
|
+
|
|
988
|
+
setFocusTab(tab) {
|
|
989
|
+
const valid = ["log", "detail", "files"];
|
|
990
|
+
if (valid.includes(tab)) { focusTab = tab; detailScrollOffset = 0; }
|
|
991
|
+
},
|
|
992
|
+
|
|
930
993
|
getLayout() {
|
|
931
994
|
return layoutHint;
|
|
932
995
|
},
|
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);
|