← Whispering

fix: Use native clipboard and suppress command word flickering

b78efe9761b058ba0605dd60921f09b803b96018 · Claude · Jan 28, 2026 01:27

Two fixes:

1. Clipboard: Replace navigator.clipboard.writeText() (JavaScript) with
   native clipboard tools (xclip/xsel/wl-copy/tkinter). The JS clipboard
   API fails in PyQt6 WebEngine due to missing feature permissions
   (setFeaturePermission TypeError). Native tools work reliably in both
   native and browser modes.

2. Command flickering: When voice commands are enabled, curr_text (the
   live Whisper preview) is checked against the command trigger dictionary.
   If it matches a command phrase, it's suppressed from the display. This
   prevents "comma" from briefly appearing in the output before being
   finalized and replaced with ",".

https://claude.ai/code/session_0158RsuryxditNwJ4AGZEs3Z

+101 −55 · 2 files changed

  • src/whispering_ui/bridge.py +8 −2
  • src/whispering_ui/components/output.py +93 −53
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index d3820fb..f3ccf06 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -520,8 +520,14 @@ class ProcessingBridge:
         curr_text = curr_text or ""
 
         # Voice command detection — only on whisper output (raw transcription)
-        if done_text and state_attr == 'whisper_text' and self.state.voice_commands_enabled:
-            done_text = self._detect_and_execute_command(done_text)
+        if state_attr == 'whisper_text' and self.state.voice_commands_enabled:
+            if done_text:
+                done_text = self._detect_and_execute_command(done_text)
+            # Suppress curr_text if it looks like a pending command word.
+            # This prevents "comma" flickering in the preview before finalization.
+            if curr_text and self.command_detector:
+                if self.command_detector.check(curr_text) is not None:
+                    curr_text = ""
 
         committed_value = getattr(self, committed_attr)
         new_segment = ""
diff --git a/src/whispering_ui/components/output.py b/src/whispering_ui/components/output.py
index cc84254..13e12c5 100644
--- a/src/whispering_ui/components/output.py
+++ b/src/whispering_ui/components/output.py
@@ -11,10 +11,72 @@ copy/cut via clipboard.
 import html
 import json
 import re
+import threading
 from nicegui import ui
 from whispering_ui.state import AppState
 
 
