From a7dd41385baa8f3ac34deda827f0344f82fb713e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 10 Aug 2026 18:31:51 +0200 Subject: feat: cost label showing spend and the next send's projection Three states stay distinct: blank for a free local model, ? for a cloud model whose prices were never entered, and a figure when they were. Collapsing the first two would make an unpriced cloud model read as free. Co-Authored-By: Claude Opus 5 --- llamachat/ui.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- test_llamachat.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/llamachat/ui.py b/llamachat/ui.py index 7d5c4a2..6c26ca2 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -32,7 +32,7 @@ from PySide6.QtWidgets import ( QPushButton, QRadioButton, QSplitter, QTextBrowser, QVBoxLayout, QWidget, ) -from . import backend, prompts +from . import backend, models as models_mod, prompts from .backend import Attachment, BackendError, SearchConfig from .config import GLOBAL_PROMPT @@ -133,6 +133,56 @@ def _short(count: int) -> str: return str(count) +def cost_text(spent: float, projected: float, billable: bool, priced: bool) -> str: + """The cost label beside the context meter. + + Three states, deliberately distinct: a local model shows nothing, a + cloud model with no price entered shows '?', and a priced one shows + what it has cost plus what the composed draft would add. Blank and '?' + must not collapse into each other, or an unpriced cloud model reads as + free. + """ + if not billable: + return "" + if not priced: + return "?" + text = models_mod.format_cost(spent) + if projected > 0: + text += f" +{models_mod.format_cost(projected)}" + return text + + +class CostLabel(QLabel): + """A one-line money readout that hides itself when there is nothing to say.""" + + def __init__(self): + super().__init__("") + self.setToolTip("") + + def set_cost( + self, spent: float, projected: float, billable: bool, priced: bool + ) -> None: + text = cost_text(spent, projected, billable, priced) + self.setText(text) + self.setVisible(bool(text)) + if not billable: + self.setToolTip("") + elif not priced: + self.setToolTip( + "No prices set for this model.\n" + "Use Model settings to enter them." + ) + else: + tip = f"{models_mod.format_cost(spent)} spent in this conversation" + if projected > 0: + tip += ( + f"\n+{models_mod.format_cost(projected)} to send what is " + "composed now" + ) + tip += "\nApproximate: based on the prices you entered." + self.setToolTip(tip) + + class StreamWorker(QObject): """Runs one streaming request off the GUI thread.""" diff --git a/test_llamachat.py b/test_llamachat.py index b561b66..18b6f0a 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -2025,6 +2025,36 @@ def test_model_dialog_values(): print("ok model dialog value conversion") +def test_cost_label_text(): + """The label distinguishes free, unpriced, and a real figure.""" + from llamachat import ui + + # A local model costs nothing, so the label says nothing. + assert ui.cost_text(spent=0.0, projected=0.0, billable=False, priced=False) == "" + + # A cloud model whose price was never entered: ? rather than blank, so + # it cannot be mistaken for free. + assert ui.cost_text( + spent=0.0, projected=0.0, billable=True, priced=False + ) == "?" + + # Spent so far, with nothing composed yet. + assert ui.cost_text( + spent=0.043, projected=0.0, billable=True, priced=True + ) == "$0.043" + + # Spent plus what sending the draft would add, kept visually separate. + assert ui.cost_text( + spent=0.043, projected=0.011, billable=True, priced=True + ) == "$0.043 +$0.011" + + # A fresh conversation on a priced model still shows the projection. + assert ui.cost_text( + spent=0.0, projected=0.002, billable=True, priced=True + ) == "$0.000 +$0.002" + print("ok cost label text") + + class _FakeResponse: """Enough of an http.client response for urlopen's context manager.""" @@ -2788,6 +2818,7 @@ if __name__ == "__main__": test_client_auth_header() test_multi_client() test_model_dialog_values() + test_cost_label_text() test_search_tool_schema() test_search_results_sanitising() test_tool_call_accumulation() -- cgit v1.2.3