contents/ui/components/ProviderCostSection.qml
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import "." as Components
import "../CostPresentation.js" as CostPresentation
import "../QuotaWindowCost.js" as QuotaWindowCost
ColumnLayout {
id: tokenCostSection
objectName: "providerLocalCostSection"
required property var applet
required property var providerData
property string accountSelectionKey: applet.accountKey(providerData)
property string accountSelectionLabel: applet.accountLabel(providerData)
property bool presentationVisible: false
property bool detailsExpanded: false
property var daySelectionMemo: ({ points: [], index: -1 })
readonly property bool costHistoryShowsTokens: applet.costHistoryShowsTokens
// Pins belong to the shown account: the addressing identity and the raw
// account label both participate, so a reselect under either spelling
// clears them. The label is read from the unprojected snapshot because
// privacy mode flattens it; the key additionally catches spacing-distinct
// accounts that collapse to one label.
readonly property string selectionScope: providerData ? JSON.stringify([providerData.provider, accountSelectionKey,
accountSelectionLabel, tokenCost ? tokenCost.historyDays : 0, tokenCost ? tokenCost.period : ""]) : ""
readonly property var selectedDay: CostPresentation.selectedCostDay(tokenCost ? tokenCost.daily : [], chartPoints, costChart.selectedIndex)
readonly property bool hasVisibleDetails: detailsExpanded || selectedDay !== null
onSelectionScopeChanged: {
detailsExpanded = false;
clearDaySelection();
}
onCostHistoryShowsTokensChanged: clearDaySelection()
Components.CostLabels {
id: costLabels
applet: tokenCostSection.applet
}
function amountText(amounts, mode) {
return CostPresentation.hasMetricValue(amounts, false) ? applet.qualifiedCostValue(applet.amountString(amounts.cost, amounts.currency), mode) : i18n("Cost unavailable");
}
function tokensText(amounts) {
return CostPresentation.hasMetricValue(amounts, true) ? applet.usageCountText(amounts.tokens, "tokens") : i18n("Tokens unavailable");
}
function daySummaryText() {
var summary = CostPresentation.amountSummary(applet.costNumberFormat, selectedDay, function (tokens) {
return applet.usageCountText(tokens, "tokens");
});
var excluded = excludedRequestsText(CostPresentation.acceptedIncompleteRequests(selectedDay));
return excluded.length > 0 ? summary + " · " + excluded : summary;
}
// The CLI left these requests out of the row's amounts; the count is stated
// beside the row instead of being folded into a figure.
function excludedRequestsText(count) {
return count > 0 ? i18np("%1 incomplete request excluded", "%1 incomplete requests excluded", count) : "";
}
function clearDaySelection() {
if (!costChart)
return;
daySelectionMemo = { points: costChart.points, index: -1 };
costChart.selectedIndex = -1;
costChart.hoveredIndex = -1;
}
function rememberDaySelection() {
// Ignore the chart's index clamp while a new points array is awaiting reconciliation.
if (daySelectionMemo.points === costChart.points)
daySelectionMemo = { points: costChart.points, index: costChart.selectedIndex };
}
function reconcileDaySelection() {
var points = costChart.points;
var index = CostPresentation.costDayIndexAfterRefresh(daySelectionMemo.points, daySelectionMemo.index, points);
daySelectionMemo = { points: points, index: index };
costChart.selectedIndex = index;
costChart.hoveredIndex = -1;
}
readonly property var tokenCost: tokenCostSection.providerData ? tokenCostSection.providerData.tokenCost : null
// Mutated only by CostPresentation.memoizedChartPoints; never reassigned, so it adds no binding dependency.
readonly property var chartPointsMemo: ({})
readonly property var chartPoints: tokenCost ? applet.costChartPoints(tokenCost.daily, chartPointsMemo) : []
readonly property var costTrustSummary: CostPresentation.costTrustSummary(tokenCost ? [tokenCost] : [])
readonly property string costErrorText: applet.privateErrorText(applet.costErrorText)
readonly property bool supportsLocalCost: tokenCostSection.providerData && applet.tokenCostHint(tokenCostSection.providerData.provider).length > 0
visible: tokenCostSection.tokenCost ? true : tokenCostSection.supportsLocalCost && tokenCostSection.costErrorText.length > 0
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
Components.TimeLabels {
id: timeLabels
}
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
PlainHeading {
text: i18n("Cost")
level: 4
type: Kirigami.Heading.Type.Primary
Layout.fillWidth: true
elide: Text.ElideRight
}
Components.PlainComboBox {
objectName: "providerCostMetricCombo"
visible: tokenCostSection.tokenCost !== null
// A view option inside a secondary section: flat, so it does not
// outweigh the quota meters above it.
flat: true
textRole: "text"
valueRole: "value"
model: [
{
text: i18n("Cost"),
value: "cost"
},
{
text: i18n("Tokens"),
value: "tokens"
}
]
currentIndex: tokenCostSection.costHistoryShowsTokens ? 1 : 0
Accessible.name: i18n("History metric")
onActivated: function (index) {
tokenCostSection.applet.setCostHistoryMetric(valueAt(index));
// The interactive pick severs the currentIndex binding; restore
// it so the combo keeps tracking settings changes made outside
// this section.
currentIndex = Qt.binding(function () {
return tokenCostSection.costHistoryShowsTokens ? 1 : 0;
});
}
}
}
PlainPlasmaLabel {
visible: tokenCostSection.costErrorText.length > 0
text: tokenCostSection.tokenCost
? i18n("Some local history is unavailable: %1", tokenCostSection.costErrorText)
: i18n("Cost unavailable: %1", tokenCostSection.costErrorText)
color: Kirigami.Theme.negativeTextColor
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
GridLayout {
id: costSummaryGrid
objectName: "costSummaryGrid"
visible: tokenCostSection.tokenCost !== null
Layout.fillWidth: true
columns: 2
columnSpacing: Kirigami.Units.largeSpacing
Repeater {
model: tokenCostSection.tokenCost ? [
{
label: i18n("Today"),
amounts: CostPresentation.todayAmounts(tokenCostSection.tokenCost, tokenCostSection.applet.panelClockMs),
valueMode: "plain"
},
{
label: tokenCostSection.tokenCost.windowLabel,
amounts: tokenCostSection.tokenCost.totals,
valueMode: tokenCostSection.tokenCost.valueMode
}
] : []
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Kirigami.Units.smallSpacing / 2
PlainPlasmaLabel {
text: modelData.label
font: Kirigami.Theme.smallFont
opacity: tokenCostSection.applet.secondaryTextOpacity
Layout.fillWidth: true
wrapMode: Text.Wrap
}
PlainPlasmaLabel {
text: tokenCostSection.amountText(modelData.amounts, modelData.valueMode)
font.weight: Font.DemiBold
Layout.fillWidth: true
wrapMode: Text.Wrap
}
PlainPlasmaLabel {
text: tokenCostSection.tokensText(modelData.amounts)
font: Kirigami.Theme.smallFont
opacity: tokenCostSection.applet.secondaryTextOpacity
Layout.fillWidth: true
wrapMode: Text.Wrap
}
}
}
}
Components.CostTrustNotice {
noticeScope: "provider:" + (tokenCostSection.providerData ? tokenCostSection.providerData.provider : "")
stateOwner: tokenCostSection.applet
presentationVisible: tokenCostSection.presentationVisible
summary: tokenCostSection.costTrustSummary
}
Components.InteractiveChart {
id: costChart
objectName: "providerCostChart"
onPointsChanged: tokenCostSection.reconcileDaySelection()
onSelectedIndexChanged: tokenCostSection.rememberDaySelection()
visible: tokenCostSection.chartPoints.length > 0
applet: tokenCostSection.applet
points: tokenCostSection.chartPoints
accent: applet.providerReadableColor(providerData ? providerData.provider : "")
kind: "bar"
accessibleTitle: tokenCostSection.costHistoryShowsTokens ? i18n("Daily token history") : i18n("Daily cost history")
Layout.topMargin: Kirigami.Units.smallSpacing / 2
}
// The axis already dates both ends of the range and the summary grid
// names the window, so only the latest day's figure remains here. A
// selected day already shows its figure in the chart readout and the
// drill-down heading, so the line would only repeat it a third time.
PlainPlasmaLabel {
id: costSparklineSummaryLabel
objectName: "costSparklineSummaryLabel"
visible: tokenCostSection.chartPoints.length > 0 && tokenCostSection.selectedDay === null
text: tokenCostSection.tokenCost ? costLabels.costSparklineSummary(tokenCostSection.tokenCost.daily) : ""
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
// A disclosure, not a primary action: a flat button keeps it from
// outweighing the figures it reveals.
Components.DisclosureButton {
objectName: "costDetailsToggle"
visible: tokenCostSection.tokenCost !== null
expanded: tokenCostSection.hasVisibleDetails
plainText: tokenCostSection.hasVisibleDetails ? i18n("Hide details") : i18n("Show details")
onClicked: {
if (tokenCostSection.hasVisibleDetails) {
tokenCostSection.detailsExpanded = false;
tokenCostSection.clearDaySelection();
} else {
tokenCostSection.detailsExpanded = true;
}
}
}
ColumnLayout {
id: costDrillDownSection
readonly property var detailData: tokenCostSection.selectedDay ? {
totals: tokenCostSection.selectedDay,
models: tokenCostSection.selectedDay.models,
modelsTruncated: tokenCostSection.selectedDay.modelsTruncated
} : tokenCostSection.tokenCost
readonly property var breakdownRows: costLabels.costBreakdownRows(detailData)
readonly property var modelRows: costLabels.costModelRows(detailData)
readonly property real metricValueColumnWidth: Kirigami.Units.gridUnit * 9
objectName: "costDrillDownSection"
visible: tokenCostSection.tokenCost !== null && tokenCostSection.hasVisibleDetails
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
RowLayout {
Layout.fillWidth: true
PlainPlasmaLabel {
objectName: "costDetailsTitle"
text: tokenCostSection.selectedDay ? i18n("Details for %1", costLabels.costDayLabel(tokenCostSection.selectedDay.label)) : i18n("Cost details")
font.weight: Font.DemiBold
Layout.fillWidth: true
wrapMode: Text.Wrap
}
Components.PlainButton {
plainText: i18n("All days")
visible: tokenCostSection.selectedDay !== null
onClicked: {
tokenCostSection.detailsExpanded = true;
tokenCostSection.clearDaySelection();
}
}
}
PlainPlasmaLabel {
visible: tokenCostSection.selectedDay !== null
text: tokenCostSection.daySummaryText()
Layout.fillWidth: true
wrapMode: Text.Wrap
}
PlainPlasmaLabel {
objectName: "costModelsEmptyNotice"
visible: costDrillDownSection.modelRows.length === 0
text: tokenCostSection.selectedDay !== null ? i18n("No model breakdown for this day.")
: i18n("No model breakdown for this period.")
Layout.fillWidth: true
wrapMode: Text.Wrap
}
PlainPlasmaLabel {
objectName: "costModelsPartialNotice"
visible: costDrillDownSection.detailData !== null && costDrillDownSection.detailData.modelsTruncated === true
text: i18n("Model breakdown is partial.")
Layout.fillWidth: true
wrapMode: Text.Wrap
}
PlainPlasmaLabel {
visible: !tokenCostSection.selectedDay && tokenCostSection.tokenCost && costLabels.costPerMillionLine(tokenCostSection.tokenCost).length > 0
text: tokenCostSection.tokenCost ? costLabels.costPerMillionLine(tokenCostSection.tokenCost) : ""
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
ColumnLayout {
visible: costDrillDownSection.breakdownRows.length > 0
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
Repeater {
model: costDrillDownSection.breakdownRows
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: modelData.label
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
id: costBreakdownValueLabel
text: modelData.value
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: applet.valueTextOpacity
font.weight: Font.Medium
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: costDrillDownSection.metricValueColumnWidth
Layout.maximumWidth: costDrillDownSection.metricValueColumnWidth
elide: Text.ElideRight
}
}
}
}
Kirigami.Separator {
visible: costDrillDownSection.modelRows.length > 0
Layout.fillWidth: true
opacity: 0.55
}
ColumnLayout {
visible: costDrillDownSection.modelRows.length > 0
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
PlainPlasmaLabel {
id: costModelsHeading
text: i18n("Models")
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
font.weight: Font.DemiBold
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
Repeater {
model: costDrillDownSection.modelRows
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: modelData.label
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
id: costModelValueLabel
text: modelData.value
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: applet.valueTextOpacity
font.weight: Font.Medium
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: costDrillDownSection.metricValueColumnWidth
Layout.maximumWidth: costDrillDownSection.metricValueColumnWidth
elide: Text.ElideRight
}
}
PlainPlasmaLabel {
objectName: "costModelExcludedRequests"
visible: text.length > 0
text: tokenCostSection.excludedRequestsText(modelData.incompleteRequests)
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
wrapMode: Text.Wrap
}
}
}
}
}
ColumnLayout {
id: quotaWindowSection
// The weekly quota row supplies the boundaries; the local daily history
// supplies the amounts. Only a row that reports its window length can
// be split, so a restored cache row shows nothing until a live refresh.
readonly property var weeklyRow: {
var rows = tokenCostSection.providerData && Array.isArray(tokenCostSection.providerData.rows)
? tokenCostSection.providerData.rows : [];
for (var i = 0; i < rows.length; i++) {
if (rows[i] && rows[i].windowMinutes === 7 * 24 * 60 && typeof rows[i].resetsAt === "string")
return rows[i];
}
return null;
}
readonly property var windows: weeklyRow && tokenCostSection.tokenCost
? QuotaWindowCost.windows(tokenCostSection.tokenCost.daily, Date.parse(weeklyRow.resetsAt),
weeklyRow.windowMinutes, applet.panelClockMs)
: []
readonly property bool anyEstimated: windows.some(function(window) {
return window.startEstimated || window.endEstimated;
})
function instantText(timestampMs) {
return timeLabels.monthDayTime(timestampMs);
}
function rangeText(window) {
return i18n("%1 - %2", instantText(window.startMs), instantText(window.endMs));
}
function amountText(window) {
var currency = tokenCostSection.tokenCost ? tokenCostSection.tokenCost.currency : "USD";
var historyIncomplete = tokenCostSection.tokenCost
&& tokenCostSection.tokenCost.historyCoverageEstablished === false;
var cost = window.cost === null ? i18n("Cost unavailable")
: (window.costPartial || historyIncomplete ? i18n("at least %1", applet.amountString(window.cost, currency))
: applet.amountString(window.cost, currency));
var tokens = window.tokens === null ? i18n("Tokens unavailable")
: (window.tokensPartial || historyIncomplete ? i18n("at least %1", applet.usageCountText(window.tokens, "tokens"))
: applet.usageCountText(window.tokens, "tokens"));
// Separators and the estimate mark are symbols, composed outside the
// catalog like the other " · " joins in this section.
var text = cost + " · " + tokens;
return window.startEstimated || window.endEstimated ? "≈ " + text : text;
}
objectName: "quotaWindowSection"
visible: tokenCostSection.detailsExpanded && windows.length > 0
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
PlainPlasmaLabel {
text: i18n("Quota weeks")
font.weight: Font.DemiBold
Layout.fillWidth: true
elide: Text.ElideRight
}
Repeater {
model: quotaWindowSection.windows
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: quotaWindowSection.rangeText(modelData)
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
font.weight: modelData.current ? Font.DemiBold : Font.Normal
opacity: modelData.current ? 1 : applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
text: quotaWindowSection.amountText(modelData)
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
horizontalAlignment: Text.AlignRight
elide: Text.ElideLeft
}
}
}
PlainPlasmaLabel {
visible: quotaWindowSection.anyEstimated
text: "≈ " + i18n("The history is kept per day, so a day containing a reset counts toward one week only.")
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
ColumnLayout {
id: costHistoryChartSection
readonly property var rows: costLabels.costHistoryRows(tokenCostSection.tokenCost)
readonly property string peakLine: tokenCostSection.tokenCost ? costLabels.costPeakLine(tokenCostSection.tokenCost.daily) : ""
readonly property string averageLine: tokenCostSection.tokenCost ? costLabels.costAverageDailyLine(tokenCostSection.tokenCost.daily, tokenCostSection.tokenCost.averageDaily) : ""
readonly property color accent: applet.providerReadableColor(tokenCostSection.providerData ? tokenCostSection.providerData.provider : "")
visible: tokenCostSection.detailsExpanded && rows.length > 1
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
RowLayout {
id: costHistoryHeaderRow
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: i18n("Cost history")
font.weight: Font.DemiBold
Layout.alignment: Qt.AlignBaseline
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
visible: costHistoryChartSection.averageLine.length > 0
text: costHistoryChartSection.averageLine
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
horizontalAlignment: Text.AlignRight
Layout.alignment: Qt.AlignBaseline
elide: Text.ElideRight
}
}
PlainPlasmaLabel {
visible: costHistoryChartSection.peakLine.length > 0
text: costHistoryChartSection.peakLine
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
Repeater {
model: costHistoryChartSection.rows
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
spacing: 0
RowLayout {
id: costHistoryMetricRow
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
id: costHistoryDateLabel
text: modelData.label
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: applet.secondaryTextOpacity
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
elide: Text.ElideRight
}
Rectangle {
id: costHistoryBarTrack
Layout.fillWidth: true
Layout.preferredHeight: applet.compactMeterTrackHeight
radius: height / 2
color: applet.withAlpha(Kirigami.Theme.textColor, 0.055)
clip: true
antialiasing: true
Rectangle {
width: parent.width * Math.max(0, Math.min(100, modelData.percent)) / 100
height: parent.height
radius: parent.radius
antialiasing: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: applet.withAlpha(costHistoryChartSection.accent, modelData.isPeak ? 0.72 : 0.46)
}
GradientStop {
position: 1
color: applet.withAlpha(costHistoryChartSection.accent, modelData.isPeak ? 1 : 0.8)
}
}
Behavior on width {
NumberAnimation {
duration: Kirigami.Units.longDuration
easing.type: Easing.OutCubic
}
}
}
}
PlainPlasmaLabel {
id: costHistoryValueLabel
text: modelData.value
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
opacity: modelData.isPeak ? applet.valueTextOpacity : applet.secondaryTextOpacity
font.weight: modelData.isPeak ? Font.DemiBold : Font.Normal
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: Kirigami.Units.gridUnit * 8
elide: Text.ElideRight
}
}
PlainPlasmaLabel {
objectName: "costHistoryExcludedRequests"
visible: text.length > 0
text: tokenCostSection.excludedRequestsText(modelData.incompleteRequests)
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
wrapMode: Text.Wrap
}
}
}
}
ColumnLayout {
visible: tokenCostSection.detailsExpanded
Layout.fillWidth: true
Components.ProjectCostSection {
applet: tokenCostSection.applet
providerCosts: tokenCostSection.tokenCost ? [tokenCostSection.tokenCost] : []
}
}
PlainPlasmaLabel {
visible: tokenCostSection.tokenCost && tokenCostSection.tokenCost.hintLine.length > 0 ? true : false
text: tokenCostSection.tokenCost ? tokenCostSection.tokenCost.hintLine : ""
font: Kirigami.Theme.smallFont
opacity: applet.secondaryTextOpacity
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}