triflux 2.0.3 → 2.2.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 CHANGED
@@ -51,14 +51,7 @@
51
51
  <details>
52
52
  <summary><strong>설치</strong></summary>
53
53
 
54
- ### 플러그인 (권장)
55
-
56
- ```bash
57
- /plugin marketplace add https://github.com/tellang/triflux
58
- /plugin install triflux
59
- ```
60
-
61
- ### npm (전역)
54
+ ### npm (권장)
62
55
 
63
56
  ```bash
64
57
  npm install -g triflux
package/README.md CHANGED
@@ -51,14 +51,7 @@
51
51
  <details>
52
52
  <summary><strong>Install</strong></summary>
53
53
 
54
- ### Plugin (recommended)
55
-
56
- ```bash
57
- /plugin marketplace add https://github.com/tellang/triflux
58
- /plugin install triflux
59
- ```
60
-
61
- ### npm (global)
54
+ ### npm (recommended)
62
55
 
63
56
  ```bash
64
57
  npm install -g triflux
@@ -7,7 +7,7 @@ import https from "node:https";
7
7
  import { createHash } from "node:crypto";
8
8
  import { spawn, execSync } from "node:child_process";
9
9
 
10
- const VERSION = "1.7";
10
+ const VERSION = "1.9";
11
11
 
12
12
  // ============================================================================
13
13
  // ANSI 색상 (OMC colors.js 스키마 일치)
@@ -19,7 +19,7 @@ const RED = "\x1b[31m";
19
19
  const GREEN = "\x1b[32m";
20
20
  const YELLOW = "\x1b[33m";
21
21
  const CYAN = "\x1b[36m";
22
- const CLAUDE_ORANGE = "\x1b[38;5;214m";
22
+ const CLAUDE_ORANGE = "\x1b[38;5;173m"; // #D87656 (Claude 테라코타)
23
23
  const CODEX_WHITE = "\x1b[97m"; // bright white (SGR 37은 Windows Terminal에서 연회색 매핑)
24
24
  const GEMINI_BLUE = "\x1b[38;5;39m";
25
25
 
@@ -52,15 +52,50 @@ function colorParallel(current, cap) {
52
52
  return red(`${current}/${cap}`);
53
53
  }
54
54
 
