diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index b87131d..fca6ce3 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,31 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [1.6.0] - 2026-06-07 + +### Fixed +- **Session freeze when the overlay's caret tracking was active** (could lock up + the whole GNOME/X11 desktop, forcing a logout/reboot). The AT-SPI caret tracker + subscribed to the high-frequency `object:text-caret-moved` signal and made + **synchronous, blocking AT-SPI reads from inside the event handler** — which + re-enters the accessibility dispatcher and is stormed by the app's *own* + `xdotool` typing (one event per character), congesting the a11y bus until the + desktop stopped responding. It now tracks **focus changes only** and reads the + caret rectangle lazily (once, when the overlay shows), never from inside an + event dispatch. + +### Changed +- **Matched preset is fused into the overlay instead of a desktop notification**: + when voice routing picks a preset, the overlay shows its emoji icon, name, and + the spoken keyword on a banner, and narrates the phase ("Transcribing…" → + "Rewriting…"). With the overlay on, the redundant per-dictation notifications + are suppressed (errors still notify); headless/overlay-off keeps notifications. + +### Added +- **Live LLM rewrite in the overlay**: rewrite presets now stream the model's + output into the bubble token-by-token, so you watch it write. The delivered + text is still the complete result, typed once the rewrite finishes. + ## [1.5.1] - 2026-06-07 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index 452842a..bb853e6 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "1.5.1" +__version__ = "1.6.0" diff --git a/linux/blitztext/caret.py b/linux/blitztext/caret.py index 22c8c42..8466808 100644 --- a/linux/blitztext/caret.py +++ b/linux/blitztext/caret.py @@ -6,8 +6,10 @@ through a chain of decreasing precision: 1. AT-SPI caret — the real text insertion point, when the focused app exposes it over accessibility (native GTK/Qt apps do; many terminals / Electron / - web views do not). Tracked passively via the GLib main loop, so reads are - instant and never block. + web views do not). We track only the *focused* object via a11y events (cheap, + low-frequency) and read its caret rectangle lazily, once, when the overlay + shows — never from inside an event dispatch, since synchronous AT-SPI reads + on the hot path can wedge the accessibility bus and freeze the session. 2. Mouse pointer — `xdotool getmouselocation`. Always available on X11; a good proxy since the pointer is usually near where you're typing. 3. Window / screen — top-centre of the target window, else screen bottom-centre. @@ -40,25 +42,39 @@ class Anchor: # Tier 1: AT-SPI caret tracking (best-effort, passive) # --------------------------------------------------------------------------- # class _CaretTracker: - """Passively follow the focused editable text's caret via AT-SPI events. + """Remember the most recently focused text object; read its caret lazily. - We never poll the a11y tree on the hot path (that can be slow and can block - on unresponsive apps). Instead we subscribe to caret-moved / focus events and - cache the last caret rectangle; `rect()` just returns the cached value if it - is fresh enough to still be meaningful. + *Why so cautious.* AT-SPI queries (``get_character_extents`` and friends) are + **synchronous, blocking D-Bus round-trips into the target application**. The + earlier design subscribed to the high-frequency ``object:text-caret-moved`` + signal and ran those blocking reads from *inside* the event handler. Two ways + that wedges a whole GNOME/X11 session: + + • Calling a synchronous AT-SPI method from within an AT-SPI event dispatch + re-enters the a11y dispatcher and can deadlock the accessibility bus. + • ``text-caret-moved`` fires once *per character* — and delivering text is + exactly what this app does, typing via ``xdotool`` into the focused + field. So a single dictation became a storm of blocking round-trips on + the GTK main loop, congesting the a11y bus until the desktop froze. + + So we now subscribe to **focus changes only** (rare, and never emitted by our + own synthetic typing), cache just the focused accessible, and do the one + blocking extents read **on demand** in :meth:`rect` — called once, when the + overlay shows, outside any event dispatch. Worst case is a slightly delayed + overlay placement, never a frozen session. """ - STALE_SECONDS = 30.0 # ignore a cached caret older than this + STALE_SECONDS = 30.0 # ignore a focus older than this def __init__(self) -> None: self._ok = False self._listener = None - self._rect: tuple[int, int, int, int] | None = None # x, y, w, h (screen) + self._focused = None # last focused accessible (read lazily) self._stamp = 0.0 self._Atspi = None def start(self) -> bool: - """Register AT-SPI listeners on the (already running) GLib main loop. + """Register the AT-SPI focus listener on the running GLib main loop. Safe to call when accessibility is disabled — it just returns False and the anchor logic skips this tier from then on. @@ -74,10 +90,10 @@ class _CaretTracker: # init() is idempotent; returns 0/1. Connects to the a11y registry. Atspi.init() self._Atspi = Atspi - self._listener = Atspi.EventListener.new(self._on_event) - # Caret moves give us the live position; focus changes let us grab the - # caret of a freshly-focused field even before it moves. - self._listener.register("object:text-caret-moved") + self._listener = Atspi.EventListener.new(self._on_focus) + # Focus changes only. Deliberately NOT "object:text-caret-moved": that + # firehose (one event per typed character, including our own output) + # plus synchronous reads is what could freeze the session. self._listener.register("object:state-changed:focused") self._ok = True log("[overlay] AT-SPI caret tracking active") @@ -90,26 +106,23 @@ class _CaretTracker: def stop(self) -> None: try: if self._listener is not None: - self._listener.deregister("object:text-caret-moved") self._listener.deregister("object:state-changed:focused") except Exception: # noqa: BLE001 pass self._listener = None + self._focused = None self._ok = False - def _on_event(self, event) -> None: - # Runs on the GLib main thread (same loop GTK uses). Keep it cheap and - # never raise — an exception here would bubble into the a11y dispatcher. + def _on_focus(self, event) -> None: + # Runs on the GLib main thread (same loop GTK uses). Do the *minimum*: + # stash the focused accessible and stamp it. Crucially, make NO synchronous + # AT-SPI calls here — that would re-enter the a11y dispatcher and risk + # deadlocking the bus. The blocking extents read happens later, in rect(). try: - if event.type.startswith("object:state-changed:focused") and not event.detail1: - return # a *de*focus event — nothing to read - source = event.source - if source is None: - return - rect = self._caret_rect(source) - if rect is not None: - self._rect = rect - self._stamp = time.time() + if not event.detail1: + return # a *de*focus event — nothing to track + self._focused = event.source + self._stamp = time.time() except Exception: # noqa: BLE001 pass @@ -142,11 +155,17 @@ class _CaretTracker: return None def rect(self) -> tuple[int, int, int, int] | None: - if not self._ok or self._rect is None: + # Called once when the overlay shows (not on the a11y hot path), so the + # single blocking extents read here is safe: at worst it briefly delays + # the overlay, it cannot storm or re-enter the bus. + if not self._ok or self._focused is None: return None if time.time() - self._stamp > self.STALE_SECONDS: return None - return self._rect + try: + return self._caret_rect(self._focused) + except Exception: # noqa: BLE001 - focused app may be gone/unresponsive + return None # --------------------------------------------------------------------------- # diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index de85784..e3fad25 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -37,17 +37,25 @@ class Daemon: def __init__(self, cfg: Config, status_cb: StatusCallback | None = None, level_cb: Callable[[float], None] | None = None, text_cb: Callable[[str], None] | None = None, - countdown_cb: Callable[[float | None, float], None] | None = None): + countdown_cb: Callable[[float | None, float], None] | None = None, + routing_cb: Callable[[str, str, str | None], None] | None = None): self.cfg = cfg self.status_cb = status_cb # Optional UI feedback hooks for the on-screen overlay. The daemon stays # UI-agnostic: these are no-ops in headless mode. level_cb gets the live - # mic level (0..1); text_cb gets the running transcript while streaming; - # countdown_cb(seconds_left, window) drives the silence auto-stop ring - # (seconds_left=None while you're speaking, so the ring clears). + # mic level (0..1); text_cb gets the running transcript while streaming + # (and the live LLM rewrite); countdown_cb(seconds_left, window) drives the + # silence auto-stop ring (seconds_left=None while you're speaking, so the + # ring clears); routing_cb(icon, preset_name, keyword) fires when voice + # routing picks a preset, so the overlay can show it. self.level_cb = level_cb self.text_cb = text_cb self.countdown_cb = countdown_cb + self.routing_cb = routing_cb + # An overlay consumes routing_cb, and it narrates every phase on-screen, so + # the redundant desktop notifications are fused into it (only errors still + # pop a bubble). Headless / overlay-off keeps the notifications. + self._overlay = routing_cb is not None self._ov_meter = None self._ov_text_final = "" self._lock = threading.Lock() @@ -99,14 +107,20 @@ class Daemon: notify(title, body, urgency=urgency, enabled=self.cfg.notify) def _dnotify(self, title: str, body: str = "", urgency: str = "normal") -> None: - """Per-dictation notification — suppressed for hands-free (wakeword) sessions.""" - if not self._session_silent: - self._notify(title, body, urgency=urgency) + """Per-dictation notification — suppressed for hands-free (wakeword) + sessions, and (except for errors) when an overlay is narrating on-screen.""" + if self._session_silent: + return + if self._overlay and urgency != "critical": + return # fused into the on-screen overlay instead of a desktop bubble + self._notify(title, body, urgency=urgency) def _rnotify(self, title: str, body: str = "", urgency: str = "normal") -> None: - """Routing feedback — which preset/keyword a voice command matched. Shown - even for hands-free sessions (it has its own toggle) so you can always see - what you triggered. Only fires on a real match, so it never spams silence.""" + """Routing feedback — which preset/keyword a voice command matched. When an + overlay is present this is shown there (via routing_cb) instead of a + notification; otherwise it pops a bubble. Only fires on a real match.""" + if self._overlay: + return # shown on the overlay banner instead notify(title, body, urgency=urgency, enabled=self.cfg.notify_routing) def _emit(self, state: str, workflow: str | None = None, message: str = "") -> None: @@ -456,6 +470,13 @@ class Daemon: icon = (getattr(target, "icon", "") or "🎙") if target else "🎙" via = f"“{res.keyword}”" if res.keyword else "no keyword → default" self._emit("busy", label, f"→ {label} ({via})") + # Fuse the match onto the overlay (icon + preset + keyword); falls + # back to a desktop notification only when there's no overlay. + if self.routing_cb: + try: + self.routing_cb(icon, label, res.keyword) + except Exception: # noqa: BLE001 - UI must not break the engine + pass self._rnotify(f"{icon} {label}", f"matched: {via}") log(f"→ routed to {label} (matched: {via})") else: @@ -468,6 +489,17 @@ class Daemon: return self._emit("busy", label, "Rewriting…") self._dnotify(f"⌛ {label}", "Rewriting…") + # Stream the rewrite into the overlay so you watch the model write + # (the bubble updates token-by-token). The delivered text is still + # the complete result, typed once the rewrite finishes. + on_token = None + if self.text_cb: + acc: list[str] = [] + + def on_token(delta: str, _acc=acc) -> None: + _acc.append(delta) + self.text_cb("".join(_acc)) + try: text = llm.chat( self.cfg.active_llm, @@ -476,6 +508,7 @@ class Daemon: model=target.model or None, temperature=target.temperature, timeout=self.cfg.timeout, + on_token=on_token, ) except LLMError as exc: self._emit("error", label, str(exc)) diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 3bba42b..150a931 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -108,6 +108,9 @@ class App: cfg, status_cb=self._status_cb, level_cb=self._on_level, text_cb=self._on_text, countdown_cb=self._on_countdown, + # Only consume routing on the overlay when there's an overlay to show + # it on; otherwise the daemon keeps the desktop notification. + routing_cb=self._on_routing if self.overlay is not None else None, ) _install_css() @@ -276,6 +279,10 @@ class App: if self.overlay is not None: self.overlay.set_countdown(remaining, total) + def _on_routing(self, icon: str, name: str, keyword: str | None) -> None: + if self.overlay is not None: + self.overlay.set_preset(icon, name, keyword) + def _overlay_status(self, state: str, message: str) -> None: """Translate engine phases into overlay show/update/hide (GTK thread).""" ov = self.overlay @@ -287,7 +294,10 @@ class App: ov.show(state, getattr(self.daemon, "_target_window", None)) elif state == "busy": self._ov_state = state - ov.set_state("busy", message) + # The routing detail ("→ Nicer email (matched: …)") is shown on the + # preset banner, not as a phase chip — keep the chip a clean phase word. + phase = "Transcribing…" if message.startswith("→") else message + ov.set_state("busy", phase) elif state == "done": # Non-streaming: the 'done' message carries the final text. Streaming # already showed it live, so don't overwrite with "Streaming stopped". diff --git a/linux/blitztext/llm.py b/linux/blitztext/llm.py index ba96dd1..3a25b4d 100644 --- a/linux/blitztext/llm.py +++ b/linux/blitztext/llm.py @@ -13,6 +13,7 @@ import os import urllib.error import urllib.request from dataclasses import dataclass +from typing import Callable from .stt import reachable @@ -48,18 +49,28 @@ def chat( model: str | None = None, temperature: float | None = None, timeout: int = 45, + on_token: Callable[[str], None] | None = None, ) -> str: + """Run a chat completion and return the full text. + + When ``on_token`` is given, the request is streamed and each content delta is + handed to the callback as it arrives (so a UI can show the model writing in + real time). The callback is best-effort — it never affects the return value, + which is always the complete, stripped response. + """ + stream = on_token is not None api_key = engine.api_key - payload = json.dumps( - { - "model": model or engine.model, - "temperature": engine.temperature if temperature is None else temperature, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_text}, - ], - } - ).encode("utf-8") + body_obj = { + "model": model or engine.model, + "temperature": engine.temperature if temperature is None else temperature, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_text}, + ], + } + if stream: + body_obj["stream"] = True + payload = json.dumps(body_obj).encode("utf-8") headers = {"Content-Type": "application/json"} if api_key: @@ -70,19 +81,46 @@ def chat( ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read().decode("utf-8")) + if stream: + content = _read_stream(resp, on_token) + else: + body = json.loads(resp.read().decode("utf-8")) + content = body["choices"][0]["message"]["content"] except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace")[:300] raise LLMError(f"HTTP {exc.code}: {detail}") from exc except urllib.error.URLError as exc: raise LLMError(f"Connection failed: {exc.reason}") from exc - - try: - content = body["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: - raise LLMError(f"Unexpected response: {str(body)[:300]}") from exc + raise LLMError(f"Unexpected response: {exc}") from exc content = (content or "").strip() if not content: raise LLMError("Empty response from model.") return content + + +def _read_stream(resp, on_token: Callable[[str], None]) -> str: + """Parse an OpenAI-style SSE stream, returning the accumulated content and + feeding each delta to ``on_token``. Tolerant of keep-alive blanks and the + trailing ``[DONE]`` sentinel.""" + parts: list[str] = [] + for raw in resp: + line = raw.decode("utf-8", "replace").strip() + if not line or not line.startswith("data:"): + continue + data = line[len("data:"):].strip() + if data == "[DONE]": + break + try: + obj = json.loads(data) + delta = obj["choices"][0]["delta"].get("content") + except (ValueError, KeyError, IndexError, TypeError): + continue + if delta: + parts.append(delta) + try: + on_token(delta) + except Exception: # noqa: BLE001 - UI hiccups must not break delivery + pass + return "".join(parts) diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index 9a2bc34..d8e8d68 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -40,6 +40,7 @@ _TAIL_W = 20 _TAIL_H = 11 _GAP = 12 # clearance between the tail tip and the anchor _BARS = 30 # waveform bar count +_PRESET_H = 22 # matched-preset header row (emoji + name + keyword) _MIN_TEXT_H = 0 _MAX_TEXT_H = 120 @@ -62,6 +63,12 @@ class Overlay: self._state = "recording" self._text = "" self._phase_label = "Listening…" + # Matched-preset banner (fused in from voice routing instead of a separate + # desktop notification): the preset's emoji, its name, and the spoken + # keyword that selected it. + self._preset_icon = "" + self._preset_name = "" + self._keyword = "" self._anchor: caret.Anchor | None = None self._tail_up = False # tail on top edge (bubble below anchor)? self._tail_x = _WIDTH // 2 # tail tip, window-local x @@ -118,6 +125,11 @@ class Overlay: def set_text(self, text: str) -> None: GLib.idle_add(self._set_text, text or "") + def set_preset(self, icon: str, name: str, keyword: str | None) -> None: + """Show the matched voice-routing preset on the overlay (emoji + name + + the spoken keyword), in place of a separate desktop notification.""" + GLib.idle_add(self._set_preset, icon or "", name or "", keyword or "") + def set_countdown(self, remaining: float | None, total: float) -> None: """Silence auto-stop progress: ``remaining`` seconds until it fires over a ``total``-second window, or ``None`` while you're still speaking.""" @@ -138,6 +150,9 @@ class Overlay: self._state = state self._phase_label = _PHASES.get(state, ((1, 1, 1), ""))[1] self._text = "" + self._preset_icon = "" + self._preset_name = "" + self._keyword = "" self._levels = deque([0.0] * _BARS, maxlen=_BARS) self._disp = [0.0] * _BARS self._cd_deadline = None @@ -163,6 +178,16 @@ class Overlay: self._area.queue_draw() return False + def _set_preset(self, icon: str, name: str, keyword: str) -> bool: + if (icon, name, keyword) == (self._preset_icon, self._preset_name, self._keyword): + return False + self._preset_icon = icon + self._preset_name = name + self._keyword = keyword + self._relayout() + self._area.queue_draw() + return False + def _set_countdown(self, remaining: float | None, total: float) -> bool: if remaining is None: self._cd_deadline = None @@ -173,7 +198,13 @@ class Overlay: def _set_state(self, state: str, message: str) -> bool: self._state = state - self._phase_label = _PHASES.get(state, ((1, 1, 1), message[:40]))[1] or message[:40] + if state == "busy": + # Honour a clean phase word from the caller ("Transcribing…", + # "Rewriting…") so the overlay narrates what's happening; fall back to + # the canned label otherwise. + self._phase_label = (message.strip() or _PHASES["busy"][1])[:28] + else: + self._phase_label = _PHASES.get(state, ((1, 1, 1), message[:40]))[1] or message[:40] if state not in ("recording", "streaming"): # The countdown only makes sense while listening; drop it as soon as # we move on to transcribing / done / idle so the ring doesn't linger. @@ -255,8 +286,10 @@ class Overlay: def _relayout(self) -> None: text_h = self._text_height() + preset_h = _PRESET_H if self._preset_name else 0 + gap_preset = 8 if preset_h else 0 gap_text = 8 if text_h else 0 - body_h = _HEADER_H + gap_text + text_h + 2 * _PAD + body_h = _HEADER_H + gap_preset + preset_h + gap_text + text_h + 2 * _PAD self._height = body_h + _TAIL_H geo = self._monitor_geo() @@ -314,15 +347,56 @@ class Overlay: wf_w = w - _PAD - wf_x self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H) - # Phase label (top-right, small) when there's room and no text yet. - if self._phase_label and not self._text: + # Phase label by the waveform (top-right) — only when there's no preset + # banner below (which carries the phase instead) and no text yet. + if self._phase_label and not self._text and not self._preset_name: self._draw_label(cr, w - _PAD, body_top + _PAD + 12, self._phase_label) - # Recognised text below the header row. + y = body_top + _PAD + _HEADER_H + # Matched-preset banner: emoji + name (left), live phase chip (right). + if self._preset_name: + y += 8 + self._draw_preset(cr, _PAD, y, w - 2 * _PAD) + y += _PRESET_H + # Recognised / rewritten text below. if self._text: - self._draw_text(cr, _PAD, body_top + _PAD + _HEADER_H + 8, w - 2 * _PAD) + y += 8 + self._draw_text(cr, _PAD, y, w - 2 * _PAD) return False + def _draw_preset(self, cr, x, y, w) -> None: + """The matched voice-routing preset, fused onto the overlay in place of a + desktop notification: emoji + name (and the spoken keyword) on the left, + the current phase ("Transcribing…", "Rewriting…") on the right.""" + cy = y + _PRESET_H / 2 + cursor = x + if self._preset_icon: + ic = self._win.create_pango_layout(self._preset_icon) + ic.set_font_description(Pango.FontDescription("Sans 13")) + iw, ih = ic.get_pixel_size() + cr.set_source_rgba(1, 1, 1, 0.95) + cr.move_to(cursor, cy - ih / 2) + PangoCairo.show_layout(cr, ic) + cursor += iw + 7 + name = GLib.markup_escape_text(self._preset_name) + if self._keyword: + kw = GLib.markup_escape_text(self._keyword) + markup = f'{name} “{kw}”' + else: + markup = f"{name}" + layout = self._win.create_pango_layout("") + layout.set_markup(markup, -1) + layout.set_font_description(Pango.FontDescription("Sans 10")) + layout.set_ellipsize(Pango.EllipsizeMode.END) + avail = (x + w) - cursor - 96 # leave room for the phase chip + layout.set_width(max(40, avail) * Pango.SCALE) + _nw, nh = layout.get_pixel_size() + cr.set_source_rgba(0.95, 0.96, 0.99, 0.98) + cr.move_to(cursor, cy - nh / 2) + PangoCairo.show_layout(cr, layout) + if self._phase_label: + self._draw_label(cr, x + w, cy, self._phase_label) + def _bubble_path(self, cr, x, y, w, h) -> None: r = _RADIUS cr.new_sub_path() diff --git a/linux/tests/test_daemon_notify.py b/linux/tests/test_daemon_notify.py index 98fa58c..d7e1568 100644 --- a/linux/tests/test_daemon_notify.py +++ b/linux/tests/test_daemon_notify.py @@ -108,6 +108,41 @@ def test_matched_preset_announced_even_hands_free(monkeypatch): assert shown == [], "no announcement when 'Announce matched preset' is off" +def test_overlay_fuses_notifications(monkeypatch): + """With an overlay (routing_cb wired), the on-screen bubble narrates every + phase, so informational desktop notifications are suppressed — but errors + still pop a bubble, and the routing match is shown on the overlay, not a + notification.""" + calls = [] + monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k))) + monkeypatch.setattr(daemon_mod, "detect_recorder", lambda pref="auto": "pw-record") + d = Daemon(Config(), routing_cb=lambda *a: None) + assert d._overlay is True + d._session_silent = False + + d._dnotify("⌛ Nicer email", "Rewriting…") # informational + assert calls == [], "informational notifications fuse into the overlay" + + d._dnotify("Error", "boom", urgency="critical") # errors still notify + assert len(calls) == 1, "errors must still pop a desktop bubble" + + calls.clear() + d.cfg.notify_routing = True + d._rnotify("⚡ Nicer email", "matched: “nicer email”") + assert calls == [], "the match is shown on the overlay banner, not as a notification" + + +def test_no_overlay_keeps_notifications(monkeypatch): + """Headless / overlay-off: notifications remain the only feedback.""" + calls = [] + monkeypatch.setattr(daemon_mod, "notify", lambda *a, **k: calls.append((a, k))) + d = _make_daemon(monkeypatch) # no routing_cb + assert d._overlay is False + d._session_silent = False + d._dnotify("⌛ Nicer email", "Rewriting…") + assert len(calls) == 1, "without an overlay, informational notifications still show" + + def test_wakeword_while_busy_does_not_notify(monkeypatch): """The away-from-keyboard "Busy" storm: a detection arriving while the previous clip is still being processed must be ignored silently.""" diff --git a/linux/tests/test_llm_stream.py b/linux/tests/test_llm_stream.py new file mode 100644 index 0000000..5b45338 --- /dev/null +++ b/linux/tests/test_llm_stream.py @@ -0,0 +1,41 @@ +"""Streaming the LLM rewrite into the overlay (so you watch the model write). + +`llm.chat(..., on_token=cb)` must parse an OpenAI-style SSE stream, hand each +content delta to the callback, and still return the complete text. +""" + +import io + +import blitztext.llm as llm + + +def test_read_stream_accumulates_and_calls_back(): + sse = ( + 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n' + "\n" # keep-alive blank line + 'data: {"choices":[{"delta":{"content":"lo"}}]}\n' + 'data: {"choices":[{"delta":{}}]}\n' # role-only / empty delta + ": comment line\n" # SSE comment, ignored + "data: [DONE]\n" + 'data: {"choices":[{"delta":{"content":"X"}}]}\n' # after DONE -> ignored + ) + tokens = [] + out = llm._read_stream(io.BytesIO(sse.encode("utf-8")), tokens.append) + + assert out == "Hello" + assert tokens == ["Hel", "lo"] + + +def test_read_stream_survives_callback_errors(): + sse = ( + 'data: {"choices":[{"delta":{"content":"a"}}]}\n' + 'data: {"choices":[{"delta":{"content":"b"}}]}\n' + "data: [DONE]\n" + ) + + def boom(_delta): + raise RuntimeError("UI exploded") + + # A failing UI callback must not break accumulation / delivery. + out = llm._read_stream(io.BytesIO(sse.encode("utf-8")), boom) + assert out == "ab"