triflux 2.0.3 → 2.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.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
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // tfx-doctor — triflux doctor 바로가기
3
3
  import { dirname } from "path";
4
4
  import { fileURLToPath } from "url";
package/bin/tfx-setup.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // tfx-setup — triflux setup 바로가기
3
3
  import { dirname } from "path";
4
4
  import { fileURLToPath } from "url";
package/bin/triflux.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
3
  import { copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "fs";
4
4
  import { join, dirname } from "path";
@@ -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.8";
11
11
 
12
12
  // ============================================================================
13
13
  // ANSI 색상 (OMC colors.js 스키마 일치)
@@ -52,17 +52,24 @@ function colorParallel(current, cap) {
52
52
  return red(`${current}/${cap}`);
53
53
  }
54
54
 
55
- function coloredBar(percent, width = 8) {
55
+ function coloredBar(percent, width = 8, baseColor = null) {
56
56
  const safePercent = Math.min(100, Math.max(0, percent));
57
57
  const filled = Math.round((safePercent / 100) * width);
58
58
  const empty = width - filled;
59
59
  let barColor;
60
60
  if (safePercent >= 85) barColor = RED;
61
61
  else if (safePercent >= 70) barColor = YELLOW;
62
- else barColor = GREEN;
62
+ else barColor = baseColor || GREEN;
63
63
  return `${barColor}${"█".repeat(filled)}${DIM}${"░".repeat(empty)}${RESET}`;
64
64
  }
65
65
 
66
+ // 프로바이더별 색상 % (< 70%: 프로바이더 색, ≥ 70%: 경고색)
67
+ function colorByProvider(value, text, providerColorFn) {
68
+ if (value >= 85) return red(text);
69
+ if (value >= 70) return yellow(text);
70
+ return providerColorFn(text);
71
+ }
72
+
66
73
  // ============================================================================
67
74
  // 상수 / 경로
68
75
  // ============================================================================
@@ -151,6 +158,28 @@ function getTerminalColumns() {
151
158
  return 0;
152
159
  }
153
160
 
161
+ let _cachedRows = 0;
162
+ function getTerminalRows() {
163
+ if (_cachedRows > 0) return _cachedRows;
164
+ if (process.stdout.rows) { _cachedRows = process.stdout.rows; return _cachedRows; }
165
+ if (process.stderr.rows) { _cachedRows = process.stderr.rows; return _cachedRows; }
166
+ const envLines = Number(process.env.LINES);
167
+ if (envLines > 0) { _cachedRows = envLines; return _cachedRows; }
168
+ try {
169
+ if (process.platform === "win32") {
170
+ const raw = execSync("mode con", { timeout: 2000, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }).toString();
171
+ const m = raw.match(/Lines[^:]*:\s*(\d+)/i) || raw.match(/줄[^:]*:\s*(\d+)/);
172
+ if (m) { _cachedRows = Number(m[1]); return _cachedRows; }
173
+ } else {
174
+ const raw = execSync("tput lines 2>/dev/null || stty size 2>/dev/null | awk '{print $1}'", {
175
+ timeout: 2000, stdio: ["pipe", "pipe", "pipe"],
176
+ }).toString().trim();
177
+ if (raw && !isNaN(Number(raw))) { _cachedRows = Number(raw); return _cachedRows; }
178
+ }
179
+ } catch { /* 감지 실패 */ }
180
+ return 0;
181
+ }
182
+
154
183
  function detectCompactMode() {
155
184
  // 1. 명시적 CLI 플래그
156
185
  if (process.argv.includes("--compact")) return true;
@@ -191,6 +220,74 @@ function detectMinimalMode() {
191
220
 
192
221
  const MINIMAL_MODE = detectMinimalMode();
193
222
 
223
+ // ============================================================================
224
+ // 4-Tier 적응형 렌더링: full > normal > compact > nano
225
+ // ============================================================================
226
+ // 초기 tier (stdin 없이 결정 가능한 수준)
227
+ let CURRENT_TIER = MINIMAL_MODE ? "nano" : COMPACT_MODE ? "compact" : "normal";
228
+
229
+ /**
230
+ * 인디케이터 인식 + 터미널 크기 기반 tier 자동 선택.
231
+ * main()에서 stdin 수신 후 호출하여 CURRENT_TIER 갱신.
232
+ */
233
+ function selectTier(stdin, claudeUsage = null) {
234
+ const hudConfig = readJson(HUD_CONFIG_PATH, null);
235
+
236
+ // 1) 명시적 tier 강제 설정
237
+ const forcedTier = hudConfig?.tier;
238
+ if (["full", "normal", "compact", "nano"].includes(forcedTier)) return forcedTier;
239
+
240
+ // 2) 기존 모드 플래그 존중
241
+ if (MINIMAL_MODE) return "nano";
242
+ if (COMPACT_MODE) return "compact";
243
+
244
+ // 3) autoResize 비활성이면 normal 유지
245
+ if (hudConfig?.autoResize === false) return "normal";
246
+
247
+ // 4) 터미널 행/열에서 상태영역 예산 추정
248
+ const rows = getTerminalRows();
249
+ const cols = getTerminalColumns() || 120;
250
+ let budget;
251
+ if (rows >= 40) budget = 6;
252
+ else if (rows >= 35) budget = 5;
253
+ else if (rows >= 28) budget = 4;
254
+ else if (rows >= 22) budget = 3;
255
+ else if (rows > 0) budget = 2;
256
+ else budget = 5; // rows 감지 불가 → 넉넉하게
257
+
258
+ // 5) 인디케이터 줄 추정
259
+ let indicatorRows = 1; // bypass permissions (거의 항상 표시)
260
+ const contextPercent = getContextPercent(stdin);
261
+ if (contextPercent >= 85) indicatorRows += 1; // "Context low" 배너
262
+ // Claude Code 사용량 경고 (노란색 배너: "You've used X% of your ... limit")
263
+ const weeklyPct = claudeUsage?.weeklyPercent ?? 0;
264
+ const fiveHourPct = claudeUsage?.fiveHourPercent ?? 0;
265
+ if (weeklyPct >= 80) indicatorRows += 1;
266
+ if (fiveHourPct >= 80) indicatorRows += 1;
267
+
268
+ // 6) 각 tier에서 줄바꿈 없이 3줄 가용한지 확인
269
+ const tierWidths = { full: 75, normal: 60, compact: 40, nano: 34 };
270
+ for (const tier of ["full", "normal", "compact", "nano"]) {
271
+ const lineWidth = tierWidths[tier];
272
+ const visualRowsPerLine = Math.ceil(lineWidth / Math.max(cols, 1));
273
+ const totalVisualRows = (3 * visualRowsPerLine) + indicatorRows;
274
+ if (totalVisualRows <= budget) return tier;
275
+ }
276
+ return "nano";
277
+ }
278
+
279
+ // full tier 전용: 게이지 바 접두사 (normal 이하 tier에서는 빈 문자열)
280
+ function tierBar(percent, baseColor = null) {
281
+ return CURRENT_TIER === "full" ? `${coloredBar(percent, 8, baseColor)} ` : "";
282
+ }
283
+ function tierDimBar() {
284
+ return CURRENT_TIER === "full" ? `${dim("░".repeat(8))} ` : "";
285
+ }
286
+ // Gemini ∞% 전용: 무한 쿼터이므로 dim 회색 바
287
+ function tierInfBar() {
288
+ return CURRENT_TIER === "full" ? `${DIM}${"█".repeat(8)}${RESET} ` : "";
289
+ }
290
+
194
291
  // ============================================================================
195
292
  // 유틸
196
293
  // ============================================================================
@@ -308,15 +405,11 @@ function normalizeTimeToken(value) {
308
405
  const text = String(value || "n/a");
309
406
  const hourMinute = text.match(/^(\d+)h(\d+)m$/);
310
407
  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`;
408
+ return `${Number(hourMinute[1])}h${Number(hourMinute[2])}m`;
314
409
  }
315
410
  const dayHour = text.match(/^(\d+)d(\d+)h$/);
316
411
  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`;
412
+ return `${Number(dayHour[1])}d${Number(dayHour[2])}h`;
320
413
  }
321
414
  return text;
322
415
  }
@@ -655,11 +748,11 @@ function formatResetRemaining(isoOrUnix) {
655
748
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
656
749
  if (isNaN(d.getTime())) return "";
657
750
  const diffMs = d.getTime() - Date.now();
658
- if (diffMs <= 0) return "00h00m";
751
+ if (diffMs <= 0) return "0h0m";
659
752
  const totalMinutes = Math.floor(diffMs / 60000);
660
753
  const totalHours = Math.floor(totalMinutes / 60);
661
754
  const minutes = totalMinutes % 60;
662
- return `${String(totalHours).padStart(2, "0")}h${String(minutes).padStart(2, "0")}m`;
755
+ return `${totalHours}h${minutes}m`;
663
756
  }
664
757
 
665
758
  function isResetPast(isoOrUnix) {
@@ -673,11 +766,11 @@ function formatResetRemainingDayHour(isoOrUnix) {
673
766
  const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
674
767
  if (isNaN(d.getTime())) return "";
675
768
  const diffMs = d.getTime() - Date.now();
676
- if (diffMs <= 0) return "0d00h";
769
+ if (diffMs <= 0) return "0d0h";
677
770
  const totalMinutes = Math.floor(diffMs / 60000);
678
771
  const days = Math.floor(totalMinutes / (60 * 24));
679
772
  const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
680
- return `${days}d${String(hours).padStart(2, "0")}h`;
773
+ return `${days}d${hours}h`;
681
774
  }
682
775
 
683
776
  function calcCooldownLeftSeconds(isoDatetime) {
@@ -1056,13 +1149,22 @@ function formatSavings(dollars) {
1056
1149
  return `$${dollars.toFixed(2)}`;
1057
1150
  }
1058
1151
 
1059
- function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1152
+ // sv 퍼센트 포맷 (1000+ k 표기, 5자 고정폭)
1153
+ const SV_CELL_WIDTH = 5;
1154
+ function formatSvPct(value) {
1155
+ if (value == null) return "--%".padStart(SV_CELL_WIDTH);
1156
+ if (value >= 10000) return `${Math.round(value / 1000)}k%`.padStart(SV_CELL_WIDTH);
1157
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k%`.padStart(SV_CELL_WIDTH);
1158
+ return `${value}%`.padStart(SV_CELL_WIDTH);
1159
+ }
1160
+
1161
+ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
1060
1162
  const contextPercent = getContextPercent(stdin);
1061
1163
  const prefix = `${bold(claudeOrange("c"))}:`;
1062
1164
 
1063
- // 절약 달러 누적 (Codex+Gemini 합산)
1064
- const svText = totalSvDollar > 0 ? formatSavings(totalSvDollar) : "$0";
1065
- const svSuffix = `${dim("sv:")}${cyan(svText.padStart(6))}`;
1165
+ // 절약 퍼센트 (Codex+Gemini sv% 합산, x/g와 동일 형식)
1166
+ const svStr = formatSvPct(combinedSvPct || 0);
1167
+ const svSuffix = `${dim("sv:")}${svStr}`;
1066
1168
 
1067
1169
  // API 실측 데이터 사용 (없으면 플레이스홀더)
1068
1170
  const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
@@ -1076,7 +1178,7 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1076
1178
 
1077
1179
  const hasData = claudeUsage != null;
1078
1180
 
1079
- if (MINIMAL_MODE) {
1181
+ if (CURRENT_TIER === "nano") {
1080
1182
  const cols = getTerminalColumns() || 80;
1081
1183
  if (!hasData) {
1082
1184
  const quotaSection = cols < 40
@@ -1085,38 +1187,37 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1085
1187
  return [{ prefix, left: quotaSection, right: "" }];
1086
1188
  }
1087
1189
  if (cols < 40) {
1088
- // 초소형: c: 12%/8% ctx:45%
1089
- const quotaSection = `${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)}${dim("/")}` +
1090
- `${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
1190
+ const quotaSection = `${colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange)}${dim("/")}` +
1191
+ `${colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange)} ` +
1091
1192
  `${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1092
1193
  return [{ prefix, left: quotaSection, right: "" }];
1093
1194
  }
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}%`)}`;
1195
+ // nano: c: 5h 12% 1w 95% sv: 191% ctx:90%
1196
+ const quotaSection = `${dim("5h")} ${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1197
+ `${dim("1w")} ${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1198
+ `${dim("sv:")}${svStr} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1099
1199
  return [{ prefix, left: quotaSection, right: "" }];
1100
1200
  }
1101
1201
 
1102
- if (COMPACT_MODE) {
1103
- // 데이터 없을 때: 전체 dim 처리 (green 0% 대신)
1202
+ if (CURRENT_TIER === "compact") {
1104
1203
  if (!hasData) {
1105
- const quotaSection = `${dim("5h: --% 1w: --% ")}` +
1106
- `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1204
+ const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
1205
+ `${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
1206
+ `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1107
1207
  return [{ prefix, left: quotaSection, right: "" }];
1108
1208
  }
1109
- const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, `${fiveHourPercent}%`)} ` +
1110
- `${dim("1w:")}${colorByPercent(weeklyPercent, `${weeklyPercent}%`)} ` +
1111
- `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1209
+ // compact: c: 5h: 14% 1w: 96% | sv: 191% ctx:43%
1210
+ const quotaSection = `${dim("5h:")}${colorByProvider(fiveHourPercent, formatPercentCell(fiveHourPercent), claudeOrange)} ` +
1211
+ `${dim("1w:")}${colorByProvider(weeklyPercent, formatPercentCell(weeklyPercent), claudeOrange)} ` +
1212
+ `${dim("|")} ${svSuffix} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1112
1213
  return [{ prefix, left: quotaSection, right: "" }];
1113
1214
  }
1114
1215
 
1115
1216
  // 데이터 없을 때: 퍼센트+시간 모두 dim 처리 (green 0% 대신)
1116
1217
  if (!hasData) {
1117
- const quotaSection = `${dim("5h:")}${dim(formatPlaceholderPercentCell())} ` +
1218
+ const quotaSection = `${dim("5h:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1118
1219
  `${dim(formatTimeCell(fiveHourReset))} ` +
1119
- `${dim("1w:")}${dim(formatPlaceholderPercentCell())} ` +
1220
+ `${dim("1w:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1120
1221
  `${dim(formatTimeCellDH(weeklyReset))}`;
1121
1222
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1122
1223
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1126,9 +1227,9 @@ function getClaudeRows(stdin, claudeUsage, totalSvDollar) {
1126
1227
  const weeklyPercentCell = formatPercentCell(weeklyPercent);
1127
1228
  const fiveHourTimeCell = formatTimeCell(fiveHourReset);
1128
1229
  const weeklyTimeCell = formatTimeCellDH(weeklyReset);
1129
- const quotaSection = `${dim("5h:")}${colorByPercent(fiveHourPercent, fiveHourPercentCell)} ` +
1230
+ const quotaSection = `${dim("5h:")}${tierBar(fiveHourPercent, CLAUDE_ORANGE)}${colorByProvider(fiveHourPercent, fiveHourPercentCell, claudeOrange)} ` +
1130
1231
  `${dim(fiveHourTimeCell)} ` +
1131
- `${dim("1w:")}${colorByPercent(weeklyPercent, weeklyPercentCell)} ` +
1232
+ `${dim("1w:")}${tierBar(weeklyPercent, CLAUDE_ORANGE)}${colorByProvider(weeklyPercent, weeklyPercentCell, claudeOrange)} ` +
1132
1233
  `${dim(weeklyTimeCell)}`;
1133
1234
  const contextSection = `${svSuffix} ${dim("|")} ${dim("ctx:")}${colorByPercent(contextPercent, `${contextPercent}%`)}`;
1134
1235
  return [{ prefix, left: quotaSection, right: contextSection }];
@@ -1151,26 +1252,30 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1151
1252
 
1152
1253
  // 절약 퍼센트 섹션 (context window 대비 %, 4자리 고정폭)
1153
1254
  const svPct = savingsMultiplier != null ? Math.round(savingsMultiplier * 100) : null;
1154
- const svStr = svPct != null ? `${svPct}%`.padStart(6) : "--%".padStart(6);
1255
+ const svStr = formatSvPct(svPct);
1155
1256
  const modelLabelStr = modelLabel ? ` ${markerColor(modelLabel)}` : "";
1156
1257
 
1258
+ // ── 프로바이더별 색상 프로필 ──
1259
+ const provAnsi = provider === "codex" ? CODEX_WHITE : provider === "gemini" ? GEMINI_BLUE : GREEN;
1260
+ const provFn = provider === "codex" ? codexWhite : provider === "gemini" ? geminiBlue : green;
1261
+
1157
1262
  // ── 쿼터 섹션 ──
1158
1263
  let quotaSection;
1159
1264
  let extraRightSection = "";
1160
1265
 
1161
- if (MINIMAL_MODE) {
1266
+ if (CURRENT_TIER === "nano") {
1162
1267
  const cols = getTerminalColumns() || 80;
1163
1268
  const minPrefix = `${bold(markerColor(`${marker}`))}:`;
1164
- const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr.trim())}` : "";
1269
+ const svCompact = svStr ? ` ${dim("sv:")}${cyan(svStr)}` : "";
1165
1270
  if (realQuota?.type === "codex") {
1166
1271
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1167
1272
  if (main) {
1168
1273
  const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1169
1274
  const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1170
1275
  if (cols < 40) {
1171
- return { prefix: minPrefix, left: `${colorByPercent(fiveP, `${fiveP}%`)}${dim("/")}${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
1276
+ return { prefix: minPrefix, left: `${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)}${dim("/")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1172
1277
  }
1173
- return { prefix: minPrefix, left: `${dim("5h")} ${colorByPercent(fiveP, `${fiveP}%`)} ${dim("1w")} ${colorByPercent(weekP, `${weekP}%`)}${svCompact}`, right: "" };
1278
+ return { prefix: minPrefix, left: `${dim("5h")} ${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ${dim("1w")} ${colorByProvider(weekP, formatPercentCell(weekP), provFn)}${svCompact}`, right: "" };
1174
1279
  }
1175
1280
  }
1176
1281
  if (realQuota?.type === "gemini") {
@@ -1178,39 +1283,39 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1178
1283
  if (bucket) {
1179
1284
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1180
1285
  if (cols < 40) {
1181
- return { prefix: minPrefix, left: `${colorByPercent(usedP, `${usedP}%`)}${svCompact}${modelLabelStr}`, right: "" };
1286
+ return { prefix: minPrefix, left: `${colorByProvider(usedP, formatPercentCell(usedP), provFn)}${svCompact}`, right: "" };
1182
1287
  }
1183
- return { prefix: minPrefix, left: `${dim("1d")} ${colorByPercent(usedP, `${usedP}%`)} ${dim("1w")} ${bold("\u221E%")}${svCompact}${modelLabelStr}`, right: "" };
1288
+ return { prefix: minPrefix, left: `${dim("1d")} ${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w")} ${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}${svCompact}`, right: "" };
1184
1289
  }
1185
1290
  }
1186
- return { prefix: minPrefix, left: dim("--%"), right: "" };
1291
+ return { prefix: minPrefix, left: dim("--%".padStart(PERCENT_CELL_WIDTH)), right: "" };
1187
1292
  }
1188
1293
 
1189
- if (COMPACT_MODE) {
1190
- // 컴팩트 모드: 바 없이 퍼센트만, right 섹션 생략
1294
+ if (CURRENT_TIER === "compact") {
1191
1295
  if (realQuota?.type === "codex") {
1192
1296
  const main = realQuota.buckets.codex || realQuota.buckets[Object.keys(realQuota.buckets)[0]];
1193
1297
  if (main) {
1194
1298
  const fiveP = isResetPast(main.primary?.resets_at) ? 0 : clampPercent(main.primary?.used_percent ?? 0);
1195
1299
  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}%`)}`;
1300
+ quotaSection = `${dim("5h:")}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1301
+ `${dim("1w:")}${colorByProvider(weekP, formatPercentCell(weekP), provFn)}`;
1198
1302
  }
1199
1303
  }
1200
1304
  if (realQuota?.type === "gemini") {
1201
1305
  const bucket = realQuota.quotaBucket;
1202
1306
  if (bucket) {
1203
1307
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1204
- quotaSection = `${dim("1d:")}${colorByPercent(usedP, `${usedP}%`)} ${dim("1w:")}${bold("\u221E%")}`;
1308
+ quotaSection = `${dim("1d:")}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1205
1309
  } else {
1206
- quotaSection = `${dim("1d:")}${dim("--%")} ${dim("1w:")}${bold("\u221E%")}`;
1310
+ quotaSection = `${dim("1d:")}${dim("--%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))}`;
1207
1311
  }
1208
1312
  }
1209
1313
  if (!quotaSection) {
1210
- quotaSection = `${dim("5h:")}${green("0%")} ${dim("1w:")}${green("0%")}`;
1314
+ quotaSection = `${dim("5h:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))} ${dim("1w:")}${provFn("0%".padStart(PERCENT_CELL_WIDTH))}`;
1211
1315
  }
1212
1316
  const prefix = `${bold(markerColor(`${marker}`))}:`;
1213
- const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", modelLabel ? markerColor(modelLabel) : ""].filter(Boolean).join(" ");
1317
+ // compact: sv + 계정 (모델 라벨 제거)
1318
+ const compactRight = [svStr ? `${dim("sv:")}${svStr}` : "", accountLabel ? markerColor(accountLabel) : ""].filter(Boolean).join(" ");
1214
1319
  return { prefix, left: quotaSection, right: compactRight };
1215
1320
  }
1216
1321
 
@@ -1221,9 +1326,9 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1221
1326
  const weekP = isResetPast(main.secondary?.resets_at) ? 0 : clampPercent(main.secondary?.used_percent ?? 0);
1222
1327
  const fiveReset = formatResetRemaining(main.primary?.resets_at) || "n/a";
1223
1328
  const weekReset = formatResetRemainingDayHour(main.secondary?.resets_at) || "n/a";
1224
- quotaSection = `${dim("5h:")}${colorByPercent(fiveP, formatPercentCell(fiveP))} ` +
1329
+ quotaSection = `${dim("5h:")}${tierBar(fiveP, provAnsi)}${colorByProvider(fiveP, formatPercentCell(fiveP), provFn)} ` +
1225
1330
  `${dim(formatTimeCell(fiveReset))} ` +
1226
- `${dim("1w:")}${colorByPercent(weekP, formatPercentCell(weekP))} ` +
1331
+ `${dim("1w:")}${tierBar(weekP, provAnsi)}${colorByProvider(weekP, formatPercentCell(weekP), provFn)} ` +
1227
1332
  `${dim(formatTimeCellDH(weekReset))}`;
1228
1333
  }
1229
1334
  }
@@ -1233,17 +1338,17 @@ function getProviderRow(provider, marker, markerColor, qosProfile, accountsConfi
1233
1338
  if (bucket) {
1234
1339
  const usedP = clampPercent((1 - (bucket.remainingFraction ?? 1)) * 100);
1235
1340
  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"))}`;
1341
+ quotaSection = `${dim("1d:")}${tierBar(usedP, provAnsi)}${colorByProvider(usedP, formatPercentCell(usedP), provFn)} ${dim(formatTimeCell(rstRemaining))} ` +
1342
+ `${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1238
1343
  } else {
1239
- quotaSection = `${dim("1d:")}${dim(formatPlaceholderPercentCell())} ` +
1240
- `${dim(formatTimeCell("--h--m"))} ${dim("1w:")}${bold("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1344
+ quotaSection = `${dim("1d:")}${tierDimBar()}${dim(formatPlaceholderPercentCell())} ` +
1345
+ `${dim(formatTimeCell("--h--m"))} ${dim("1w:")}${tierInfBar()}${dim("\u221E%".padStart(PERCENT_CELL_WIDTH))} ${dim(formatTimeCellDH("-d--h"))}`;
1241
1346
  }
1242
1347
  }
1243
1348
 
1244
1349
  // 폴백: 쿼터 데이터 없을 때
1245
1350
  if (!quotaSection) {
1246
- quotaSection = `${dim("5h:")}${dim("--%")} ${dim("1w:")}${dim("--%")}`;
1351
+ quotaSection = `${dim("5h:")}${tierDimBar()}${dim("--%")} ${dim("1w:")}${tierDimBar()}${dim("--%")}`;
1247
1352
  }
1248
1353
 
1249
1354
  const prefix = `${bold(markerColor(`${marker}`))}:`;
@@ -1347,32 +1452,41 @@ async function main() {
1347
1452
  || geminiQuota?.buckets?.find((b) => b.modelId === "gemini-3-flash-preview")
1348
1453
  || null;
1349
1454
 
1350
- // 합산 절약: 달러 누적 (getClaudeRows에서 $ 포맷)
1351
- const totalSvDollar = totalCostSaved;
1455
+ // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
1456
+ const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
1457
+
1458
+ // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
1459
+ CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
1460
+
1461
+ const codexQuotaData = codexBuckets ? { type: "codex", buckets: codexBuckets } : null;
1462
+ const geminiQuotaData = { type: "gemini", quotaBucket: geminiBucket, session: geminiSession };
1352
1463
 
1353
1464
  const rows = [
1354
- ...getClaudeRows(stdin, claudeUsageSnapshot.data, totalSvDollar),
1465
+ ...getClaudeRows(stdin, claudeUsageSnapshot.data, combinedSvPct),
1355
1466
  getProviderRow("codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
1356
- codexBuckets ? { type: "codex", buckets: codexBuckets } : null, codexEmail,
1357
- codexSv, null),
1467
+ codexQuotaData, codexEmail, codexSv, null),
1358
1468
  getProviderRow("gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
1359
- { type: "gemini", quotaBucket: geminiBucket, session: geminiSession }, geminiEmail,
1360
- geminiSv, getGeminiModelLabel(geminiModel)),
1469
+ geminiQuotaData, geminiEmail, geminiSv, null),
1361
1470
  ];
1471
+
1472
+ // 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
1473
+ const codexActive = codexBuckets != null;
1474
+ const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null;
1475
+
1362
1476
  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
- }
1477
+
1478
+ // 비활성 dim 래핑 (rows 순서: [claude, codex, gemini])
1479
+ if (outputLines.length >= 3) {
1480
+ if (!codexActive) outputLines[1] = `${DIM}${stripAnsi(outputLines[1])}${RESET}`;
1481
+ if (!geminiActive) outputLines[2] = `${DIM}${stripAnsi(outputLines[2])}${RESET}`;
1370
1482
  }
1483
+
1371
1484
  // Context low 메시지 뒤에 HUD가 분리되도록 선행 개행 추가
1372
1485
  const contextPercent = getContextPercent(stdin);
1373
1486
  const contextLowPrefix = contextPercent >= 85 ? "\n" : "";
1374
- // RESET prefix: 이전 렌더 잔여 ANSI 색상 방지
1375
- process.stdout.write(`\x1b[0m${contextLowPrefix}${outputLines.join("\n")}\n`);
1487
+ // 줄별 RESET: Claude Code TUI 스타일 간섭 방지 (색상 밝기 버그 수정)
1488
+ const resetedLines = outputLines.map(line => `\x1b[0m${line}`);
1489
+ process.stdout.write(`${contextLowPrefix}${resetedLines.join("\n")}\n`);
1376
1490
  }
1377
1491
 
1378
1492
  main().catch(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "2.0.3",
3
+ "version": "2.1.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": {