55
- function coloredBar(percent, width = 8) {
55
+ const GAUGE_WIDTH = 5;
56
+ const GAUGE_BLOCKS = ["░", "▒", "▓", "█"]; // 밝기 0~3
57
+
58
+ function coloredBar(percent, width = GAUGE_WIDTH, baseColor = null) {
56
59
  const safePercent = Math.min(100, Math.max(0, percent));
57
- const filled = Math.round((safePercent / 100) * width);
58
- const empty = width - filled;
60
+ const perBlock = 100 / width;
61
+
62
+ // 상태별 색상
59
63
  let barColor;
60
64
  if (safePercent >= 85) barColor = RED;
61
65
  else if (safePercent >= 70) barColor = YELLOW;
62
- else barColor = GREEN;
63
- return `${barColor}${"█".repeat(filled)}${DIM}${"░".repeat(empty)}${RESET}`;
66
+ else barColor = baseColor || GREEN;
67
+
68
+ let bar = "";
69
+ for (let i = 0; i < width; i++) {
70
+ const blockStart = i * perBlock;
71
+ const blockEnd = (i + 1) * perBlock;
72
+
73
+ if (safePercent >= blockEnd) {
74
+ bar += "█"; // 완전 채움
75
+ } else if (safePercent > blockStart) {
76
+ // 프론티어: 구간 내 진행률
77
+ const progress = (safePercent - blockStart) / perBlock;
78
+ if (progress >= 0.75) bar += "▓";
79
+ else if (progress >= 0.33) bar += "▒";
80
+ else bar += "░";
81
+ } else {
82
+ bar += "░"; // 미도달
83
+ }
84
+ }
85
+
86
+ // 채워진 부분 = barColor, 빈 부분 = DIM
87
+ const filledEnd = Math.ceil(safePercent / perBlock);
88
+ const coloredPart = barColor + bar.slice(0, filledEnd) + RESET;
89
+ const dimPart = filledEnd < width ? DIM + bar.slice(filledEnd) + RESET : "";
90
+
91
+ return coloredPart + dimPart;
92
+ }
93
+
94
+ // 프로바이더별 색상 % (< 70%: 프로바이더 색, ≥ 70%: 경고색)
95
+ function colorByProvider(value, text, providerColorFn) {
96
+ if (value >= 85) return red(text);
97
+ if (value >= 70) return yellow(text);
98
+ return providerColorFn(text);
64
99
  }
65
100
 
66
101
  // ============================================================================
@@ -115,7 +150,7 @@ const GEMINI_SESSION_STALE_MS = 15 * 1000; // 15초
115
150
  const GEMINI_API_TIMEOUT_MS = 3000; // 3초
116
151
  const ACCOUNT_LABEL_WIDTH = 10;
117
152
  const PROVIDER_PREFIX_WIDTH = 2;
118
- const PERCENT_CELL_WIDTH = 4;
153
+ const PERCENT_CELL_WIDTH = 3;
119
154
  const TIME_CELL_INNER_WIDTH = 6;
120
155
  const CLAUDE_REFRESH_FLAG = "--refresh-claude-usage";
121
156
  const CODEX_REFRESH_FLAG = "--refresh-codex-rate-limits";
@@ -151,6 +186,28 @@ function getTerminalColumns() {
151
186
  return 0;
152
187
  }
153
188
 
189
+ let _cachedRows = 0;
190
+ function getTerminalRows() {
191
+ if (_cachedRows > 0) return _cachedRows;
192
+ if (process.stdout.rows) { _cachedRows = process.stdout.rows; return _cachedRows; }
193
+ if (process.stderr.rows) { _cachedRows = process.stderr.rows; return _cachedRows; }
194
+ const envLines = Number(process.env.LINES);
195
+ if (envLines > 0) { _cachedRows = envLines; return _cachedRows; }
196
+ try {
197
+ if (process.platform === "win32") {
198
+ const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
199
+ const m = raw.match(/Lines[^:]*:\s*(\d+)/i) || raw.match(/줄[^:]*:\s*(\d+)/);
200
+ if (m) { _cachedRows = Number(m[1]); return _cachedRows; }
201
+ } else {
202
+ const raw = execSync("tput lines 2>/dev/null || stty size 2>/dev/null | awk '{print $1}'", {
203
+ timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
204
+ }).toString().trim();
205
+ if (raw && !isNaN(Number(raw))) { _cachedRows = Number(raw); return _cachedRows; }
206
+ }
207
+ } catch { /* 감지 실패 */ }
208
+ return 0;
209
+ }
210
+
154
211
  function detectCompactMode() {
155
212
  // 1. 명시적 CLI 플래그
156
213
  if (process.argv.includes("--compact")) return true;
@@ -191,6 +248,74 @@ function detectMinimalMode() {
191
248
 
192
249
  const MINIMAL_MODE = detectMinimalMode();
193
250
 
251
+ // ============================================================================
252
+ // 4-Tier 적응형 렌더링: full > normal > compact > nano
253
+ // ============================================================================
254
+ // 초기 tier (stdin 없이 결정 가능한 수준)
255
+ let CURRENT_TIER = MINIMAL_MODE ? "nano" : COMPACT_MODE ? "compact" : "normal";
256
+
257
+ /**
258
+ * 인디케이터 인식 + 터미널 크기 기반 tier 자동 선택.
259
+ * main()에서 stdin 수신 후 호출하여 CURRENT_TIER 갱신.
260
+ */
261
+ function selectTier(stdin, claudeUsage = null) {
262
+ const hudConfig = readJson(HUD_CONFIG_PATH, null);
263
+
264
+ // 1) 명시적 tier 강제 설정
265
+ const forcedTier = hudConfig?.tier;
266
+ if (["full", "normal", "compact", "nano"].includes(forcedTier)) return forcedTier;
267
+
268
+ // 2) 기존 모드 플래그 존중
269
+ if (MINIMAL_MODE) return "nano";
270
+ if (COMPACT_MODE) return "compact";
271
+
272
+ // 3) autoResize 비활성이면 normal 유지
273
+ if (hudConfig?.autoResize === false) return "normal";
274
+
275
+ // 4) 터미널 행/열에서 상태영역 예산 추정
276
+ const rows = getTerminalRows();
277
+ const cols = getTerminalColumns() || 120;
278
+ let budget;
279
+ if (rows >= 40) budget = 6;
280
+ else if (rows >= 35) budget = 5;
281
+ else if (rows >= 28) budget = 4;
282
+ else if (rows >= 22) budget = 3;
283
+ else if (rows > 0) budget = 2;
284
+ else budget = 5; // rows 감지 불가 → 넉넉하게
285
+
286
+ // 5) 인디케이터 줄 추정
287
+ let indicatorRows = 1; // bypass permissions (거의 항상 표시)
288
+ const contextPercent = getContextPercent(stdin);
289
+ if (contextPercent >= 85) indicatorRows += 1; // "Context low" 배너
290
+ // Claude Code 사용량 경고 (노란색 배너: "You've used X% of your ... limit")
291
+ const weeklyPct = claudeUsage?.weeklyPercent ?? 0;
292
+ const fiveHourPct = claudeUsage?.fiveHourPercent ?? 0;
293
+ if (weeklyPct >= 80) indicatorRows += 1;
294
+ if (fiveHourPct >= 80) indicatorRows += 1;
295
+
296
+ // 6) 각 tier에서 줄바꿈 없이 3줄 가용한지 확인
297
+ const tierWidths = { full: 70, normal: 60, compact: 40, nano: 34 };
298
+ for (const tier of ["full", "normal", "compact", "nano"]) {
299
+ const lineWidth = tierWidths[tier];
300
+ const visualRowsPerLine = Math.ceil(lineWidth / Math.max(cols, 1));
301
+ const totalVisualRows = (3 * visualRowsPerLine) + indicatorRows;
302
+ if (totalVisualRows <= budget) return tier;
303
+ }
304
+ return "nano";
305
+ }
306
+
307
+ // full tier 전용: 게이지 바 접두사 (normal 이하 tier에서는 빈 문자열)
308
+ function tierBar(percent, baseColor = null) {
309
+ return CURRENT_TIER === "full" ? coloredBar(percent, GAUGE_WIDTH, baseColor) + " " : "";
310
+ }
311
+ function tierDimBar() {
312
+ return CURRENT_TIER === "full" ? DIM + "░".repeat(GAUGE_WIDTH) + RESET + " " : "";
313
+ }
314
+ // Gemini ∞% 전용: 무한 쿼터이므로 dim 회색 바
315
+ function tierInfBar() {
316
+ return CURRENT_TIER === "full" ? DIM + "█".repeat(GAUGE_WIDTH) + RESET + " " : "";
317
+ }
318
+
194
319
  // ============================================================================
195
320
  // 유틸
196
321
  // ============================================================================
@@ -308,22 +433,18 @@ function normalizeTimeToken(value) {
308
433
  const text = String(value || "n/a");
309
434
  const hourMinute = text.match(/^(\d+)h(\d+)m$/);
310
435
  if (hourMinute) {
311
- const hours = String(Number(hourMinute[1])).padStart(2, "0");
312
- const minutes = String(Number(hourMinute[2])).padStart(2, "0");
313
- return `${hours}h${minutes}m`;
436
+ return `${Number(hourMinute[1])}h${String(Number(hourMinute[2])).padStart(2, "0")}m`;
314
437
  }
315
438
  const dayHour = text.match(/^(\d+)d(\d+)h$/);
316
439
  if (dayHour) {
317
- const days = String(Number(dayHour[1]));
318
- const hours = String(Number(dayHour[2])).padStart(2, "0");
319
- return `${days}d${hours}h`;
440
+ return `${Number(dayHour[1])}d${Number(dayHour[2])}h`;
320
441
  }
321
442
  return text;
322
443
  }
323
444
 
324
445
  function formatTimeCell(value) {
325
446
  const text = normalizeTimeToken(value);
326
- return `(${text.padStart(TIME_CELL_INNER_WIDTH, " ")})`;
447
+ return `(${text.padStart(TIME_CELL_INNER_WIDTH, "0")})`;
327
448
  }
328
449
 
329
450
  // 주간(d/h) 전용 — 최대 7d00h(5자)이므로 공백 불필요
@@ -655,11 +776,11 @@ function formatResetRemaining(isoOrUnix) {
655
776
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
656
777
  if (isNaN(d.getTime())) return "";
657
778
  const diffMs = d.getTime() - Date.now();
658
- if (diffMs <= 0) return "00h00m";
779
+ if (diffMs <= 0) return "0h00m";
659
780
  const totalMinutes = Math.floor(diffMs / 60000);
660
781
  const totalHours = Math.floor(totalMinutes / 60);
661
782
  const minutes = totalMinutes % 60;
662
- return `${String(totalHours).padStart(2, "0")}h${String(minutes).padStart(2, "0")}m`;
783
+ return `${totalHours}h${String(minutes).padStart(2, "0")}m`;
663
784
  }
664
785
 
665
786
  function isResetPast(isoOrUnix) {
@@ -673,11 +794,11 @@ function formatResetRemainingDayHour(isoOrUnix) {
673
794
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
674
795
  if (isNaN(d.getTime())) return "";
675
796
  const diffMs = d.getTime() - Date.now();
676
- if (diffMs <= 0) return "0d00h";
797
+ if (diffMs <= 0) return "0d0h";
677
798
  const totalMinutes = Math.floor(diffMs / 60000);
678
799
  const days = Math.floor(totalMinutes / (60 * 24));
679
800
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
680
- return `${days}d${String(hours).padStart(2, "0")}h`;
801
+ return `${days}d${hours}h`;
681
802
  }
682
803
 
683
804
  function calcCooldownLeftSeconds(isoDatetime) {
@@ -1056,13 +1177,22 @@ function formatSavings(dollars) {
1056
1177
  return `$${dollars.toFixed(2)}`;
1057
1178
  }
1058
1179
 
1059
- function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1180
+ // sv 퍼센트 포맷 (1000+ k 표기, 5자 고정폭)
1181
+ const SV_CELL_WIDTH = 5;
1182
+ function formatSvPct(value) {
1183
+ if (value == null) return "--%".padStart(SV_CELL_WIDTH);
1184
+ if (value >= 10000) return `${Math.round(value / 1000)}k%`.padStart(SV_CELL_WIDTH);
1185
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k%`.padStart(SV_CELL_WIDTH);
1186
+ return `${value}%`.padStart(SV_CELL_WIDTH);
1187
+ }
1188
+
1189
+ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1060
1190
  const contextPercent = getContextPercent(stdin);
1061
1191
  const prefix = `${bold(claudeOrange("c"))}:`;
1062
1192
 
1063
- // 절약 달러 누적 (Codex+Gemini 합산)
1064
- const svText = totalSvDollar > 0 ? formatSavings(totalSvDollar) : "$0";
1065
- const svSuffix = `${dim("sv:")}${cyan(svText.padStart(6))}`;
1193
+ // 절약 퍼센트 (Codex+Gemini sv% 합산, x/g와 동일 형식)
1194
+ const svStr = formatSvPct(combinedSvPct || 0);
1195
+ const svSuffix = `${dim("sv:")}${svStr}`;
1066
1196
 
1067
1197
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1068
1198
  const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
@@ -1076,7 +1206,7 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1076
1206
 
1077
1207
  const hasData = claudeUsage != null;
1078
1208
 
1079
- if (MINIMAL_MODE) {
1209
+ if (CURRENT_TIER === "nano") {
1080
1210
  const cols = getTerminalColumns() || 80;
1081
1211
  if (!hasData) {
1082
1212
  const quotaSection = cols < 40
@@ -1085,38 +1215,37 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1085
1215
  return [{ prefix, left: quotaSection, right: "" }];
1086
1216
  }
1087
1217
  if (cols < 40) {
1088
- // 초소형: c: 12%/8% ctx:45%
1089
- const quotaSection = `${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)}${dim("/")}` +
1090
- `${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
1218
+ const quotaSection = `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}${dim("/")}` +
1219
+ `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)} ` +
1091
1220
  `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1092
1221
  return [{ prefix, left: quotaSection, right: "" }];
1093
1222
  }
1094
- // minimal: c: 5h 12% 1w 8% sv:$16.57 ctx:45%
1095
- const svCompact = `${dim("sv:")}${cyan(svText)}`;
1096
- const quotaSection = `${dim("5h")} ${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)} ` +
1097
- `${dim("1w")} ${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
1098
- `${svCompact} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1223
+ // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1224
+ const quotaSection = `${dim("5h")} ${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1225
+ `${dim("1w")} ${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1226
+ `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1099
1227
  return [{ prefix, left: quotaSection, right: "" }];
1100
1228
  }
1101
1229
 
1102
- if (COMPACT_MODE) {
1103
- // 데이터 없을 때: 전체 dim 처리 (green 0% 대신)
1230
+ if (CURRENT_TIER === "compact") {
1104
1231
  if (!hasData) {
1105
- const quotaSection = `${dim("5h: --% 1w: --% ")}` +
1106
- `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1232
+ const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
1233
+ `${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
1234
+ `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1107
1235
  return [{ prefix, left: quotaSection, right: "" }];
1108
1236
  }
1109
- const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)} ` +
1110
- `${dim("1w:")}${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
1111
- `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1237
+ // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1238
+ const quotaSection = `${dim("5h:")}${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1239
+ `${dim("1w:")}${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1240
+ `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1112
1241
  return [{ prefix, left: quotaSection, right: "" }];
1113
1242
  }
1114
1243
 
1115
1244
  // 데이터 없을 때: 퍼센트+시간 모두 dim 처리 (green 0% 대신)
1116
1245
  if (!hasData) {
1117
- const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
1246
+ const quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1118
1247
  `${dim(formatTimeCell(fiveHourReset))} ` +
1119
- `${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
1248
+ `${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1120
1249
  `${dim(formatTimeCellDH(weeklyReset))}`;
1121
1250
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1122
1251
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1126,9 +1255,9 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1126
1255
  const weeklyPercentCell = formatPercentCell(weeklyPercent);
1127
1256
  const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1128
1257
  const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1129
- const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, fiveHourPercentCell)} ` +
1258
+ const quotaSection = `${dim("5h:")}${tierBar(fiveHourPercent, CLAUDE_ORANGE)}${colorByProvider(fiveHourPercent, fiveHourPercentCell, claudeOrange)} ` +
1130
1259
  `${dim(fiveHourTimeCell)} ` +
1131
- `${dim("1w:")}${colorByPercent(weeklyPercent, weeklyPercentCell)} ` +
1260
+ `${dim("1w:")}${tierBar(weeklyPercent, CLAUDE_ORANGE)}${colorByProvider(weeklyPercent, weeklyPercentCell, claudeOrange)} ` +
1132
1261
  `${dim(weeklyTimeCell)}`;
1133
1262
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1134
1263
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1151,26 +1280,30 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1151
1280
 
1152
1281
  // 절약 퍼센트 섹션 (context window 대비 %, 4자리 고정폭)
1153
1282
  const svPct = savingsMultiplier != null ? Math.round(savingsMultiplier * 100) : null;
1154
- const svStr = svPct != null ? `${svPct}%`.padStart(6) : "--%".padStart(6);
1283
+ const svStr = formatSvPct(svPct);
1155
1284
  const modelLabelStr = modelLabel ? ` ${markerColor(modelLabel)}` : "";
1156
1285
 
1286
+ // ── 프로바이더별 색상 프로필 ──
1287
+ const provAnsi = provider === "codex" ? CODEX_WHITE : provider === "gemini" ? GEMINI_BLUE : GREEN;
1288
+ const provFn = provider === "codex" ? codexWhite : provider === "gemini" ? geminiBlue : green;
1289
+
1157
1290
  // ── 쿼터 섹션 ──
1158
1291
  let quotaSection;
1159
1292
  let extraRightSection = "";
1160
1293
 
1161
- if (MINIMAL_MODE) {
1294
+ if (CURRENT_TIER === "nano") {
1162
1295
  const cols = getTerminalColumns() || 80;
1163
1296
  const minPrefix = `${bold(markerColor(`${marker}`))}:`;
1164
- const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr.trim())}` : "";
1297
+ const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr)}` : "";
1165
1298
  if (realQuota?.type === "codex") {
1166
1299
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1167
1300
  if (main) {
1168
1301
  const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1169
1302
  const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1170
1303
  if (cols < 40) {
1171
- return { prefix: minPrefix, left: `${colorByPercent(fiveP, `${fiveP}%`)}${dim("/")}${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
1304
+ return { prefix: minPrefix, left: `${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)}${dim("/")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1172
1305
  }
1173
- return { prefix: minPrefix, left: `${dim("5h")} ${colorByPercent(fiveP, `${fiveP}%`)} ${dim("1w")} ${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
1306
+ return { prefix: minPrefix, left: `${dim("5h")} ${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ${dim("1w")} ${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1174
1307
  }
1175
1308
  }
1176
1309
  if (realQuota?.type === "gemini") {
@@ -1178,39 +1311,39 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1178
1311
  if (bucket) {
1179
1312
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1180
1313
  if (cols < 40) {
1181
- return { prefix: minPrefix, left: `${colorByPercent(usedP, `${usedP}%`)}${svCompact}${modelLabelStr}`, right: "" };
1314
+ return { prefix: minPrefix, left: `${colorByProvider(usedP, formatPercentCell(usedP), provFn)}${svCompact}`, right: "" };
1182
1315
  }
1183
- return { prefix: minPrefix, left: `${dim("1d")} ${colorByPercent(usedP, `${usedP}%`)} ${dim("1w")} ${bold("\u221E%")}${svCompact}${modelLabelStr}`, right: "" };
1316
+ return { prefix: minPrefix, left: `${dim("1d")} ${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w")} ${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}${svCompact}`, right: "" };
1184
1317
  }
1185
1318
  }
1186
- return { prefix: minPrefix, left: dim("--%"), right: "" };
1319
+ return { prefix: minPrefix, left: dim("--%".padStart(PERCENT_CELL_WIDTH)), right: "" };
1187
1320
  }
1188
1321
 
1189
- if (COMPACT_MODE) {
1190
- // 컴팩트 모드: 바 없이 퍼센트만, right 섹션 생략
1322
+ if (CURRENT_TIER === "compact") {
1191
1323
  if (realQuota?.type === "codex") {
1192
1324
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1193
1325
  if (main) {
1194
1326
  const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1195
1327
  const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1196
- quotaSection = `${dim("5h:")}${colorByPercent(fiveP, `${fiveP}%`)} ` +
1197
- `${dim("1w:")}${colorByPercent(weekP, `${weekP}%`)}`;
1328
+ quotaSection = `${dim("5h:")}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1329
+ `${dim("1w:")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}`;
1198
1330
  }
1199
1331
  }
1200
1332
  if (realQuota?.type === "gemini") {
1201
1333
  const bucket = realQuota.quotaBucket;
1202
1334
  if (bucket) {
1203
1335
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1204
- quotaSection = `${dim("1d:")}${colorByPercent(usedP, `${usedP}%`)} ${dim("1w:")}${bold("\u221E%")}`;
1336
+ quotaSection = `${dim("1d:")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1205
1337
  } else {
1206
- quotaSection = `${dim("1d:")}${dim("--%")} ${dim("1w:")}${bold("\u221E%")}`;
1338
+ quotaSection = `${dim("1d:")}${dim("--%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1207
1339
  }
1208
1340
  }
1209
1341
  if (!quotaSection) {
1210
- quotaSection = `${dim("5h:")}${green("0%")} ${dim("1w:")}${green("0%")}`;
1342
+ quotaSection = `${dim("5h:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))}`;
1211
1343
  }
1212
1344
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1213
- const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", modelLabel ? markerColor(modelLabel) : ""].filter(Boolean).join(" ");
1345
+ // compact: sv + 계정 (모델 라벨 제거)
1346
+ const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
1214
1347
  return { prefix, left: quotaSection, right: compactRight };
1215
1348
  }
1216
1349
 
@@ -1221,9 +1354,9 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1221
1354
  const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1222
1355
  const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
1223
1356
  const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
1224
- quotaSection = `${dim("5h:")}${colorByPercent(fiveP, formatPercentCell(fiveP))} ` +
1357
+ quotaSection = `${dim("5h:")}${tierBar(fiveP, provAnsi)}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1225
1358
  `${dim(formatTimeCell(fiveReset))} ` +
1226
- `${dim("1w:")}${colorByPercent(weekP, formatPercentCell(weekP))} ` +
1359
+ `${dim("1w:")}${tierBar(weekP, provAnsi)}${colorByProvider(weekP, formatPercentCell(weekP), provFn)} ` +
1227
1360
  `${dim(formatTimeCellDH(weekReset))}`;
1228
1361
  }
1229
1362
  }
@@ -1233,17 +1366,17 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1233
1366
  if (bucket) {
1234
1367
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1235
1368
  const rstRemaining = formatResetRemaining(bucket.resetTime) || "n/a";
1236
- quotaSection = `${dim("1d:")}${colorByPercent(usedP, formatPercentCell(usedP))} ${dim(formatTimeCell(rstRemaining))} ` +
1237
- `${dim("1w:")}${bold("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1369
+ quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1370
+ `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1238
1371
  } else {
1239
- quotaSection = `${dim("1d:")}${dim(formatPlaceholderPercentCell())} ` +
1240
- `${dim(formatTimeCell("--h--m"))} ${dim("1w:")}${bold("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1372
+ quotaSection = `${dim("1d:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1373
+ `${dim(formatTimeCell("--h--m"))} ${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1241
1374
  }
1242
1375
  }
1243
1376
 
1244
1377
  // 폴백: 쿼터 데이터 없을 때
1245
1378
  if (!quotaSection) {
1246
- quotaSection = `${dim("5h:")}${dim("--%")} ${dim("1w:")}${dim("--%")}`;
1379
+ quotaSection = `${dim("5h:")}${tierDimBar()}${dim("--%")} ${dim("1w:")}${tierDimBar()}${dim("--%")}`;
1247
1380
  }
1248
1381
 
1249
1382
  const prefix = `${bold(markerColor(`${marker}`))}:`;
@@ -1347,32 +1480,41 @@ async function main() {
1347
1480
  || geminiQuota?.buckets?.find((b) => b.modelId === "gemini-3-flash-preview")
1348
1481
  || null;
1349
1482
 
1350
- // 합산 절약: 달러 누적 (getClaudeRows에서 $ 포맷)
1351
- const totalSvDollar = totalCostSaved;
1483
+ // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
1484
+ const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
1485
+
1486
+ // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
1487
+ CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
1488
+
1489
+ const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
1490
+ const geminiQuotaData = { type: "gemini", quotaBucket: geminiBucket, session: geminiSession };
1352
1491
 
1353
1492
  const rows = [
1354
- ...getClaudeRows(stdin, claudeUsageSnapshot.data, totalSvDollar),
1493
+ ...getClaudeRows(stdin, claudeUsageSnapshot.data, combinedSvPct),
1355
1494
  getProviderRow("codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
1356
- codexBuckets ? { type: "codex", buckets: codexBuckets } : null, codexEmail,
1357
- codexSv, null),
1495
+ codexQuotaData, codexEmail, codexSv, null),
1358
1496
  getProviderRow("gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
1359
- { type: "gemini", quotaBucket: geminiBucket, session: geminiSession }, geminiEmail,
1360
- geminiSv, getGeminiModelLabel(geminiModel)),
1497
+ geminiQuotaData, geminiEmail, geminiSv, null),
1361
1498
  ];
1499
+
1500
+ // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
1501
+ const codexActive = codexBuckets != null;
1502
+ const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null;
1503
+
1362
1504
  let outputLines = renderAlignedRows(rows);
1363
- // maxLines 설정: 알림 배너와 공존할 때 라인 수 제한 (hud.json의 lines 값)
1364
- const maxLines = Number(readJson(HUD_CONFIG_PATH, null)?.lines) || 0;
1365
- if (maxLines > 0 && outputLines.length > maxLines) {
1366
- while (outputLines.length > maxLines) {
1367
- const last = outputLines.pop();
1368
- outputLines[outputLines.length - 1] += ` ${last.trim()}`;
1369
- }
1505
+
1506
+ // 비활성 dim 래핑 (rows 순서: [claude, codex, gemini])
1507
+ if (outputLines.length >= 3) {
1508
+ if (!codexActive) outputLines[1] = `${DIM}${stripAnsi(outputLines[1])}${RESET}`;
1509
+ if (!geminiActive) outputLines[2] = `${DIM}${stripAnsi(outputLines[2])}${RESET}`;
1370
1510
  }
1511
+
1371
1512
  // Context low 메시지 뒤에 HUD가 분리되도록 선행 개행 추가
1372
1513
  const contextPercent = getContextPercent(stdin);
1373
1514
  const contextLowPrefix = contextPercent >= 85 ? "\n" : "";
1374
- // RESET prefix: 이전 렌더 잔여 ANSI 색상 방지
1375
- process.stdout.write(`\x1b[0m${contextLowPrefix}${outputLines.join("\n")}\n`);
1515
+ // 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
1516
+ const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
1517
+ process.stdout.write(`${contextLowPrefix}${resetedLines.join("\n")}\n`);
1376
1518
  }
1377
1519
 
1378
1520
  main().catch(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.0.3",
3
+ "version": "2.2.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {