← Whispering

feat: Implement Phase 1 voice commands with rich text output panels

657c0804a07a20fe8d61f47b67d1db1155d55cd5 · Claude · Jan 27, 2026 22:34

Voice command detection system using isolation heuristic on existing
Whisper output. When a finalized Whisper segment exactly matches a
command trigger phrase (stripped of punctuation/whitespace), it is
intercepted and replaced with the corresponding text substitution.

New files:
- config/voice_commands.yaml: Command definitions with multi-language
  triggers (en, he, fr, de, es) for 15 punctuation/whitespace commands
- src/commands_config.py: YAML config loader following ai_config.py pattern
- src/command_detector.py: Pattern matcher with isolation and prefix modes
- src/command_executor.py: Action dispatcher (insert_text, with Phase 2
  format_toggle/format_block placeholders)

Modified files:
- output.py: Replace ui.textarea with ui.html() in scrollable containers
  for rich text rendering. Text buffers remain plain strings in state;
  HTML conversion happens at render time. Copy/Cut strip tags for clipboard.
- bridge.py: Integrate command detection in _update_text_buffer() for
  whisper output. Re-initializes detector on each recording start to
  pick up language/settings changes.
- state.py: Add voice_commands_enabled field
- sidebar.py: Add Voice Commands toggle checkbox
- main.py: Load/save voice_commands_enabled setting, update CSS for
  new output-richtext class (remove old textarea-specific Quasar rules)

Supported commands (Phase 1):
  comma, period, question mark, exclamation mark, colon, semicolon,
  dash, ellipsis, open/close quote, open/close parenthesis,
  new line, new paragraph, tab

https://claude.ai/code/session_0158RsuryxditNwJ4AGZEs3Z

+777 −65 · 9 files changed

  • config/voice_commands.yaml +198 −0
  • src/command_detector.py +155 −0
  • src/command_executor.py +123 −0
  • src/commands_config.py +108 −0
  • src/whispering_ui/bridge.py +82 −0
  • src/whispering_ui/components/output.py +98 −29
  • src/whispering_ui/components/sidebar.py +6 −0
  • src/whispering_ui/main.py +4 −36
  • src/whispering_ui/state.py +3 −0
diff --git a/config/voice_commands.yaml b/config/voice_commands.yaml
new file mode 100644
index 0000000..33ad7d4
--- /dev/null
+++ b/config/voice_commands.yaml
@@ -0,0 +1,198 @@
+# Voice Commands Configuration
+# Commands are detected using the isolation heuristic: if an entire Whisper segment
+# matches a command trigger (and nothing else), it's treated as a command.
+# If the trigger phrase appears mid-sentence, it passes through as normal text.
+
+# detection_mode: how commands are distinguished from regular speech
+#   "isolation" - command must be the entire segment (default, recommended)
+#   "prefix"    - command must start with a wake word (e.g., "command bold")
+detection_mode: isolation
+
+# prefix_word: wake word for prefix mode (ignored in isolation mode)
+prefix_word: "command"
+
+# Commands are organized by category.
+# Each command has:
+#   action: the action type to execute
+#   insert: (optional) text to insert for substitution-type actions
+#   triggers: dictionary of language code -> list of trigger phrases
+
+commands:
+  # === PUNCTUATION ===
+  comma:
+    action: insert_text
+    insert: ","
+    triggers:
+      en: ["comma"]
+      he: ["פסיק"]
+      fr: ["virgule"]
+      de: ["komma"]
+      es: ["coma"]
+
+  period:
+    action: insert_text
+    insert: "."
+    triggers:
+      en: ["period", "full stop", "dot"]
+      he: ["נקודה"]
+      fr: ["point"]
+      de: ["punkt"]
+      es: ["punto"]
+
+  question_mark:
+    action: insert_text
+    insert: "?"
+    triggers:
+      en: ["question mark"]
+      he: ["סימן שאלה"]
+      fr: ["point d'interrogation"]
+      de: ["fragezeichen"]
+      es: ["signo de interrogación"]
+
+  exclamation_mark:
+    action: insert_text
+    insert: "!"
+    triggers:
+      en: ["exclamation mark", "exclamation point"]
+      he: ["סימן קריאה"]
+      fr: ["point d'exclamation"]
+      de: ["ausrufezeichen"]
+      es: ["signo de exclamación"]
+
+  colon:
+    action: insert_text
+    insert: ":"
+    triggers:
+      en: ["colon"]
+      he: ["נקודתיים"]
+      fr: ["deux points"]
+      de: ["doppelpunkt"]
+      es: ["dos puntos"]
+
+  semicolon:
+    action: insert_text
+    insert: ";"
+    triggers:
+      en: ["semicolon", "semi colon"]
+      he: ["נקודה פסיק"]
+      fr: ["point virgule"]
+      de: ["semikolon"]
+      es: ["punto y coma"]
+
+  dash:
+    action: insert_text
+    insert: " — "
+    triggers:
+      en: ["dash", "em dash"]
+      he: ["מקף"]
+      fr: ["tiret"]
+      de: ["gedankenstrich"]
+      es: ["guion"]
+
+  ellipsis:
+    action: insert_text
+    insert: "..."
+    triggers:
+      en: ["ellipsis", "dot dot dot"]
+      he: ["שלוש נקודות"]
+      fr: ["points de suspension"]
+      de: ["auslassungspunkte"]
+      es: ["puntos suspensivos"]
+
+  open_quote:
+    action: insert_text
+    insert: "\""
+    triggers:
+      en: ["open quote", "begin quote", "quote"]
+      he: ["פתח מרכאות"]
+      fr: ["ouvrir guillemets"]
+
+  close_quote:
+    action: insert_text
+    insert: "\""
+    triggers:
+      en: ["close quote", "end quote", "unquote"]
+      he: ["סגור מרכאות"]
+      fr: ["fermer guillemets"]
+
+  open_parenthesis:
+    action: insert_text
+    insert: " ("
+    triggers:
+      en: ["open parenthesis", "open paren", "left paren"]
+      he: ["פתח סוגריים"]
+      fr: ["ouvrir parenthèse"]
+
+  close_parenthesis:
+    action: insert_text
+    insert: ") "
+    triggers:
+      en: ["close parenthesis", "close paren", "right paren"]
+      he: ["סגור סוגריים"]
+      fr: ["fermer parenthèse"]
+
+  # === WHITESPACE & STRUCTURE ===
+  new_line:
+    action: insert_text
+    insert: "\n"
+    triggers:
+      en: ["new line", "next line", "line break"]
+      he: ["שורה חדשה"]
+      fr: ["nouvelle ligne"]
+      de: ["neue zeile"]
+      es: ["nueva línea"]
+
+  new_paragraph:
+    action: insert_text
+    insert: "\n\n"
+    triggers:
+      en: ["new paragraph", "next paragraph", "paragraph"]
+      he: ["פסקה חדשה"]
+      fr: ["nouveau paragraphe"]
+      de: ["neuer absatz"]
+      es: ["nuevo párrafo"]
+
+  tab:
+    action: insert_text
+    insert: "\t"
+    triggers:
+      en: ["tab", "indent"]
+      he: ["טאב"]
+
+  # === FORMATTING (Phase 2 - stateful, but we define them now as no-ops) ===
+  # These are placeholders for Phase 2 implementation.
+  # In Phase 1, they will be recognized but produce a notification
+  # that formatting commands require rich text support.
+
+  # bold:
+  #   action: format_toggle
+  #   format: bold
+  #   triggers:
+  #     en: ["bold", "start bold", "make bold"]
+  #     he: ["מודגש", "הפוך למודגש"]
+  #   end_triggers:
+  #     en: ["stop bold", "end bold"]
+  #     he: ["הפסק מודגש"]
+
+  # italic:
+  #   action: format_toggle
+  #   format: italic
+  #   triggers:
+  #     en: ["italic", "italics", "start italic"]
+  #     he: ["נטוי"]
+  #   end_triggers:
+  #     en: ["stop italic", "end italic"]
+
+  # heading_1:
+  #   action: format_block
+  #   format: h1
+  #   triggers:
+  #     en: ["title one", "heading one", "title 1", "heading 1"]
+  #     he: ["כותרת אחת", "כותרת 1"]
+
+  # heading_2:
+  #   action: format_block
+  #   format: h2
+  #   triggers:
+  #     en: ["title two", "heading two", "title 2", "heading 2"]
+  #     he: ["כותרת שתיים", "כותרת 2"]
diff --git a/src/command_detector.py b/src/command_detector.py
new file mode 100644
index 0000000..607ddce
--- /dev/null
+++ b/src/command_detector.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""
+Voice Command Detector
+Detects voice commands in transcribed text using configurable detection modes.
+
+Detection modes:
+- isolation: The entire Whisper segment must match a trigger phrase exactly
+- prefix: The segment must begin with a wake word (e.g., "command bold")
+"""
+
+import re
+import unicodedata
+from typing import Optional, Tuple, Dict
+
+from commands_config import VoiceCommandsConfig
+
+
+class CommandDetector:
+    """
+    Detects voice commands in finalized Whisper text segments.
+
+    Uses the isolation heuristic by default: if the entire segment (stripped of
+    whitespace and punctuation) matches a known trigger phrase, it's a command.
+    Otherwise it's regular speech.
+    """
+
+    def __init__(self, config: VoiceCommandsConfig, language: str = None):
+        self.config = config
+        self.language = language
+        self.detection_mode = config.detection_mode
+        self.prefix_word = config.prefix_word.lower().strip()
+
+        # Build trigger lookup map
+        self._trigger_map = config.build_trigger_map(language)
+
+    def set_language(self, language: str):
+        """Update the language filter and rebuild the trigger map."""
+        self.language = language
+        self._trigger_map = self.config.build_trigger_map(language)
+
+    def _normalize(self, text: str) -> str:
+        """
+        Normalize text for matching: lowercase, strip whitespace,
+        remove trailing punctuation that Whisper often appends.
+        """
+        text = text.strip().lower()
+        # Remove leading/trailing punctuation (periods, commas, etc.)
+        text = text.strip('.,!?;:"\'-…')
+        # Collapse multiple spaces
+        text = re.sub(r'\s+', ' ', text)
+        return text.strip()
+
+    def check(self, segment_text: str) -> Optional[dict]:
+        """
+        Check if a text segment contains a voice command.
+
+        Args:
+            segment_text: The finalized text from Whisper (a single segment
+                          or accumulated done_text chunk).
+
+        Returns:
+            Command info dict if a command was detected:
+                {
+                    'command_id': str,
+                    'action': str,
+                    'insert': str or None,
+                    'format': str or None,
+                    'matched_trigger': str,
+                    'remaining_text': str,  # text after command removal
+                }
+            None if no command detected.
+        """
+        if not segment_text or not segment_text.strip():
+            return None
+
+        if self.detection_mode == "prefix":
+            return self._check_prefix(segment_text)
+        else:
+            return self._check_isolation(segment_text)
+
+    def _check_isolation(self, text: str) -> Optional[dict]:
+        """
+        Isolation mode: the entire segment must match a trigger phrase.
+        This is the recommended mode — it avoids false positives because
+        commands spoken mid-sentence are ignored.
+        """
+        normalized = self._normalize(text)
+
+        if not normalized:
+            return None
+
+        cmd_info = self._trigger_map.get(normalized)
+        if cmd_info:
+            return {
+                **cmd_info,
+                "matched_trigger": normalized,
+                "remaining_text": "",
+            }
+
+        return None
+
+    def _check_prefix(self, text: str) -> Optional[dict]:
+        """
+        Prefix mode: the segment must start with the wake word,
+        followed by the trigger phrase.
+        E.g., "command comma" → triggers comma insertion.
+        """
+        normalized = self._normalize(text)
+
+        if not normalized.startswith(self.prefix_word):
+            return None
+
+        # Strip the prefix word and check what remains
+        after_prefix = normalized[len(self.prefix_word):].strip()
+
+        if not after_prefix:
+            return None
+
+        cmd_info = self._trigger_map.get(after_prefix)
+        if cmd_info:
+            return {
+                **cmd_info,
+                "matched_trigger": after_prefix,
+                "remaining_text": "",
+            }
+
+        return None
+
+    def check_multi_segment(self, text: str) -> Tuple[str, list]:
+        """
+        Check a multi-line text block for commands. Each line (split by newline)
+        is checked independently. Commands are extracted and remaining text is
+        reassembled.
+
+        This is useful when done_text contains multiple sentences separated
+        by newlines, and one of them might be a command.
+
+        Args:
+            text: Text that may contain multiple segments
+
+        Returns:
+            (remaining_text, list_of_commands)
+            where each command is the dict returned by check()
+        """
+        if not text:
+            return ("", [])
+
+        # Split on sentence boundaries for checking
+        # We check the entire text first (most common case)
+        result = self.check(text)
+        if result:
+            return (result["remaining_text"], [result])
+
+        # No command found in the whole text
+        return (text, [])
diff --git a/src/command_executor.py b/src/command_executor.py
new file mode 100644
index 0000000..0e918be
--- /dev/null
+++ b/src/command_executor.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""
+Voice Command Executor
+Dispatches detected commands to their appropriate actions:
+- insert_text: substitute the trigger with a text string (punctuation, whitespace)
+- format_toggle: toggle a formatting state (Phase 2)
+- format_block: apply block-level formatting (Phase 2)
+"""
+
+from typing import Optional
+from debug import debug_print
+
+
+class CommandExecutor:
+    """
+    Executes voice commands by producing text substitutions
+    and/or triggering autotype keystrokes.
+    """
+
+    def __init__(self):
+        # Track last executed command for debugging
+        self.last_command = None
+        self.last_result = None
+
+    def execute(self, command: dict) -> Optional[str]:
+        """
+        Execute a detected command.
+
+        Args:
+            command: Command info dict from CommandDetector.check():
+                {
+                    'command_id': str,
+                    'action': str,
+                    'insert': str or None,
+                    'format': str or None,
+                    'matched_trigger': str,
+                    'remaining_text': str,
+                }
+
+        Returns:
+            Text to insert into the output buffer (replaces the command segment),
+            or None if the command produces no text (e.g., a keystroke-only action).
+        """
+        if not command:
+            return None
+
+        action = command.get("action", "")
+        self.last_command = command
+
+        if action == "insert_text":
+            result = self._execute_insert_text(command)
+        elif action == "format_toggle":
+            result = self._execute_format_toggle(command)
+        elif action == "format_block":
+            result = self._execute_format_block(command)
+        else:
+            debug_print(f"[CMD] Unknown action type: {action}")
+            result = None
+
+        self.last_result = result
+        return result
+
+    def _execute_insert_text(self, command: dict) -> Optional[str]:
+        """
+        Insert text action: replace the voice command with a text string.
+        E.g., "comma" -> ","
+        """
+        insert = command.get("insert", "")
+        cmd_id = command.get("command_id", "?")
+
+        if insert is not None:
+            debug_print(f"[CMD] insert_text: '{cmd_id}' -> '{insert}'")
+            return insert
+
+        debug_print(f"[CMD] insert_text: '{cmd_id}' has no insert value")
+        return None
+
+    def _execute_format_toggle(self, command: dict) -> Optional[str]:
+        """
+        Format toggle action (Phase 2 placeholder).
+        E.g., "bold" toggles bold formatting on/off.
+        """
+        fmt = command.get("format", "?")
+        debug_print(f"[CMD] format_toggle: '{fmt}' (Phase 2 - not yet implemented)")
+        return None
+
+    def _execute_format_block(self, command: dict) -> Optional[str]:
+        """
+        Format block action (Phase 2 placeholder).
+        E.g., "heading 1" applies H1 to the next paragraph.
+        """
+        fmt = command.get("format", "?")
+        debug_print(f"[CMD] format_block: '{fmt}' (Phase 2 - not yet implemented)")
+        return None
+
+    def execute_autotype_keys(self, keys: str) -> bool:
+        """
+        Send a keystroke sequence to the focused application via autotype.
+        Used for formatting commands that need to send Ctrl+B, etc.
+
+        Args:
+            keys: Key combination string (e.g., "ctrl+b", "Return")
+
+        Returns:
+            True if successful
+        """
+        try:
+            import subprocess
+            import shutil
+
+            if shutil.which("xdotool"):
+                subprocess.run(
+                    ["xdotool", "key", "--clearmodifiers", keys],
+                    check=True,
+                    capture_output=True
+                )
+                return True
+            else:
+                debug_print("[CMD] xdotool not available for keystroke simulation")
+                return False
+        except Exception as e:
+            debug_print(f"[CMD] Keystroke error: {e}")
+            return False
diff --git a/src/commands_config.py b/src/commands_config.py
new file mode 100644
index 0000000..d5dd2c6
--- /dev/null
+++ b/src/commands_config.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""
+Voice Commands Configuration Loader
+Loads voice command definitions from config/voice_commands.yaml
+"""
+
+import yaml
+from pathlib import Path
+from typing import Dict, List, Optional
+
+
+class VoiceCommandsConfig:
+    """Loads and provides access to voice command definitions."""
+
+    def __init__(self, config_path: str = None):
+        if config_path is None:
+            config_path = Path(__file__).parent.parent / "config" / "voice_commands.yaml"
+        self.config_path = Path(config_path)
+        self.config = self._load_config()
+
+    def _load_config(self) -> dict:
+        """Load configuration from YAML file."""
+        if not self.config_path.exists():
+            print(f"Voice commands config not found: {self.config_path}")
+            return {"commands": {}, "detection_mode": "isolation", "prefix_word": "command"}
+
+        with open(self.config_path, 'r') as f:
+            data = yaml.safe_load(f)
+            return data if data else {"commands": {}, "detection_mode": "isolation", "prefix_word": "command"}
+
+    @property
+    def detection_mode(self) -> str:
+        """Get detection mode: 'isolation' or 'prefix'."""
+        return self.config.get("detection_mode", "isolation")
+
+    @property
+    def prefix_word(self) -> str:
+        """Get prefix word for prefix detection mode."""
+        return self.config.get("prefix_word", "command")
+
+    def get_commands(self) -> Dict:
+        """Get all command definitions."""
+        return self.config.get("commands", {})
+
+    def build_trigger_map(self, language: str = None) -> Dict[str, dict]:
+        """
+        Build a flat lookup from trigger phrase -> command info.
+
+        Args:
+            language: Language code to filter triggers (e.g., 'en', 'he').
+                      If None, includes triggers from all languages.
+
+        Returns:
+            Dict mapping lowercase trigger phrase -> {
+                'command_id': str,
+                'action': str,
+                'insert': str or None,
+                'format': str or None,
+            }
+        """
+        trigger_map = {}
+        commands = self.get_commands()
+
+        for cmd_id, cmd_def in commands.items():
+            triggers = cmd_def.get("triggers", {})
+            action = cmd_def.get("action", "")
+            insert = cmd_def.get("insert")
+            fmt = cmd_def.get("format")
+
+            # Collect trigger phrases for the specified language or all languages
+            phrases = []
+            if language and language in triggers:
+                phrases = triggers[language]
+            elif language is None:
+                for lang_phrases in triggers.values():
+                    phrases.extend(lang_phrases)
+            else:
+                # Language not found in triggers — try 'en' as fallback
+                phrases = triggers.get("en", [])
+
+            for phrase in phrases:
+                trigger_map[phrase.lower().strip()] = {
+                    "command_id": cmd_id,
+                    "action": action,
+                    "insert": insert,
+                    "format": fmt,
+                }
+
+        return trigger_map
+
+
+def load_voice_commands_config() -> Optional[VoiceCommandsConfig]:
+    """
+    Load voice commands configuration, returning None if not available.
+
+    Returns:
+        VoiceCommandsConfig instance or None
+    """
+    try:
+        config = VoiceCommandsConfig()
+        commands = config.get_commands()
+        if not commands:
+            print("Voice commands config loaded but contains no commands")
+            return None
+        return config
+    except Exception as e:
+        print(f"Error loading voice commands config: {e}")
+        return None
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index fd1e279..d3820fb 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -15,6 +15,15 @@ from cmque import DataDeque, PairDeque, Queue
 from whispering_ui.state import AppState
 from session_logger import SessionLogger
 
+# Voice command modules
+try:
+    from commands_config import load_voice_commands_config
+    from command_detector import CommandDetector
+    from command_executor import CommandExecutor
+    VOICE_COMMANDS_AVAILABLE = True
+except ImportError:
+    VOICE_COMMANDS_AVAILABLE = False
+
 
 class ProcessingBridge:
     """
@@ -68,6 +77,72 @@ class ProcessingBridge:
         self._stop_requested = False
         self._auto_stopped = False
 
+        # Voice command detection
+        self.command_detector = None
+        self.command_executor = None
+        self._init_voice_commands()
+
+    def _init_voice_commands(self):
+        """Initialize voice command detection if available and enabled."""
+        if not VOICE_COMMANDS_AVAILABLE:
+            return
+
+        if not self.state.voice_commands_enabled:
+            self.command_detector = None
+            self.command_executor = None
+            return
+
+        try:
+            config = load_voice_commands_config()
+            if config:
+                # Determine language for trigger matching
+                lang = self.state.source_language
+                if lang == "auto" or not lang:
+                    lang = None  # Use all languages
+
+                self.command_detector = CommandDetector(config, language=lang)
+                self.command_executor = CommandExecutor()
+                print(f"[INFO] Voice commands initialized (mode={config.detection_mode}, lang={lang})", flush=True)
+            else:
+                self.command_detector = None
+                self.command_executor = None
+        except Exception as e:
+            print(f"[WARN] Voice commands init failed: {e}", flush=True)
+            self.command_detector = None
+            self.command_executor = None
+
+    def _detect_and_execute_command(self, text: str) -> str:
+        """
+        Check if text contains a voice command. If so, execute it and return
+        the replacement text (or empty string if the command consumed the text).
+        If no command, return the original text unchanged.
+
+        Args:
+            text: Finalized text segment from Whisper
+
+        Returns:
+            Text to use in the output buffer (original or substituted)
+        """
+        if not self.command_detector or not self.command_executor:
+            return text
+
+        if not text or not text.strip():
+            return text
+
+        result = self.command_detector.check(text)
+        if result is None:
+            return text
+
+        # Command detected — execute it
+        replacement = self.command_executor.execute(result)
+
+        if replacement is not None:
+            # The command produced replacement text (e.g., "comma" -> ",")
+            return replacement
+        else:
+            # The command consumed the text with no output (e.g., a keystroke action)
+            return ""
+
     def start_recording(self):
         """Start the recording and processing thread."""
         if self.state.is_recording:
