diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index a9f998a..27787f4 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.0.0" +__version__ = "2.0.1" diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 34079c9..76d58e1 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -555,33 +555,43 @@ class Daemon: # the complete result, typed once the rewrite finishes. on_token = None if self.text_cb: - acc: list[str] = [] - # Pulse "thinking" dots until the first token arrives. + # Use a deque to stream the last ~400 chars to the overlay so + # Pango never has to lay out a 15 000-char code block on each + # token, and "".join() stays O(window) not O(total). + from collections import deque + _window: deque[str] = deque() + _window_len: list[int] = [0] + _OVERLAY_CHARS = 400 + _thinking_frames = ["⏳ Thinking.", "⏳ Thinking..", "⏳ Thinking...", "⏳ Thinking"] - _thinking_state: list[int] = [0] # [frame_idx] 0 = still thinking + _thinking_state: list[int] = [0] _first_token: list[bool] = [False] def _pulse_thinking(_s=_thinking_state, _f=_first_token) -> bool: if _f[0]: - return False # first token arrived, stop pulsing + return False self.text_cb(_thinking_frames[_s[0] % len(_thinking_frames)]) _s[0] += 1 - return True # keep timer running + return True try: from gi.repository import GLib as _GLib - # MUST be scheduled via idle_add so timeout_add is called - # from the GTK main thread, not from _process's background - # thread. GLib.timeout_add from non-main threads is not - # thread-safe in PyGObject/GTK3 and can wedge the main loop. + # idle_add ensures timeout_add runs on the GTK main thread — + # calling timeout_add from a background thread is not safe. _GLib.idle_add(lambda: _GLib.timeout_add(400, _pulse_thinking) and False) - except Exception: # noqa: BLE001 - headless mode, no GLib + except Exception: # noqa: BLE001 pass - def on_token(delta: str, _acc=acc, _f=_first_token) -> None: + def on_token(delta: str, + _w=_window, _wl=_window_len, _f=_first_token) -> None: _f[0] = True - _acc.append(delta) - self.text_cb("".join(_acc)) + _w.append(delta) + _wl[0] += len(delta) + # Trim old tokens from the front to stay within the window. + while _wl[0] > _OVERLAY_CHARS and len(_w) > 1: + _wl[0] -= len(_w[0]) + _w.popleft() + self.text_cb("".join(_w)) # Use the preset's pinned engine when set, else the active one. engine_name = getattr(target, "llm_engine", "") or "" diff --git a/linux/blitztext/paste.py b/linux/blitztext/paste.py index b0e0490..d5cc14e 100644 --- a/linux/blitztext/paste.py +++ b/linux/blitztext/paste.py @@ -41,6 +41,13 @@ def _focus(window_id: str | None) -> None: time.sleep(0.05) +# Above this character count, or when the text contains newlines, xdotool type +# sends thousands of synchronous X11 round-trips and can flood the X11 server's +# per-client event buffer until the whole session freezes. Auto-upgrade to a +# single clipboard paste instead, which is instantaneous. +_TYPE_THRESHOLD = 300 + + def deliver(text: str, *, mode: str = "type", window_id: str | None = None, type_delay_ms: int = 4) -> None: if not text: return @@ -55,6 +62,15 @@ def deliver(text: str, *, mode: str = "type", window_id: str | None = None, type # Give the user time to release the hotkey modifiers before we synthesize input. time.sleep(0.12) + # Long or multi-line text: force clipboard paste regardless of configured mode. + # xdotool type at 12ms/char for a 15 000-char code block takes ~3 minutes and + # sends so many synchronous X11 events that the server's per-client buffer + # overflows, freezing the entire X11 session. + if mode == "type" and (len(text) > _TYPE_THRESHOLD or "\n" in text): + if _set_clipboard(text): + mode = "paste" + # If clipboard isn't available we fall through to xdotool type as before. + if mode == "paste" and _set_clipboard(text): if wayland: if shutil.which("wtype"):