From 8f056e8cf022f059c947b84ec4000a1be248d6be Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 10 Jun 2026 17:57:41 +0200 Subject: [PATCH] feat: cancel button during transcription and rewriting (v2.03.41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay: show × button in busy state (transcribing/rewriting), not only while recording — updates hit-region, draw call, and label layout - daemon: cancel_dictation() now handles _busy via threading.Event (_abort_event); _process() clears the event at start, checks after STT returns and after LLM rewrite completes, skipping delivery if set - llm: chat() and _read_stream() accept abort_event; streaming loop breaks immediately when the event is set so cancellation is near-instant Co-Authored-By: Claude Sonnet 4.6 --- linux/CHANGELOG.md | 10 ++++++++++ linux/blitztext/__init__.py | 2 +- linux/blitztext/daemon.py | 27 ++++++++++++++++++++++++--- linux/blitztext/llm.py | 7 +++++-- linux/blitztext/overlay.py | 8 ++++---- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 4fbe509..9c8cec5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,16 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [2.03.41] - 2026-06-10 + +### Added +- **Cancel button shown during transcription and rewriting.** The `×` button in + the top-right corner of the overlay is now visible and clickable in the + `busy` state (Transcribing… / Rewriting…), not only while recording. + Clicking it during transcription discards the result once the STT call + returns. Clicking it during a rewrite breaks out of the LLM stream + immediately — the partial text is discarded and nothing is typed. + ## [2.03.40] - 2026-06-10 ### Changed diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index c43067c..7c6ef35 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__ = "2.03.40" +__version__ = "2.03.41" diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 7a6e0a5..831c7a4 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -163,6 +163,7 @@ class Daemon: self._active_workflow: Workflow | None = None self._target_window: str | None = None self._busy = False + self._abort_event = threading.Event() self._prepared = False self._listener = None # Synthetic preset used by the voice-routing hotkey. @@ -520,13 +521,19 @@ class Daemon: self._active_workflow = None self._stream_segment_text = "" rec = None - else: + busy = False + elif self._recording is not None: streamer = None - if self._recording is None: - return rec = self._recording self._recording = None self._active_workflow = None + busy = False + elif self._busy: + streamer = None + rec = None + busy = True + else: + return if streamer is not None: streamer.stop() self._ov_meter_stop() @@ -534,6 +541,11 @@ class Daemon: self._notify("Cancelled", "Streaming stopped.", "low") self._play_sound("device-removed") return + if busy: + self._abort_event.set() + self._emit("idle", None, "Cancelled") + self._play_sound("device-removed") + return rec.discard() self._emit("idle", None, "Cancelled") self._notify("Cancelled", "Recording discarded.", "low") @@ -608,6 +620,7 @@ class Daemon: # -- worker --------------------------------------------------------------- def _process(self, audio_path, workflow: Workflow, window_id, send_enter: bool = False) -> None: + self._abort_event.clear() label = workflow.name try: # Quality gate: drop silent / too-short clips before we even transcribe. @@ -630,6 +643,9 @@ class Daemon: local_transcriber=self.transcriber, timeout=self.cfg.timeout, ) + if self._abort_event.is_set(): + log(f"✗ {label}: cancelled during transcription.") + return text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation) text = quality.expand_spoken_punctuation(text) @@ -747,6 +763,7 @@ class Daemon: temperature=target.temperature, timeout=self.cfg.timeout, on_token=on_token, + abort_event=self._abort_event, ) except LLMError as exc: self._emit("error", label, str(exc)) @@ -763,6 +780,10 @@ class Daemon: self.text_cb(f"✗ {msg}") return + if self._abort_event.is_set(): + log(f"✗ {label}: cancelled during rewrite.") + return + # Sanity-check: reject responses that are >80 % whitespace — # a model that's cold-starting or misconfigured sometimes streams # spaces or blank lines instead of real output. diff --git a/linux/blitztext/llm.py b/linux/blitztext/llm.py index 0cdc295..df3e3f8 100644 --- a/linux/blitztext/llm.py +++ b/linux/blitztext/llm.py @@ -50,6 +50,7 @@ def chat( temperature: float | None = None, timeout: int = 45, on_token: Callable[[str], None] | None = None, + abort_event=None, ) -> str: """Run a chat completion and return the full text. @@ -82,7 +83,7 @@ def chat( try: with urllib.request.urlopen(req, timeout=timeout) as resp: if stream: - content = _read_stream(resp, on_token) + content = _read_stream(resp, on_token, abort_event) else: body = json.loads(resp.read().decode("utf-8")) content = body["choices"][0]["message"]["content"] @@ -107,12 +108,14 @@ def chat( return content -def _read_stream(resp, on_token: Callable[[str], None]) -> str: +def _read_stream(resp, on_token: Callable[[str], None], abort_event=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: + if abort_event and abort_event.is_set(): + break line = raw.decode("utf-8", "replace").strip() if not line or not line.startswith("data:"): continue diff --git a/linux/blitztext/overlay.py b/linux/blitztext/overlay.py index c644e7e..fff266f 100644 --- a/linux/blitztext/overlay.py +++ b/linux/blitztext/overlay.py @@ -129,7 +129,7 @@ class Overlay: gdkwin = self._win.get_window() if gdkwin is None: return - if self._on_cancel_cb and self._state in ("recording", "streaming"): + if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"): x, y, w, h = self._cancel_btn_rect r = cairo.Region(cairo.RectangleInt(int(x), int(y), int(w), int(h))) else: @@ -403,14 +403,14 @@ class Overlay: wf_w = w - _PAD - wf_x self._draw_wave(cr, wf_x, body_top + _PAD, wf_w, _HEADER_H) - # × cancel button (top-right corner, recording/streaming only). - if self._on_cancel_cb and self._state in ("recording", "streaming"): + # × cancel button (top-right corner, recording/streaming/busy). + if self._on_cancel_cb and self._state in ("recording", "streaming", "busy"): self._draw_cancel_btn(cr, body_top) # Phase label by the waveform — only when there's no preset banner and no text. # Shift left to leave room for the × button. label_right = (w - _PAD - _CANCEL_BTN_R * 2 - 6 - if self._on_cancel_cb and self._state in ("recording", "streaming") + if self._on_cancel_cb and self._state in ("recording", "streaming", "busy") else w - _PAD) if self._phase_label and not self._text and not self._preset_name: self._draw_label(cr, label_right, body_top + _PAD + 12, self._phase_label)