Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "-"
Expand All @@ -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 {
Expand Down Expand Up @@ -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, "┌", "┬", "┐")
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
44 changes: 29 additions & 15 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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",
},
}

Expand All @@ -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)
Expand Down
Loading