Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/readme/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<role>` 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.<role>` 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. |

Expand Down
2 changes: 1 addition & 1 deletion src/core/pace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions src/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions src/tui/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Surface, boolean>;
};

Expand Down Expand Up @@ -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<Surface, boolean>,
Expand Down Expand Up @@ -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<string, Show>;
// "Getting low" stays at or above "almost out", so ‹ › skip the values that would break that.
const warningChoices = (settings: DisplaySettings) =>
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 19 additions & 7 deletions src/tui/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
18 changes: 13 additions & 5 deletions src/tui/window/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = " ";
Expand Down Expand Up @@ -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)}`,
Expand All @@ -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;
}
Expand Down
16 changes: 16 additions & 0 deletions src/view/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, ",");
}
Expand Down
6 changes: 5 additions & 1 deletion src/view/row-facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -97,10 +99,12 @@ export function rowFacts(item: VisibleRow, group: Pick<Group, "rows">, 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"),
Expand Down
Loading