From 737751aa4b5171d014a1b962ef7194cd90daa6d6 Mon Sep 17 00:00:00 2001 From: YG Park Date: Mon, 10 Aug 2026 17:22:02 +0900 Subject: [PATCH] Add emoji to usage pace --- internal/cli/run.go | 30 +++++++++++++++++---------- internal/cli/run_test.go | 44 ++++++++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/internal/cli/run.go b/internal/cli/run.go index 3882b05..ff8ab06 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -340,7 +340,7 @@ func appendUsageRow(rows [][]string, showProvider bool, values ...string) [][]st func usagePace(limit api.UsageLimit, now time.Time) string { if limit.LimitReached || limit.UsedPercent >= 100 { - return "LIMIT REACHED" + return "⛔ LIMIT REACHED" } if limit.WindowSeconds == nil || limit.ResetAt == nil || *limit.WindowSeconds <= 0 { return "-" @@ -358,33 +358,33 @@ func usagePace(limit api.UsageLimit, now time.Time) string { elapsed := window - remaining if elapsed < maxDuration(time.Minute, window/100) { - return "TOO EARLY" + return "⏳ TOO EARLY" } projected := limit.UsedPercent / elapsed.Seconds() * window.Seconds() if limit.UsedPercent <= 0 { - return "OK · ~100% left at reset" + return "✅ OK · ~100% left at reset" } if projected >= 100 { rate := limit.UsedPercent / elapsed.Seconds() if math.Abs(projected-100) < 1e-9 { - return "RUNS OUT at reset" + return "🔥 RUNS OUT at reset" } runOut := time.Duration((100 - limit.UsedPercent) / rate * float64(time.Second)) if runOut >= remaining { - return "RUNS OUT at reset" + return "🔥 RUNS OUT at reset" } if runOut <= 0 { - return "RUNS OUT" + return "🔥 RUNS OUT" } - return "RUNS OUT in " + strings.TrimPrefix(formatUsageTimeRemaining(runOut), "in ") + return "🔥 RUNS OUT in " + strings.TrimPrefix(formatUsageTimeRemaining(runOut), "in ") } if projected <= 90 { remainingPercent := max(0, int(math.Round(100-projected))) - return fmt.Sprintf("OK · ~%d%% left at reset", remainingPercent) + return fmt.Sprintf("✅ OK · ~%d%% left at reset", remainingPercent) } spare := max(1, int(math.Round(100-projected))) - return fmt.Sprintf("CLOSE · ~%d%% spare at reset", spare) + return fmt.Sprintf("⚠️ CLOSE · ~%d%% spare at reset", spare) } func maxDuration(a, b time.Duration) time.Duration { @@ -451,7 +451,7 @@ func printTable(output io.Writer, rows [][]string, rightAligned map[int]bool, se for _, row := range rows { for column, value := range row { row[column] = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value) - widths[column] = max(widths[column], utf8.RuneCountInString(row[column])) + widths[column] = max(widths[column], tableDisplayWidth(row[column])) } } printTableBorder(output, widths, "┌", "┬", "┐") @@ -461,7 +461,7 @@ func printTable(output io.Writer, rows [][]string, rightAligned map[int]bool, se } fmt.Fprint(output, "│") for column, value := range row { - padding := widths[column] - utf8.RuneCountInString(value) + padding := widths[column] - tableDisplayWidth(value) if rightAligned[column] && index > 0 { fmt.Fprintf(output, " %s%s │", strings.Repeat(" ", padding), value) } else { @@ -476,6 +476,14 @@ func printTable(output io.Writer, rows [][]string, rightAligned map[int]bool, se printTableBorder(output, widths, "└", "┴", "┘") } +func tableDisplayWidth(value string) int { + width := utf8.RuneCountInString(value) + for _, emoji := range []string{"✅", "🔥", "⛔", "⏳"} { + width += strings.Count(value, emoji) + } + return width +} + func printTableBorder(output io.Writer, widths []int, left string, middle string, right string) { fmt.Fprint(output, left) for column, width := range widths { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 3427689..d748d78 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -165,14 +165,14 @@ func TestChatGPTUsageOutputShowsEveryLimitAndPartialErrors(t *testing.T) { }} var output bytes.Buffer printUsageAt(&output, usage, time.Date(2026, 8, 7, 9, 24, 55, 0, time.FixedZone("KST", 9*60*60))) - want := `┌────────────────────┬──────┬─────────────────────┬─────────┬──────┬───────────┬─────────────────────────────────────┬────────────────────┐ -│ NAME │ PLAN │ LIMIT │ WINDOW │ USED │ REMAINING │ RESET │ PACE │ -├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┼────────────────────┤ -│ person@example.com │ pro │ default │ primary │ 88% │ 12% │ 2026-08-11 09:24 KST (in 4d) │ RUNS OUT in 9h 49m │ -│ │ │ GPT-5.3-Codex-Spark │ primary │ 0% │ 100% │ - │ - │ -├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┼────────────────────┤ -│ other@example.com │ - │ - │ - │ - │ - │ ERROR: ChatGPT usage is unavailable │ - │ -└────────────────────┴──────┴─────────────────────┴─────────┴──────┴───────────┴─────────────────────────────────────┴────────────────────┘ + want := `┌────────────────────┬──────┬─────────────────────┬─────────┬──────┬───────────┬─────────────────────────────────────┬───────────────────────┐ +│ NAME │ PLAN │ LIMIT │ WINDOW │ USED │ REMAINING │ RESET │ PACE │ +├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┼───────────────────────┤ +│ person@example.com │ pro │ default │ primary │ 88% │ 12% │ 2026-08-11 09:24 KST (in 4d) │ 🔥 RUNS OUT in 9h 49m │ +│ │ │ GPT-5.3-Codex-Spark │ primary │ 0% │ 100% │ - │ - │ +├────────────────────┼──────┼─────────────────────┼─────────┼──────┼───────────┼─────────────────────────────────────┼───────────────────────┤ +│ other@example.com │ - │ - │ - │ - │ - │ ERROR: ChatGPT usage is unavailable │ - │ +└────────────────────┴──────┴─────────────────────┴─────────┴──────┴───────────┴─────────────────────────────────────┴───────────────────────┘ ` if output.String() != want { t.Fatalf("output =\n%s\nwant:\n%s", output.String(), want) @@ -199,27 +199,27 @@ func TestUsagePaceProjectsQuotaAtReset(t *testing.T) { { name: "healthy", limit: api.UsageLimit{UsedPercent: 30, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "OK · ~40% left at reset", + want: "✅ OK · ~40% left at reset", }, { name: "close", limit: api.UsageLimit{UsedPercent: 46, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "CLOSE · ~8% spare at reset", + want: "⚠️ CLOSE · ~8% spare at reset", }, { name: "running out", limit: api.UsageLimit{UsedPercent: 60, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "RUNS OUT in 2d 8h", + want: "🔥 RUNS OUT in 2d 8h", }, { name: "limit reached", limit: api.UsageLimit{UsedPercent: 100, LimitReached: true, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "LIMIT REACHED", + want: "⛔ LIMIT REACHED", }, { name: "too early", limit: api.UsageLimit{UsedPercent: 1, ResetAt: resetAt(0.005), WindowSeconds: window()}, - want: "TOO EARLY", + want: "⏳ TOO EARLY", }, { name: "missing duration", @@ -229,12 +229,12 @@ func TestUsagePaceProjectsQuotaAtReset(t *testing.T) { { name: "exactly at limit", limit: api.UsageLimit{UsedPercent: 50, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "RUNS OUT at reset", + want: "🔥 RUNS OUT at reset", }, { name: "unused", limit: api.UsageLimit{UsedPercent: 0, ResetAt: resetAt(0.5), WindowSeconds: window()}, - want: "OK · ~100% left at reset", + want: "✅ OK · ~100% left at reset", }, } @@ -247,6 +247,20 @@ func TestUsagePaceProjectsQuotaAtReset(t *testing.T) { } } +func TestTableDisplayWidthHandlesUsageEmoji(t *testing.T) { + for value, want := range map[string]int{ + "✅ OK": 5, + "⚠️ CLOSE": 8, + "🔥 RUNS OUT": 11, + "⛔ LIMIT": 8, + "⏳ EARLY": 8, + } { + if got := tableDisplayWidth(value); got != want { + t.Fatalf("tableDisplayWidth(%q) = %d, want %d", value, got, want) + } + } +} + func TestUsageResetFormattingUsesLocalTimeAndFriendlyRemainingTime(t *testing.T) { location := time.FixedZone("KST", 9*60*60) now := time.Date(2026, 8, 7, 12, 0, 0, 0, location)