diff --git a/docs/readme/configuration.md b/docs/readme/configuration.md index bb969efc..129f047b 100644 --- a/docs/readme/configuration.md +++ b/docs/readme/configuration.md @@ -171,10 +171,11 @@ Each section has `show: true`, `runsOut` (`true` for `sidebar` and `toast`, `fal ### Colors -Roles are `good`, `warning`, `low`, `empty`, `title`, `provider`, `account`, `label`, `percent`, `reset`, `value`, `error`, `statusName`, `separator`, and `arrows`. Each `colors.` follows the theme when unset. Set a role to `text`, `muted`, `accent`, `green`, `yellow`, `red`, `blue`, `bar`, or a `#rrggbb` color. `bar` follows the row's level color. The Colors tab's **Color preset** sets every role at once: `theme colors` unsets them all; `grey` sets `good`, `warning`, `provider`, `label` and `arrows` to `muted` and unsets the rest, so the panel title keeps the theme's text color, the rest is grey, and only almost-out limits and errors are red. +Roles are `good`, `warning`, `low`, `empty`, `title`, `provider`, `account`, `label`, `percent`, `reset`, `value`, `error`, `statusName`, `separator`, and `arrows`. Each `colors.` follows the theme when unset. Set a role to `text`, `muted`, `accent`, `green`, `yellow`, `red`, `blue`, `bar`, or a `#rrggbb` color. `bar` follows the row's level color: by the share left against `colors.warningBelow` and `colors.lowBelow`, or by pace with `colors.levelBy: "pace"`. The Colors tab's **Color preset** sets every role at once: `theme colors` unsets them all; `grey` sets `good`, `warning`, `provider`, `label` and `arrows` to `muted` and unsets the rest, so the panel title keeps the theme's text color, the rest is grey, and only almost-out limits and errors are red. | Setting | Default | Values | | --- | --- | --- | +| `colors.levelBy` | `"left"` | `left` or `pace`. With `pace`, the level colors follow how fast a limit is used against even use until its reset: `good` at or under even use (an untouched limit included), `warning` up to 5 points faster than even use, `low` faster than that. Before a tenth of the window has passed, a fast pace says too little, so the row follows the share left, and a nearly used-up limit is still `low`. A row without a reset time or a known window length follows the share left. Toasts after a reply still go by the share left. **Bar colors follow** on the Colors tab. | | `colors.warningBelow` | `50` | Whole number 0–100, percent left below which quota is getting low. | | `colors.lowBelow` | `20` | Whole number 0–100, percent left below which quota is almost out. | diff --git a/src/core/pace.ts b/src/core/pace.ts index ff4e84a7..b151934a 100644 --- a/src/core/pace.ts +++ b/src/core/pace.ts @@ -8,7 +8,7 @@ import { WINDOWS } from "./windows.js"; /** Within this many percentage points of even use counts as on track. */ const ON_TRACK_POINTS = 5; /** Before this share of the window has passed, the pace so far says too little for a run-out time. */ -const MIN_ELAPSED_SHARE = 0.1; +export const MIN_ELAPSED_SHARE = 0.1; export type Pace = { /** The percent that would be left now with even use: the share of the window still to come. */ diff --git a/src/settings/schema.ts b/src/settings/schema.ts index aec01daa..804b8f13 100644 --- a/src/settings/schema.ts +++ b/src/settings/schema.ts @@ -125,6 +125,11 @@ export const SETTINGS_SCHEMA = { "colors.statusName": { kind: "color", default: undefined }, "colors.separator": { kind: "color", default: undefined }, "colors.arrows": { kind: "color", default: undefined }, + /** + * What the bar colors follow: "left", the share left against the two levels below; "pace", how + * fast the limit is used against even use until its reset (the share left without a reset). + */ + "colors.levelBy": choice(["left", "pace"], "left"), "colors.warningBelow": { ...whole(0, 100), default: 50 }, "colors.lowBelow": { ...whole(0, 100), default: 20 }, "layout.bar": choice(["blocks", "shades", "line", "squares", "dots", "none"], "blocks"), diff --git a/src/tui/display.ts b/src/tui/display.ts index f303659d..b3a1b12a 100644 --- a/src/tui/display.ts +++ b/src/tui/display.ts @@ -39,6 +39,7 @@ type StatusDetail = Settings["statusLine"]["detail"]; export type DisplaySettings = Settings & RowSettings & { thresholds: { warningBelow: number; lowBelow: number }; + levelBy: Settings["colors"]["levelBy"]; runsOut: Record; }; @@ -94,6 +95,7 @@ export function displaySettings(settings: Settings): DisplaySettings { warningBelow: Math.max(settings.colors.warningBelow, settings.colors.lowBelow), lowBelow: settings.colors.lowBelow, }, + levelBy: settings.colors.levelBy, runsOut: Object.fromEntries( SURFACES.map((surface) => [surface, settings[surface].runsOut]), ) as Record, @@ -289,6 +291,7 @@ const SHOW = { models: named({ false: "all", true: "current model's only" }), tokens: named({ current: "this session", tree: "with subagents" }), limitHit: named({ false: "retry like normal", true: "wait for the reset" }), + levelBy: named({ left: "the share left", pace: "your pace" }), } satisfies Record; // "Getting low" stays at or above "almost out", so ‹ › skip the values that would break that. const warningChoices = (settings: DisplaySettings) => @@ -329,6 +332,9 @@ export const FIELDS: readonly Field[] = [ field("toast.sessionTokens", "Session tokens line", SHOW.tokensLine), runsOutField("toast", "after the row"), ...COLOR_FIELDS.slice(0, 4), + field("colors.levelBy", "Bar colors follow", SHOW.levelBy, { + note: "pace: at or under even use, a little faster, or faster than it refills; the share left without a reset or in a window's first tenth", + }), field("colors.warningBelow", "'Getting low' below", percentLeft, { values: THRESHOLDS, choices: warningChoices, diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 274b8db4..ac3eae21 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -4,7 +4,7 @@ * call, so a surface that colors inside its JSX follows a theme or light/dark switch at once. */ import { RGBA } from "@opentui/core"; -import { levelOf } from "../view/format.js"; +import { type Level, levelOf } from "../view/format.js"; import type { ColorRole, Segment } from "../view/text.js"; import { type ColorChoice, type ColorName, type DisplaySettings, THEME_COLORS } from "./display.js"; @@ -51,9 +51,18 @@ function choiceFor(display: DisplaySettings, role: ColorRole): ColorChoice { return display.colors[role] ?? THEME_COLORS[role]; } -/** The bar color for a percent left: plenty left, getting low, or almost out. */ -export function levelColor(theme: Theme, display: DisplaySettings, percent: number): RGBA { - const role = levelOf(percent, display.thresholds); +/** + * The bar color for a percent left: plenty left, getting low, or almost out. With the bar colors + * following pace, a row's level by pace wins; a row without one falls back to its percent. + */ +export function levelColor( + theme: Theme, + display: DisplaySettings, + percent: number, + paceLevel?: Level, +): RGBA { + const role = + display.levelBy === "pace" && paceLevel ? paceLevel : levelOf(percent, display.thresholds); return fixedColor(theme, choiceFor(display, role), THEME_COLORS[role] as ColorName); } @@ -63,14 +72,17 @@ export function roleColor( display: DisplaySettings, role: ColorRole, percent = 100, + paceLevel?: Level, ): RGBA { const choice = choiceFor(display, role); - return choice === "bar" ? levelColor(theme, display, percent) : fixedColor(theme, choice, "text"); + return choice === "bar" + ? levelColor(theme, display, percent, paceLevel) + : fixedColor(theme, choice, "text"); } /** The color of one line segment. */ export function segmentColor(theme: Theme, display: DisplaySettings, segment: Segment): RGBA { return segment.role === "bar" - ? levelColor(theme, display, segment.percent ?? 100) - : roleColor(theme, display, segment.role, segment.percent); + ? levelColor(theme, display, segment.percent ?? 100, segment.paceLevel) + : roleColor(theme, display, segment.role, segment.percent, segment.paceLevel); } diff --git a/src/tui/window/limits.ts b/src/tui/window/limits.ts index afff82da..bccf4ae1 100644 --- a/src/tui/window/limits.ts +++ b/src/tui/window/limits.ts @@ -18,7 +18,14 @@ import { columnsOf, padEndColumns, padStartColumns, sliceColumns } from "../../v import { type BarShape, DEFAULT_WORDS, fit } from "../../view/format.js"; import { errorWords, type Place, type RowFacts, rowFacts } from "../../view/row-facts.js"; import { type Group, type RowSettings, visibleGroups } from "../../view/select.js"; -import { barSegments, type Layout, type Line, type Segment, trimEnd } from "../../view/text.js"; +import { + barSegments, + type Layout, + type Line, + type Segment, + trimEnd, + withPaceLevel, +} from "../../view/text.js"; /** Before a provider's name. */ const HEADING_INDENT = " "; @@ -122,6 +129,7 @@ function rowLines(row: RowFacts, grid: Grid, width: number, shape: BarShape): Li const value = padStartColumns(fit(row.value, room).trimEnd(), room); return [{ id, segments: [label, { text: value, role: "value" }] }]; } + const tag = (segments: Segment[]) => withPaceLevel(segments, row.paceLevel); const bar = barSegments(shape, percent, grid.bar, row.evenLeft); const percentSegment: Segment = { text: `${GAP}${padStartColumns(row.value, grid.percent)}`, @@ -140,18 +148,18 @@ function rowLines(row: RowFacts, grid: Grid, width: number, shape: BarShape): Li segments.push({ text, role: "reset", percent }); } if (pace) segments.push({ ...pace, text: `${WIDE_GAP}${pace.text}` }); - return [{ id, segments: trimEnd(segments) }]; + return [{ id, segments: tag(trimEnd(segments)) }]; } const under: Segment = { text: " ".repeat(grid.barColumn), role: "label" }; - const lines: Line[] = [{ id, segments: [label, ...bar, percentSegment] }]; + const lines: Line[] = [{ id, segments: tag([label, ...bar, percentSegment]) }]; const resetSegment: Segment = { text: reset, role: "reset", percent }; const together = grid.barColumn + columnsOf(reset) + 3 + columnsOf(pace?.text ?? ""); if (reset && pace && together <= width) { const dot: Segment = { text: " · ", role: "reset", percent }; - lines.push({ id, segments: [under, resetSegment, dot, pace] }); + lines.push({ id, segments: tag([under, resetSegment, dot, pace]) }); return lines; } - if (reset) lines.push({ id, segments: [under, resetSegment] }); + if (reset) lines.push({ id, segments: tag([under, resetSegment]) }); if (pace) lines.push({ id, segments: [under, pace] }); return lines; } diff --git a/src/view/format.ts b/src/view/format.ts index 29e324d2..6abb04fb 100644 --- a/src/view/format.ts +++ b/src/view/format.ts @@ -4,6 +4,7 @@ * Money, token counts and shares are written the way T3 Code's usage page writes them, always * in US English, so the text is the same on every machine. */ +import { MIN_ELAPSED_SHARE, type Pace } from "../core/pace.js"; import type { Amount, QuotaRow, SessionTokens, Words } from "../core/types.js"; import type { Settings } from "../settings/schema.js"; import { columnsOf, padEndColumns, sliceColumns } from "./columns.js"; @@ -138,6 +139,21 @@ export function levelOf(percentRemaining: number, thresholds: Thresholds): Level return left < thresholds.warningBelow ? "warning" : "good"; } +/** + * The level for a pace: "good" at or under even use, "warning" up to 5 points faster, "low" + * faster than that. At a window's start even use leaves about 100%, so an untouched limit is + * on track; it reads as good, not as a warning. Before a tenth of the window has passed, one + * burst of use reads as fast, so a fast pace gives no level and the row keeps the share-left + * colors: a nearly used-up limit is still "low" then. + */ +export function paceLevel(pace: Pace, percentRemaining: number): Level | undefined { + if (pace.kind === "spare") return "good"; + if (pace.kind === "on_track") { + return clampPercent(percentRemaining) >= pace.evenLeft ? "good" : "warning"; + } + return pace.evenLeft > (1 - MIN_ELAPSED_SHARE) * 100 ? undefined : "low"; +} + function groupDigits(integer: string): string { return integer.replace(/\B(?=(\d{3})+(?!\d))/gu, ","); } diff --git a/src/view/row-facts.ts b/src/view/row-facts.ts index fe20d239..10c7f7a0 100644 --- a/src/view/row-facts.ts +++ b/src/view/row-facts.ts @@ -8,7 +8,7 @@ import { describeError } from "../core/errors.js"; import { type Pace, paceOf } from "../core/pace.js"; import type { Words } from "../core/types.js"; import { WINDOWS } from "../core/windows.js"; -import { formatDuration, percentText, rowValueText } from "./format.js"; +import { formatDuration, type Level, paceLevel, percentText, rowValueText } from "./format.js"; import type { Group, VisibleRow } from "./select.js"; /** How one place shows rows. */ @@ -44,6 +44,8 @@ export type RowFacts = { name: string; /** Its percent left as the row has it, for bars and level colors; undefined without one. */ percent?: number; + /** Its level by pace, for the bar colors when they follow pace; undefined without a pace. */ + paceLevel?: Level; /** Its percent in the place's style, or the value of a row without one ("$1,240.50 left"). */ value: string; /** The time until it resets in the place's style ("2h 14m", "3.2d"); "" without a percent. */ @@ -97,10 +99,12 @@ export function rowFacts(item: VisibleRow, group: Pick, place: Pl const resetIn = (style: Words["reset"]) => Number.isFinite(resetMs) ? formatDuration(resetMs - nowMs, style, words.resetDecimals) : ""; const pace = paceOf(row, nowMs); + const level = pace && paceLevel(pace, percent); return { id: row.id, name, percent, + ...(level ? { paceLevel: level } : {}), value: percentText(words, percent, withWord), reset: resetIn(times), shortReset: resetIn("short"), diff --git a/src/view/text.ts b/src/view/text.ts index fcc8392d..20fc66cb 100644 --- a/src/view/text.ts +++ b/src/view/text.ts @@ -13,6 +13,7 @@ import { cacheHit, evenUseCell, fit, + type Level, LOW_CACHE_MARK, percentWord, sessionTokensText, @@ -40,9 +41,24 @@ export type ColorRole = /** * A piece of a line. `role` "bar" is the filled part of a bar, drawn in its level color. - * `percent` is the row's percent left, for roles that follow the bar color. + * `percent` is the row's percent left, for roles that follow the bar color, and `paceLevel` its + * level by pace, for when the bar colors follow pace. */ -export type Segment = { text: string; role: ColorRole | "bar"; percent?: number; bold?: boolean }; +export type Segment = { + text: string; + role: ColorRole | "bar"; + percent?: number; + paceLevel?: Level; + bold?: boolean; +}; + +/** `segments` with the row's level by pace on each one that follows the bar color. */ +export function withPaceLevel(segments: Segment[], paceLevel: Level | undefined): Segment[] { + if (!paceLevel) return segments; + return segments.map((segment) => + segment.percent === undefined ? segment : { ...segment, paceLevel }, + ); +} /** A line of a surface. `id` is the provider or row it shows ("" for a spacer). */ export type Line = { id: string; segments: Segment[] }; @@ -294,12 +310,15 @@ export function sidebarLines(input: SidebarInput): Line[] { const labelText = `${" ".repeat(layout.indent)}${fit(row.name, nameWidth)} `; return { id: row.id, - segments: [ - { text: labelText, role: "label", percent }, - ...barSegments(layout.bar, percent, barWidth, row.evenLeft), - { text: row.value.padStart(5), role: "percent", percent }, - { text: row.reset.padStart(resetWidth), role: "reset", percent }, - ], + segments: withPaceLevel( + [ + { text: labelText, role: "label", percent }, + ...barSegments(layout.bar, percent, barWidth, row.evenLeft), + { text: row.value.padStart(5), role: "percent", percent }, + { text: row.reset.padStart(resetWidth), role: "reset", percent }, + ], + row.paceLevel, + ), }; }; @@ -382,15 +401,18 @@ export function collapsedLines(input: CollapsedInput): Line[] { return { id: group.providerId, segments: clip( - [ - name, - { text: `${fit(picked.name, 4)} `, role: "label", percent }, - ...barSegments(layout.bar, percent, barWidth, picked.evenLeft), - { text: picked.value.padStart(percentWidth), role: "percent", percent }, - error - ? { text: ` ${error}`, role: "error" } - : { text: ` ${picked.reset.padStart(resetWidth)}`, role: "reset", percent }, - ], + withPaceLevel( + [ + name, + { text: `${fit(picked.name, 4)} `, role: "label", percent }, + ...barSegments(layout.bar, percent, barWidth, picked.evenLeft), + { text: picked.value.padStart(percentWidth), role: "percent", percent }, + error + ? { text: ` ${error}`, role: "error" } + : { text: ` ${picked.reset.padStart(resetWidth)}`, role: "reset", percent }, + ], + picked.paceLevel, + ), input.width, ), }; @@ -453,18 +475,21 @@ function statusSegment(input: StatusInput, group: Group, level: StatusLevel): Li : undefined; return { id: group.providerId, - segments: [ - name, - ...(withBar - ? [ - { text: " ", role: "separator" as const }, - ...barSegments(layout.bar, percent, STATUS_BAR_WIDTH, picked.evenLeft), - ] - : []), - { text: ` ${picked.value}`, role: "percent", percent }, - ...(after ? [after] : []), - ...(picked.pace ? [{ text: ` (${picked.pace.words})`, role: "warning" as const }] : []), - ], + segments: withPaceLevel( + [ + name, + ...(withBar + ? [ + { text: " ", role: "separator" as const }, + ...barSegments(layout.bar, percent, STATUS_BAR_WIDTH, picked.evenLeft), + ] + : []), + { text: ` ${picked.value}`, role: "percent", percent }, + ...(after ? [after] : []), + ...(picked.pace ? [{ text: ` (${picked.pace.words})`, role: "warning" as const }] : []), + ], + picked.paceLevel, + ), }; } @@ -551,13 +576,16 @@ export function bigStatusLine(input: StatusInput, width?: number): Line | undefi : picked.reset ? { text: ` · ${picked.reset}`, role: "reset", percent } : undefined; - const segments: Segment[] = [ - { text: `${statusName(input, group)} ${picked.name} `, role: "statusName", percent }, - ...barSegments(input.layout.bar, percent, 10, picked.evenLeft), - { text: ` ${picked.value}`, role: "percent", percent }, - ...(after ? [after] : []), - ...(picked.pace ? [{ text: ` · ${picked.pace.words}`, role: "warning" as const }] : []), - ]; + const segments = withPaceLevel( + [ + { text: `${statusName(input, group)} ${picked.name} `, role: "statusName", percent }, + ...barSegments(input.layout.bar, percent, 10, picked.evenLeft), + { text: ` ${picked.value}`, role: "percent", percent }, + ...(after ? [after] : []), + ...(picked.pace ? [{ text: ` · ${picked.pace.words}`, role: "warning" as const }] : []), + ], + picked.paceLevel, + ); return { id: group.providerId, segments: width === undefined ? segments : clip(segments, width) }; } @@ -681,16 +709,19 @@ function groupedToastLines(input: ToastInput): Line[] { } const name = `${" ".repeat(GROUPED_INDENT)}${fit(row.name, nameWidth)} `; const number = row.value.padStart(4); - const segments: Segment[] = [ - { text: name, role: "label", percent }, - ...(withBar ? barSegments(layout.bar, percent, barWidth, row.evenLeft) : []), - { - text: `${withBar ? " " : ""}${word ? `${number} ${word}` : number}`, - role: "percent", - percent, - }, - ...(row.reset ? [{ text: ` ${row.reset}`, role: "reset" as const, percent }] : []), - ]; + const segments = withPaceLevel( + [ + { text: name, role: "label", percent }, + ...(withBar ? barSegments(layout.bar, percent, barWidth, row.evenLeft) : []), + { + text: `${withBar ? " " : ""}${word ? `${number} ${word}` : number}`, + role: "percent", + percent, + }, + ...(row.reset ? [{ text: ` ${row.reset}`, role: "reset" as const, percent }] : []), + ], + row.paceLevel, + ); const line: Line = { id: row.id, segments: clip(segments, room) }; return row.pace ? [line, runsOutLine(row.id, row.pace.words, barColumn, room)] : [line]; }; @@ -746,14 +777,12 @@ function detailedToastLines(input: ToastInput): Line[] { { text: ` ${percentWords}`, role: "percent", percent }, ] : [{ text: percentWords.padStart(room), role: "percent", percent }]; + const nameLine = row.reset + ? spread(name, { text: row.reset, role: "reset", percent }, room) + : clip([name], room); return [ - { - id: row.id, - segments: row.reset - ? spread(name, { text: row.reset, role: "reset", percent }, room) - : clip([name], room), - }, - { id: row.id, segments: clip(bar, room) }, + { id: row.id, segments: withPaceLevel(nameLine, row.paceLevel) }, + { id: row.id, segments: withPaceLevel(clip(bar, room), row.paceLevel) }, ...(row.pace ? [runsOutLine(row.id, row.pace.words, 0, room)] : []), ]; }; diff --git a/tests/settings.tabs.test.ts b/tests/settings.tabs.test.ts index 0f221b72..2a28b303 100644 --- a/tests/settings.tabs.test.ts +++ b/tests/settings.tabs.test.ts @@ -86,6 +86,7 @@ const EXPECTED = { statusName: undefined, separator: undefined, arrows: undefined, + levelBy: "left", warningBelow: 50, lowBelow: 20, }, diff --git a/tests/tui.plugin.test.ts b/tests/tui.plugin.test.ts index ea21c3e7..e6cf96fb 100644 --- a/tests/tui.plugin.test.ts +++ b/tests/tui.plugin.test.ts @@ -23,7 +23,7 @@ vi.mock("solid-js", async (importOriginal) => ({ }, })); -function provider(label: string, percentRemaining?: number): ProviderSnapshot { +function provider(label: string, percentRemaining?: number, resetsAt?: string): ProviderSnapshot { const providerId = label.toLowerCase(); return { providerId, @@ -42,6 +42,7 @@ function provider(label: string, percentRemaining?: number): ProviderSnapshot { window: "week", prominence: "primary", percentRemaining, + ...(resetsAt ? { resetsAt } : {}), }, ], okAtMs: NOW, @@ -81,6 +82,8 @@ function startToasts(answer: QuotaSnapshot | undefined, change: (d: DisplaySetti const OK = snapshot([provider("Claude", 80)]); const LOW = snapshot([provider("Claude", 10)]); +/** 60% left with 5 of its 7 days to come: used faster than it refills, but not getting low. */ +const FAST = snapshot([provider("Claude", 60, new Date(NOW + 5 * 86_400_000).toISOString())]); describe("quota toasts", () => { it.each<{ @@ -120,6 +123,16 @@ describe("quota toasts", () => { }, shown: false, }, + { + name: "not after a reply for a fast pace alone, with the bar colors following pace", + trigger: "reply", + answer: FAST, + change: (display) => { + display.toast.afterReply = "low"; + display.levelBy = "pace"; + }, + shown: false, + }, { name: "never when the toast surface is off", trigger: "compaction", diff --git a/tests/tui.toast-colors.test.ts b/tests/tui.toast-colors.test.ts index db977aea..a9a01109 100644 --- a/tests/tui.toast-colors.test.ts +++ b/tests/tui.toast-colors.test.ts @@ -41,10 +41,10 @@ const PROVIDERS = [ provider("openrouter", "OpenRouter", [], { error: { code: "login_expired" } }), ]; -/** A grouped toast of PROVIDERS, 54 columns wide. */ -function lines() { +/** A grouped toast of `providers`, 54 columns wide. */ +function lines(providers = PROVIDERS) { const display = defaultDisplaySettings(); - const snapshot = { version: 6, providers: PROVIDERS, resetNotices: [], generatedAtMs: NOW }; + const snapshot = { version: 6, providers, resetNotices: [], generatedAtMs: NOW }; return toastLines({ groups: visibleGroups(snapshot, display, "toast", DEFAULT_WORDS), words: DEFAULT_WORDS, @@ -102,6 +102,60 @@ describe("colored quota toasts", () => { ]); }); + it("colors the bars by pace when they follow it, and by the share left without a pace", () => { + const toast = lines([ + provider("anthropic", "Claude", [ + // 15% left 10 minutes before the reset: plenty to spare. + quotaRow("anthropic.five_hour", { + label: "5h", + window: "five_hour", + percentRemaining: 15, + resetsAt: at(10 * MINUTE), + }), + // 60% left with 5 of its 7 days to come: used faster than it refills. + quotaRow("anthropic.weekly", { + label: "Weekly", + window: "week", + percentRemaining: 60, + resetsAt: at(5 * 24 * 60 * MINUTE), + }), + // No reset, so no pace: 15% left stays almost out. + quotaRow("anthropic.monthly", { label: "Monthly", window: "month", percentRemaining: 15 }), + ]), + provider("zai", "Z.ai", [ + // 5% left 10 minutes into the window: too early for pace, and nearly used up. + quotaRow("zai.five_hour", { + label: "5h", + window: "five_hour", + percentRemaining: 5, + resetsAt: at(290 * MINUTE), + }), + ]), + ]); + const percentColors = (levelBy: "left" | "pace") => + ( + coloredMessage(toast, THEME as unknown as Theme, { + ...defaultDisplaySettings(), + levelBy, + }) as Array + ) + .filter((child): child is Node => typeof child !== "string") + .filter(({ props }) => String(props.children).endsWith("% left")) + .map(({ props }) => [String(props.children).trim(), props.style.fg]); + expect(percentColors("left")).toEqual([ + ["15% left", "red"], + ["60% left", "green"], + ["15% left", "red"], + ["5% left", "red"], + ]); + expect(percentColors("pace")).toEqual([ + ["15% left", "green"], + ["60% left", "red"], + ["15% left", "red"], + ["5% left", "red"], + ]); + }); + /** A toast shown through an OpenCode toast that `show` stands for, once the TUI has drawn. */ function showOn({ show = () => {}, diff --git a/tests/view.row-facts.test.ts b/tests/view.row-facts.test.ts index 0c088941..b930eba0 100644 --- a/tests/view.row-facts.test.ts +++ b/tests/view.row-facts.test.ts @@ -141,6 +141,16 @@ describe("row facts", () => { ]).toEqual([50, undefined, undefined]); }); + it("gives a row with a pace its level by pace, whatever pace words the place shows", () => { + const noReset = quotaRow("x.a", { percentRemaining: 30 }); + expect([ + factsOf(paced(80)).paceLevel, + factsOf(paced(47)).paceLevel, + factsOf(paced(30), { pace: "all" }).paceLevel, + factsOf(noReset).paceLevel, + ]).toEqual(["good", "warning", "low", undefined]); + }); + it("says a group's last error in the words of `describeError`, with the group's name", () => { const failed = { name: "Claude", error: { code: "network" } } as const; expect([errorWords(failed, NOW), errorWords({ name: "Claude" }, NOW)]).toEqual([ diff --git a/tests/view.test.ts b/tests/view.test.ts index 9f7799e0..40b96ab4 100644 --- a/tests/view.test.ts +++ b/tests/view.test.ts @@ -16,6 +16,7 @@ import { fit as fitToWidth, formatDuration, levelOf, + paceLevel, rowValueText, } from "../src/view/format.js"; import { @@ -163,6 +164,27 @@ describe("view format", () => { expect(levelOf(percent, { warningBelow: 50, lowBelow: 20 })).toBe(expected); }); + it.each([ + { kind: "spare", evenLeft: 50, left: 80, expected: "good" }, + { kind: "on_track", evenLeft: 50, left: 54, expected: "good" }, + { kind: "on_track", evenLeft: 50, left: 50, expected: "good" }, + { kind: "on_track", evenLeft: 50, left: 46, expected: "warning" }, + // Untouched at the window's start, when even use still leaves about 100%. + { kind: "on_track", evenLeft: 98, left: 100, expected: "good" }, + { kind: "fast", evenLeft: 50, left: 30, expected: "low" }, + // Under a tenth of the window passed: no level, so the share left decides. + { kind: "fast", evenLeft: 95, left: 50, expected: undefined }, + { kind: "fast", evenLeft: 95, left: 0, expected: undefined }, + { kind: "fast", evenLeft: 90, left: 50, expected: "low" }, + ] as const)("$kind with $left% left and $evenLeft% of the window to come is $expected", ({ + kind, + evenLeft, + left, + expected, + }) => { + expect(paceLevel({ evenLeft, kind }, left)).toBe(expected); + }); + it.each([ { name: "dollars",