"""Rolling transcript of the conversation. The speech bubble hides itself after a few seconds, which is fine for chat but bad for anything you actually needed to read (a command's output, a number, a URL). This keeps the last HISTORY_LIMIT turns so the tray's History window — and click-to-copy on the bubble — have something to show. Pure logic, no Qt: the UI half is ui/history_window.py. """ from __future__ import annotations from collections import deque from dataclasses import dataclass from typing import Iterable, Optional USER = "you" PET = "bolt" SYSTEM = "system" @dataclass(frozen=True) class Entry: role: str text: str timestamp: Optional[float] = None # time.time(); None when not recorded def formatted(self, clock=None) -> str: label = {USER: "You", PET: "Bolt", SYSTEM: "—"}.get(self.role, self.role) stamp = clock(self.timestamp) if (clock and self.timestamp) else None return f"[{stamp}] {label}: {self.text}" if stamp else f"{label}: {self.text}" class ConversationHistory: def __init__(self, limit: int = 100): self._entries: deque[Entry] = deque(maxlen=max(1, limit)) def add(self, role: str, text: str, timestamp: Optional[float] = None) -> Optional[Entry]: text = (text or "").strip() if not text: return None entry = Entry(role=role, text=text, timestamp=timestamp) self._entries.append(entry) return entry def entries(self) -> list[Entry]: return list(self._entries) def last(self, role: Optional[str] = None) -> Optional[Entry]: for entry in reversed(self._entries): if role is None or entry.role == role: return entry return None def clear(self) -> None: self._entries.clear() def as_text(self, clock=None, entries: Optional[Iterable[Entry]] = None) -> str: return "\n".join(e.formatted(clock) for e in (entries if entries is not None else self._entries)) def __len__(self) -> int: return len(self._entries)