+def _copy_to_clipboard_native(text: str) -> bool:
+    """Copy text to system clipboard using native tools (not JS).
+    Uses the same approach as autotype.py for PyQt6 compatibility."""
+    import subprocess
+    import shutil
+
+    # Try xclip first (Linux X11)
+    if shutil.which("xclip"):
+        try:
+            proc = subprocess.Popen(
+                ["xclip", "-selection", "clipboard"],
+                stdin=subprocess.PIPE, stderr=subprocess.DEVNULL
+            )
+            proc.communicate(input=text.encode("utf-8"))
+            if proc.returncode == 0:
+                return True
+        except Exception:
+            pass
+
+    # Try xsel
+    if shutil.which("xsel"):
+        try:
+            proc = subprocess.Popen(
+                ["xsel", "--clipboard", "--input"],
+                stdin=subprocess.PIPE, stderr=subprocess.DEVNULL
+            )
+            proc.communicate(input=text.encode("utf-8"))
+            if proc.returncode == 0:
+                return True
+        except Exception:
+            pass
+
+    # Try wl-copy (Wayland)
+    if shutil.which("wl-copy"):
+        try:
+            proc = subprocess.Popen(
+                ["wl-copy"],
+                stdin=subprocess.PIPE, stderr=subprocess.DEVNULL
+            )
+            proc.communicate(input=text.encode("utf-8"))
+            if proc.returncode == 0:
+                return True
+        except Exception:
+            pass
+
+    # Fallback to tkinter
+    try:
+        import tkinter as tk
+        root = tk.Tk()
+        root.withdraw()
+        root.clipboard_clear()
+        root.clipboard_append(text)
+        root.update()
+        root.destroy()
+        return True
+    except Exception:
+        pass
+
+    return False
+
+
 def _text_to_html(text: str) -> str:
     """
     Convert plain text to safe HTML for display.
@@ -214,37 +276,25 @@ def create_output_panels(state: AppState, bridge=None):
 
 
 def _copy_text(text: str, label: str):
-    """Copy text to clipboard (thread-safe). Strips any HTML tags."""
-    # State stores plain text, so use it directly
+    """Copy text to system clipboard using native tools."""
     text_to_copy = str(text) if text else ""
 
     if not text_to_copy.strip():
         ui.notify(f'No {label} text to copy', type='warning')
         return
 
-    try:
-        # Escape the text safely for JavaScript
-        escaped_text = json.dumps(text_to_copy)
-        # Use async clipboard API with proper error handling
-        js_code = f'''
-        (async () => {{
-            try {{
-                await navigator.clipboard.writeText({escaped_text});
-            }} catch (err) {{
-                console.error('Clipboard write failed:', err);
-            }}
-        }})();
-        '''
-        ui.run_javascript(js_code)
-        ui.notify(f'Copied {label} text to clipboard', type='positive')
-    except Exception as e:
-        print(f"Clipboard error: {e}")
-        ui.notify(f'Clipboard error - try again', type='warning')
+    def do_copy():
+        success = _copy_to_clipboard_native(text_to_copy)
+        if not success:
+            print(f"Clipboard copy failed for {label}")
+
+    # Run clipboard operation in background thread to avoid blocking UI
+    threading.Thread(target=do_copy, daemon=True).start()
+    ui.notify(f'Copied {label} text to clipboard', type='positive')
 
 
 def _cut_text(state: AppState, text_type: str, panel_key: str, html_panels: dict):
-    """Cut text (copy and clear) - thread-safe."""
-    # Capture the text value immediately
+    """Cut text (copy to clipboard and clear panel)."""
     if text_type == 'whisper':
         text = str(state.whisper_text) if state.whisper_text else ""
     elif text_type == 'ai':
@@ -258,36 +308,26 @@ def _cut_text(state: AppState, text_type: str, panel_key: str, html_panels: dict
         ui.notify(f'No text to cut', type='warning')
         return
 
+    def do_copy():
+        success = _copy_to_clipboard_native(text)
+        if not success:
+            print(f"Clipboard cut failed for {text_type}")
+
+    threading.Thread(target=do_copy, daemon=True).start()
+
+    # Clear the text in state
+    if text_type == 'whisper':
+        state.whisper_text = ""
+    elif text_type == 'ai':
+        state.ai_text = ""
+    elif text_type == 'translation':
+        state.translation_text = ""
+
+    # Clear HTML panel
     try:
-        # Copy to clipboard
-        escaped_text = json.dumps(text)
-        js_code = f'''
-        (async () => {{
-            try {{
-                await navigator.clipboard.writeText({escaped_text});
-            }} catch (err) {{
-                console.error('Clipboard write failed:', err);
-            }}
-        }})();
-        '''
-        ui.run_javascript(js_code)
-
-        # Clear the text in state
-        if text_type == 'whisper':
-            state.whisper_text = ""
-        elif text_type == 'ai':
-            state.ai_text = ""
-        elif text_type == 'translation':
-            state.translation_text = ""
-
-        # Clear HTML panel
-        try:
-            if panel_key in html_panels:
-                html_panels[panel_key].content = ""
-        except Exception:
-            pass
+        if panel_key in html_panels:
+            html_panels[panel_key].content = ""
+    except Exception:
+        pass
 
-        ui.notify(f'Cut text to clipboard', type='positive')
-    except Exception as e:
-        print(f"Cut error: {e}")
-        ui.notify(f'Cut error - try again', type='warning')
+    ui.notify(f'Cut text to clipboard', type='positive')