@@ -93,6 +168,9 @@ class ProcessingBridge:
         self.tts_session_id = time.strftime("%Y%m%d_%H%M%S")
         self.tts_session_text = ""
 
+        # Re-initialize voice commands (language or settings may have changed)
+        self._init_voice_commands()
+
         # Initialize AI processor if enabled
         self.ai_processor = self._create_ai_processor()
 
@@ -441,6 +519,10 @@ class ProcessingBridge:
         done_text = done_text or ""
         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)
+
         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 3bccbd8..631f217 100644
--- a/src/whispering_ui/components/output.py
+++ b/src/whispering_ui/components/output.py
@@ -1,13 +1,51 @@
 #!/usr/bin/env python3
 """
 Output Component
-Text display panels for Whisper, AI, and Translation output with Copy/Cut buttons
+Rich text display panels for Whisper, AI, and Translation output with Copy/Cut buttons.
+
+Panels render HTML content inside scrollable containers, supporting formatted text
+from voice commands (bold, italic, headings, etc.) while maintaining plain-text
+copy/cut via clipboard.
 """
 
+import html
+import json
+import re
 from nicegui import ui
 from whispering_ui.state import AppState
 
 
+def _text_to_html(text: str) -> str:
+    """
+    Convert plain text to safe HTML for display.
+    Escapes HTML entities, then converts newlines to <br> tags.
+    Preserves any existing HTML tags that were inserted by the command system.
+    """
+    if not text:
+        return ""
+
+    # The text may already contain HTML tags from the command executor (Phase 2).
+    # For Phase 1, all text is plain so we escape everything.
+    # We escape & < > but preserve newlines as <br>.
+    escaped = html.escape(text)
+    # Convert \n to <br>
+    escaped = escaped.replace("\n", "<br>")
+    # Convert \t to spaces for display
+    escaped = escaped.replace("\t", "&nbsp;&nbsp;&nbsp;&nbsp;")
+    return escaped
+
+
+def _html_to_plain(html_text: str) -> str:
+    """
+    Strip HTML tags from text for clipboard operations.
+    Converts <br> back to newlines and removes all other tags.
+    """
+    if not html_text:
+        return ""
+    # The state stores plain text, so just return it directly
+    return html_text
+
+
 def create_output_panels(state: AppState, bridge=None):
     """
     Create output text panels with Copy/Cut buttons.
@@ -52,9 +90,11 @@ def create_output_panels(state: AppState, bridge=None):
         },
     ]
 
-    textareas = {}
+    html_panels = {}
     count_labels = {}
     title_labels = {}
+    # Track previous text values to avoid redundant DOM updates
+    _prev_values = {cfg['key']: None for cfg in panel_defs}
 
     with output_container:
         stack = ui.element('div').classes('output-stack flex flex-col w-full h-full flex-1')
@@ -78,7 +118,8 @@ def create_output_panels(state: AppState, bridge=None):
                                 on_click=lambda cfg=config: _cut_text(
                                     state,
                                     cfg['cut_type'],
-                                    textareas[cfg['key']]
+                                    cfg['key'],
+                                    html_panels
                                 )
                             ).props('dense flat').classes('text-xs')
 
@@ -102,30 +143,62 @@ def create_output_panels(state: AppState, bridge=None):
                                 config['play_btn'] = play_btn
                                 config['stop_btn'] = stop_btn
 
-                    textareas[config['key']] = ui.textarea(
-                        placeholder=config['placeholder']
-                    ).classes('w-full h-full flex-1 output-textarea').style('font-family: monospace; min-height: 0; height: 100%;').props('outlined readonly')
+                    # Rich text panel: scrollable div with HTML content
+                    scroll_container = ui.element('div').classes(
+                        'w-full flex-1 output-richtext'
+                    ).style(
+                        'overflow-y: auto; overflow-x: hidden; '
+                        'min-height: 0; height: 100%; '
+                        'font-family: Menlo, Consolas, "Liberation Mono", monospace; '
+                        'font-size: 0.875rem; '
+                        'line-height: 1.5; '
+                        'padding: 0.25rem 0.4rem; '
+                        'color: #e0e0e0; '
+                        'user-select: text; '
+                        'white-space: pre-wrap; '
+                        'word-wrap: break-word; '
+                    )
+
+                    with scroll_container:
+                        html_panel = ui.html('').classes('w-full')
+                        html_panels[config['key']] = html_panel
 
         def update_outputs():
             for cfg in panel_defs:
                 text_value = getattr(state, cfg['state_attr'])
-                area = textareas[cfg['key']]
-                if area.value != text_value:
-                    area.value = text_value
+                key = cfg['key']
+
+                # Only update DOM when value actually changed
+                if _prev_values[key] != text_value:
+                    _prev_values[key] = text_value
+                    if text_value:
+                        html_content = _text_to_html(text_value)
+                    else:
+                        html_content = (
+                            f'<span style="color: #666; font-style: italic;">'
+                            f'{html.escape(cfg["placeholder"])}</span>'
+                        )
+                    html_panels[key].content = html_content
+
+                    # Auto-scroll to bottom
+                    html_panels[key].run_method(
+                        'scrollIntoView',
+                        False  # alignToTop=False -> scroll to bottom
+                    )
 
                 chars, words = cfg['count_fn']()
-                count_labels[cfg['key']].text = f'{chars} chars, {words} words'
+                count_labels[key].text = f'{chars} chars, {words} words'
 
                 # Update AI panel title dynamically
-                if cfg['key'] == 'ai':
+                if key == 'ai':
                     task_name = state.get_current_ai_task_name()
                     if task_name:
-                        title_labels[cfg['key']].text = f"AI Output - {task_name}"
+                        title_labels[key].text = f"AI Output - {task_name}"
                     else:
-                        title_labels[cfg['key']].text = cfg['title']
+                        title_labels[key].text = cfg['title']
 
                 # Update audio playback controls visibility for AI panel
-                if cfg['key'] == 'ai' and 'play_btn' in cfg and 'stop_btn' in cfg:
+                if key == 'ai' and 'play_btn' in cfg and 'stop_btn' in cfg:
                     # Show controls if TTS is enabled, source is AI, and we have an audio file
                     show_controls = (
                         state.tts_enabled and
@@ -141,8 +214,8 @@ def create_output_panels(state: AppState, bridge=None):
 
 
 def _copy_text(text: str, label: str):
-    """Copy text to clipboard (thread-safe)."""
-    # Capture the text value immediately to avoid race conditions
+    """Copy text to clipboard (thread-safe). Strips any HTML tags."""
+    # State stores plain text, so use it directly
     text_to_copy = str(text) if text else ""
 
     if not text_to_copy.strip():
@@ -150,8 +223,6 @@ def _copy_text(text: str, label: str):
         return
 
     try:
-        # Use JavaScript clipboard API via ui.run_javascript
-        import json
         # Escape the text safely for JavaScript
         escaped_text = json.dumps(text_to_copy)
         # Use async clipboard API with proper error handling
@@ -165,16 +236,15 @@ def _copy_text(text: str, label: str):
         }})();
         '''
         ui.run_javascript(js_code)
-        ui.notify(f'✓ Copied {label} text to clipboard', type='positive')
+        ui.notify(f'Copied {label} text to clipboard', type='positive')
     except Exception as e:
-        # Log error but don't crash
         print(f"Clipboard error: {e}")
         ui.notify(f'Clipboard error - try again', type='warning')
 
 
-def _cut_text(state: AppState, text_type: str, textarea):
+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 to avoid race conditions
+    # Capture the text value immediately
     if text_type == 'whisper':
         text = str(state.whisper_text) if state.whisper_text else ""
     elif text_type == 'ai':
@@ -189,8 +259,7 @@ def _cut_text(state: AppState, text_type: str, textarea):
         return
 
     try:
-        # Copy to clipboard with async API
-        import json
+        # Copy to clipboard
         escaped_text = json.dumps(text)
         js_code = f'''
         (async () => {{
@@ -211,14 +280,14 @@ def _cut_text(state: AppState, text_type: str, textarea):
         elif text_type == 'translation':
             state.translation_text = ""
 
-        # Update textarea (wrap in try to prevent crash if textarea is being updated)
+        # Clear HTML panel
         try:
-            textarea.value = ""
+            if panel_key in html_panels:
+                html_panels[panel_key].content = ""
         except Exception:
-            pass  # Ignore if textarea is busy
+            pass
 
-        ui.notify(f'✓ Cut text to clipboard', type='positive')
+        ui.notify(f'Cut text to clipboard', type='positive')
     except Exception as e:
-        # Log error but don't crash
         print(f"Cut error: {e}")
         ui.notify(f'Cut error - try again', type='warning')
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index 163a732..2a35f13 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -298,6 +298,12 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             ).classes('flex-grow').props('dense')
             auto_select.on_value_change(lambda e: setattr(state, 'autotype_mode', e.value))
 
+        # Voice commands
+        with ui.row().classes('items-center w-full gap-1'):
+            vcmd_cb = ui.checkbox('Voice Commands', value=state.voice_commands_enabled).props('dense')
+            vcmd_cb.on_value_change(lambda e: setattr(state, 'voice_commands_enabled', e.value))
+            vcmd_cb.tooltip('Detect voice commands (comma, period, new paragraph, etc.)')
+
         ui.separator().classes('my-1')
 
         # === TRANSLATION SECTION ===
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index b7ea662..9e88563 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -55,6 +55,7 @@ def main():
 
     state.auto_stop_enabled = settings.get("auto_stop_enabled", False)
     state.auto_stop_minutes = settings.get("auto_stop_minutes", 5)
+    state.voice_commands_enabled = settings.get("voice_commands_enabled", False)
 
     # Initialize microphone list
     try:
@@ -252,52 +253,18 @@ def main():
                 margin-bottom: 0.05rem;
             }
 
-            .output-panel .q-field {
-                flex: 1 1 0;
-                display: flex;
-                flex-direction: column;
-                min-height: 0;
-                margin: 0;
-                --q-field-padding: 0;
-                --q-field-label-padding: 0;
-            }
-
-            .output-panel .q-field__inner,
-            .output-panel .q-field__control,
-            .output-panel .q-field__native {
+            .output-panel .output-richtext {
                 flex: 1 1 0;
                 min-height: 0;
                 height: 100%;
                 width: 100%;
             }
 
-            .output-panel .q-field__bottom,
-            .output-panel .q-field__messages {
-                display: none;
-            }
-
-            .output-panel .q-field__control {
-                align-items: stretch;
-            }
-
-            .output-panel .q-field__native textarea {
-                height: 100% !important;
-                min-height: 0;
-                width: 100%;
-                resize: none;
-                padding: 0;
-                margin: 0;
-            }
-
-            .output-textarea {
+            .output-richtext {
                 flex: 1 1 0;
                 min-height: 0;
                 width: 100%;
                 height: 100%;
-                resize: none;
-                font-family: Menlo, Consolas, 'Liberation Mono', monospace;
-                padding-bottom: 0;
-                margin: 0;
             }
 
             .section-muted {
@@ -374,6 +341,7 @@ def main():
         settings.set("autotype", state.autotype_mode)
         settings.set("auto_stop_enabled", state.auto_stop_enabled)
         settings.set("auto_stop_minutes", state.auto_stop_minutes)
+        settings.set("voice_commands_enabled", state.voice_commands_enabled)
 
         if state.ai_available:
             settings.set("ai_enabled", state.ai_enabled)
diff --git a/src/whispering_ui/state.py b/src/whispering_ui/state.py
index 7b150d8..24c78b2 100644
--- a/src/whispering_ui/state.py
+++ b/src/whispering_ui/state.py
@@ -54,6 +54,9 @@ class AppState:
     # === Autotype Settings ===
     autotype_mode: str = "Off"  # "Off", "Whisper", "Translation", "AI"
 
+    # === Voice Commands Settings ===
+    voice_commands_enabled: bool = False
+
     # === Auto-stop Settings ===
     auto_stop_enabled: bool = False
     auto_stop_minutes: int = 5