feat: cancel button during transcription and rewriting (v2.03.41)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
4915884316
commit
8f056e8cf0
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user