Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
+33
-10
@@ -2,8 +2,8 @@
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { subscriptionLabels, subscriptionLabel, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
@@ -12,6 +12,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
return limit === null ? numerator : `${numerator} / ${formatTokens(limit, language)}`;
|
||||
}
|
||||
|
||||
/** CLI aliases have no global catalog limit: only this session's matching
|
||||
* response can supply a denominator. Other providers keep their catalog join. */
|
||||
export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap<string, number> | undefined,
|
||||
cliWindow: ReturnType<typeof cliContextWindowOf> = null): number | null {
|
||||
if (!model) return null;
|
||||
if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null;
|
||||
return limits?.get(model) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The below-composer stats bar (wave 4 observability): the session's
|
||||
* money/context footprint as one quiet row beneath the composer —
|
||||
@@ -26,25 +35,31 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
* Spending arrives already priced by the server through Pi's catalog and
|
||||
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||
* transcript estimate. Its catalog denominator follows the selected model;
|
||||
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||
* transcript estimate. Its denominator follows the selected model, from the
|
||||
* catalog or the session's CLI report; model changes preserve the numerator.
|
||||
* Successful compaction clears the numerator and any old CLI report.
|
||||
*/
|
||||
export function StatsBar({
|
||||
turns,
|
||||
contextTokens = null,
|
||||
cliContextWindow = null,
|
||||
pricing,
|
||||
dictationSlot,
|
||||
chatId,
|
||||
leadingSlot,
|
||||
onModelChange,
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
|
||||
pricing?: SessionPricing;
|
||||
/** The bound session (per-session models, owner 2026-09-28): present
|
||||
* on the in-session bar — the model control binds THE SESSION'S own
|
||||
* record; absent on the draft surfaces, where it binds the strong
|
||||
* slot (the new-session default). */
|
||||
chatId?: string;
|
||||
/** The same effective identity the visible model control owns. */
|
||||
onModelChange?: (model: string | undefined) => void;
|
||||
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
|
||||
* space between the session data and the model control — the mic
|
||||
* button lives there, and while recording the meter unfurls to fill
|
||||
@@ -58,6 +73,10 @@ export function StatsBar({
|
||||
const { t, language } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const modelChanged = useCallback((model: string | undefined) => {
|
||||
setSelectedModel(model);
|
||||
onModelChange?.(model);
|
||||
}, [onModelChange]);
|
||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -82,18 +101,19 @@ export function StatsBar({
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
// Catalog-only denominator; the picker reports its effective selection.
|
||||
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||
// The picker reports its effective selection. CLI windows are session-
|
||||
// scoped; unknown metadata always means usage-only, never a guessed maximum.
|
||||
const { catalog } = useModelCatalog();
|
||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||
const limit = contextLimitForModel(selectedModel, limits, cliContextWindow);
|
||||
const contextLabel = formatContextMeasurement(contextTokens, limit, language);
|
||||
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers, (value) => formatUsd(value, language));
|
||||
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers, (value) => formatUsd(value, language));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -146,6 +166,9 @@ export function StatsBar({
|
||||
{contextLabel}
|
||||
</span>
|
||||
</div>
|
||||
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
|
||||
<p>{t("Window reported by Claude Code; may be lower for auto-compaction.")}</p>
|
||||
)}
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{subscription ? t("Usage") : t("Total cost")}</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
@@ -172,7 +195,7 @@ export function StatsBar({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||
<span className="text-muted-foreground">{turn.steps.length}{" "}{t("steps")}</span>
|
||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
|
||||
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
@@ -193,7 +216,7 @@ export function StatsBar({
|
||||
{/* The model + thinking control — one controller for the conversation
|
||||
agents (prober/teacher); the record's `turns` above still folds
|
||||
the ACTUAL models billed, regardless of what the picker says. */}
|
||||
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||
<ModelControl chatId={chatId} onModelChange={modelChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user