← Whispering

fix: polish output layout and auto-stop handling

d45b8ee7678e036db3440e3871a394d559936c24 · ovidiucs · Dec 2, 2025 00:45

+470 −247 · 8 files changed

  • src/core_parts/processing.py +3 −0
  • src/debug.py +15 −0
  • src/gui.py +2 −0
  • src/settings.py +13 −1
  • src/whispering_ui/bridge.py +88 −33
  • src/whispering_ui/components/output.py +71 −64
  • src/whispering_ui/components/sidebar.py +192 −136
  • src/whispering_ui/main.py +86 −13
diff --git a/src/core_parts/processing.py b/src/core_parts/processing.py
index 3a08582..a08ea6d 100644
--- a/src/core_parts/processing.py
+++ b/src/core_parts/processing.py
@@ -175,6 +175,9 @@ def proc(index, model, vad, memory, patience, timeout, prompt, source, target, t
         while ts2tl := ts2tl_queue.get():
             done_src, curr_src = ts2tl
 
+            if curr_src and curr_src.strip():
+                last_activity_time = time.time()
+
             # Track last curr_src for exit processing
             last_curr_src = curr_src
 
diff --git a/src/debug.py b/src/debug.py
index b9dbb5f..557f1f8 100644
--- a/src/debug.py
+++ b/src/debug.py
@@ -28,3 +28,18 @@ def debug_print(*args, **kwargs):
     """Print debug message only if debug mode is enabled in settings."""
     if DEBUG_ENABLED:
         print(*args, **kwargs)
+
+
+def set_debug_enabled(enabled: bool, *, persist: bool = False) -> bool:
+    """Update debug flag at runtime and optionally persist to settings."""
+    global DEBUG_ENABLED, _settings
+    DEBUG_ENABLED = bool(enabled)
+
+    if _settings is None:
+        _settings = Settings()
+
+    if persist:
+        _settings.set("debug_enabled", DEBUG_ENABLED)
+        _settings.save()
+
+    return DEBUG_ENABLED
diff --git a/src/gui.py b/src/gui.py
index cb5f1fb..3275895 100755
--- a/src/gui.py
+++ b/src/gui.py
@@ -8,6 +8,7 @@ from tkinter import filedialog, messagebox
 
 import core
 from settings import Settings
+from debug import set_debug_enabled
 from gui_parts.help_dialog import HelpDialog, MODEL_VRAM
 from gui_parts.text_widget import Text
 
@@ -26,6 +27,7 @@ class App(tk.Tk):
 
         # Load debug mode from settings (default: False)
         self.debug_enabled = self.settings.get("debug_enabled", False)
+        set_debug_enabled(self.debug_enabled)
 
         # Load text visibility state from settings (default: False - start in minimal mode)
         self.text_visible = self.settings.get("text_visible", False)
diff --git a/src/settings.py b/src/settings.py
index cb95e59..3ed9dcc 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -13,7 +13,18 @@ class Settings:
     """Manages application settings persistence."""
 
     def __init__(self, settings_file: str = "whispering_settings.json"):
-        self.settings_file = Path(settings_file)
+        settings_path = Path(settings_file)
+        if not settings_path.is_absolute():
+            base_dir = Path(__file__).resolve().parent
+            project_root = base_dir.parent
+            primary = (project_root / settings_path).resolve()
+            legacy = (base_dir / settings_path).resolve()
+            if primary.exists() or not legacy.exists():
+                settings_path = primary
+            else:
+                settings_path = legacy
+
+        self.settings_file = settings_path
         self.defaults = {
             "mic_index": 0,
             "model": "large-v3",
@@ -38,6 +49,7 @@ class Settings:
             "ai_trigger_mode": "time",  # "time" or "words"
             "ai_process_words": 150,  # Words per processing batch
             "text_visible": True,  # Text windows visible by default
+            "debug_enabled": False,
             "auto_stop_enabled": False,  # Auto-stop disabled by default
             "auto_stop_minutes": 5  # Auto-stop after N minutes of inactivity
         }
diff --git a/src/whispering_ui/bridge.py b/src/whispering_ui/bridge.py
index 0298180..1a2c5cd 100644
--- a/src/whispering_ui/bridge.py
+++ b/src/whispering_ui/bridge.py
@@ -45,6 +45,14 @@ class ProcessingBridge:
         self.tts_session_text = ""
         self.tts_session_id = None
 
+        # Track last committed text for incremental updates
+        self._whisper_committed = ""
+        self._translation_committed = ""
+        self._ai_committed = ""
+        self._stream_live = False
+        self._stop_requested = False
+        self._auto_stopped = False
+
     def start_recording(self):
         """Start the recording and processing thread."""
         if self.state.is_recording:
@@ -60,6 +68,15 @@ class ProcessingBridge:
         self.state.error_message = None
         self.state.status_message = "Starting..."
         self.level[0] = 0
+        self.state.whisper_text = ""
+        self.state.translation_text = ""
+        self.state.ai_text = ""
+        self._whisper_committed = ""
+        self._translation_committed = ""
+        self._ai_committed = ""
+        self._stream_live = False
+        self._stop_requested = False
+        self._auto_stopped = False
 
         # Start new TTS session
         self.tts_session_id = time.strftime("%Y%m%d_%H%M%S")
@@ -140,6 +157,7 @@ class ProcessingBridge:
             return
 
         # Signal stop
+        self._stop_requested = True
         self.ready[0] = False
         self.state.status_message = "Stopping..."
 
@@ -161,8 +179,12 @@ class ProcessingBridge:
 
         # Update state
         self.state.is_recording = False
-        self.state.status_message = ""
+        if not self._auto_stopped:
+            self.state.status_message = ""
         self.state.audio_level = 0
+        self._stream_live = False
+        self._auto_stopped = False
+        self._stop_requested = False
 
     def _start_polling(self):
         """Start polling queues for new data."""
@@ -189,59 +211,92 @@ class ProcessingBridge:
         # Update audio level
         self.state.audio_level = min(100, self.level[0])
 
+        # Update status once audio stream is live
+        if self.ready[0] is True and not self._stream_live:
+            self._stream_live = True
+            self.state.status_message = "Listening..."
+        elif self.ready[0] is False and self._stream_live and not self._stop_requested and self.state.auto_stop_enabled and not self._auto_stopped:
+            self._auto_stopped = True
+            minutes = self.state.auto_stop_minutes
+            self.state.status_message = f"Auto-stop after {minutes}m of silence"
+
         # Poll whisper queue (Queue wraps PairDeque)
         while self.ts_queue:
             res = self.ts_queue.get()
             if res:
                 done, curr = res
-                # Append done text (new complete text)
-                if done and len(done) > len(self.state.whisper_text):
-                    new_text = done[len(self.state.whisper_text):]
-                    self.state.whisper_text = done
+                done_text = done or ""
+                curr_text = curr or ""
+                new_text = ""
+
+                if done_text:
+                    prev_committed = self._whisper_committed if len(done_text) >= len(self._whisper_committed) else ""
+                    if len(done_text) > len(prev_committed):
+                        new_text = done_text[len(prev_committed):]
+                    self._whisper_committed = done_text
 
-                    # Handle TTS for Whisper
-                    if self.state.tts_enabled and self.state.tts_source == "whisper":
-                        self.tts_session_text += new_text + " "
+                    if new_text:
+                        if self.state.tts_enabled and self.state.tts_source == "whisper":
+                            self.tts_session_text += new_text + " "
 
-                    # Handle autotype for Whisper
-                    if self.state.autotype_mode == "Whisper":
-                        self._autotype_text(new_text)
+                        if self.state.autotype_mode == "Whisper" and new_text.strip():
+                            self._autotype_text(new_text)
+
+                preview = self._whisper_committed + curr_text
+                if preview != self.state.whisper_text:
+                    self.state.whisper_text = preview
 
         # Poll translation queue (Queue wraps PairDeque)
         while self.tl_queue:
             res = self.tl_queue.get()
             if res:
                 done, curr = res
-                # Append done text (new complete text)
-                if done and len(done) > len(self.state.translation_text):
-                    new_text = done[len(self.state.translation_text):]
-                    self.state.translation_text = done
+                done_text = done or ""
+                curr_text = curr or ""
+                new_text = ""
+
+                if done_text:
+                    prev_committed = self._translation_committed if len(done_text) >= len(self._translation_committed) else ""
+                    if len(done_text) > len(prev_committed):
+                        new_text = done_text[len(prev_committed):]
+                    self._translation_committed = done_text
+
+                    if new_text:
+                        if self.state.tts_enabled and self.state.tts_source == "translation":
+                            self.tts_session_text += new_text + " "
 
-                    # Handle TTS for Translation
-                    if self.state.tts_enabled and self.state.tts_source == "translation":
-                        self.tts_session_text += new_text + " "
+                        if self.state.autotype_mode == "Translation" and new_text.strip():
+                            self._autotype_text(new_text)
 
-                    # Handle autotype for Translation
-                    if self.state.autotype_mode == "Translation":
-                        self._autotype_text(new_text)
+                preview = self._translation_committed + curr_text
+                if preview != self.state.translation_text:
+                    self.state.translation_text = preview
 
         # Poll AI proofread queue (Queue wraps PairDeque)
         while self.pr_queue:
             res = self.pr_queue.get()
             if res:
                 done, curr = res
-                # Append done text (new complete text)
-                if done and len(done) > len(self.state.ai_text):
-                    new_text = done[len(self.state.ai_text):]
-                    self.state.ai_text = done
-
-                    # Handle TTS for AI
-                    if self.state.tts_enabled and self.state.tts_source == "ai":
-                        self.tts_session_text += new_text + " "
-
-                    # Handle autotype for AI
-                    if self.state.autotype_mode == "AI":
-                        self._autotype_text(new_text)
+                done_text = done or ""
+                curr_text = curr or ""
+                new_text = ""
+
+                if done_text:
+                    prev_committed = self._ai_committed if len(done_text) >= len(self._ai_committed) else ""
+                    if len(done_text) > len(prev_committed):
+                        new_text = done_text[len(prev_committed):]
+                    self._ai_committed = done_text
+
+                    if new_text:
+                        if self.state.tts_enabled and self.state.tts_source == "ai":
+                            self.tts_session_text += new_text + " "
+
+                        if self.state.autotype_mode == "AI" and new_text.strip():
+                            self._autotype_text(new_text)
+
+                preview = self._ai_committed + curr_text
+                if preview != self.state.ai_text:
+                    self.state.ai_text = preview
 
     def _validate_settings(self) -> bool:
         """Validate settings before starting. Returns True if valid."""
diff --git a/src/whispering_ui/components/output.py b/src/whispering_ui/components/output.py
index fa726b4..dd5cd40 100644
--- a/src/whispering_ui/components/output.py
+++ b/src/whispering_ui/components/output.py
@@ -19,73 +19,80 @@ def create_output_panels(state: AppState):
         The output container element for visibility control
     """
 
-    output_container = ui.column().classes('flex-grow p-4 gap-3')
+    output_container = ui.column().classes('flex-grow w-full h-full gap-0').style('height: 100%; min-height: 0; padding: 0;')
+
+    panel_defs = [
+        {
+            'key': 'whisper',
+            'title': 'Whisper Output',
+            'placeholder': 'Whisper transcription will appear here...',
+            'state_attr': 'whisper_text',
+            'count_fn': state.get_whisper_count,
+            'cut_type': 'whisper',
+            'copy_label': 'Whisper',
+        },
+        {
+            'key': 'ai',
+            'title': 'AI Output',
+            'placeholder': 'AI processed text will appear here...',
+            'state_attr': 'ai_text',
+            'count_fn': state.get_ai_count,
+            'cut_type': 'ai',
+            'copy_label': 'AI',
+        },
+        {
+            'key': 'translation',
+            'title': 'Translation Output',
+            'placeholder': 'Translation will appear here...',
+            'state_attr': 'translation_text',
+            'count_fn': state.get_translation_count,
+            'cut_type': 'translation',
+            'copy_label': 'Translation',
+        },
+    ]
+
+    textareas = {}
+    count_labels = {}
 
     with output_container:
-        # === WHISPER OUTPUT ===
-        with ui.column().classes('flex-grow'):
-            with ui.row().classes('items-center justify-between w-full mb-1'):
-                ui.label('Whisper Output').classes('font-bold')
-
-                # Count and buttons
-                with ui.row().classes('items-center gap-2'):
-                    whisper_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
-                    ui.button('Copy', on_click=lambda: _copy_text(state.whisper_text, 'Whisper')).props('dense flat').classes('text-xs')
-                    ui.button('Cut', on_click=lambda: _cut_text(state, 'whisper', whisper_area)).props('dense flat').classes('text-xs')
-
-            whisper_area = ui.textarea(
-                placeholder='Whisper transcription will appear here...'
-            ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly')
-
-        # === AI OUTPUT ===
-        with ui.column().classes('flex-grow'):
-            with ui.row().classes('items-center justify-between w-full mb-1'):
-                ui.label('AI Output').classes('font-bold')
-
-                # Count and buttons
-                with ui.row().classes('items-center gap-2'):
-                    ai_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
-                    ui.button('Copy', on_click=lambda: _copy_text(state.ai_text, 'AI')).props('dense flat').classes('text-xs')
-                    ui.button('Cut', on_click=lambda: _cut_text(state, 'ai', ai_area)).props('dense flat').classes('text-xs')
-
-            ai_area = ui.textarea(
-                placeholder='AI processed text will appear here...'
-            ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly')
-
-        # === TRANSLATION OUTPUT ===
-        with ui.column().classes('flex-grow'):
-            with ui.row().classes('items-center justify-between w-full mb-1'):
-                ui.label('Translation Output').classes('font-bold')
-
-                # Count and buttons
-                with ui.row().classes('items-center gap-2'):
-                    trans_count = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
-                    ui.button('Copy', on_click=lambda: _copy_text(state.translation_text, 'Translation')).props('dense flat').classes('text-xs')
-                    ui.button('Cut', on_click=lambda: _cut_text(state, 'translation', trans_area)).props('dense flat').classes('text-xs')
-
-            trans_area = ui.textarea(
-                placeholder='Translation will appear here...'
-            ).classes('w-full').style('min-height: 200px; font-family: monospace;').props('outlined readonly')
-
-        # Update text areas from state
+        stack = ui.column().classes('output-stack w-full h-full flex-1')
+        for config in panel_defs:
+            with stack:
+                panel = ui.element('div').classes('output-panel').style('flex: 1; min-height: 0;')
+                with panel:
+                    with ui.row().classes('panel-header w-full'):
+                        ui.label(config['title']).classes('font-bold')
+                        with ui.row().classes('items-center gap-2'):
+                            count_labels[config['key']] = ui.label('0 chars, 0 words').classes('text-xs text-gray-500')
+                            ui.button(
+                                'Copy',
+                                on_click=lambda cfg=config: _copy_text(
+                                    getattr(state, cfg['state_attr']),
+                                    cfg['copy_label']
+                                )
+                            ).props('dense flat').classes('text-xs')
+                            ui.button(
+                                'Cut',
+                                on_click=lambda cfg=config: _cut_text(
+                                    state,
+                                    cfg['cut_type'],
+                                    textareas[cfg['key']]
+                                )
+                            ).props('dense flat').classes('text-xs')
+
+                    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')
+
         def update_outputs():
-            # Update whisper
-            if whisper_area.value != state.whisper_text:
-                whisper_area.value = state.whisper_text
-            wc, ww = state.get_whisper_count()
-            whisper_count.text = f'{wc} chars, {ww} words'
-
-            # Update AI
-            if ai_area.value != state.ai_text:
-                ai_area.value = state.ai_text
-            ac, aw = state.get_ai_count()
-            ai_count.text = f'{ac} chars, {aw} words'
-
-            # Update translation
-            if trans_area.value != state.translation_text:
-                trans_area.value = state.translation_text
-            tc, tw = state.get_translation_count()
-            trans_count.text = f'{tc} chars, {tw} words'
+            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
+
+                chars, words = cfg['count_fn']()
+                count_labels[cfg['key']].text = f'{chars} chars, {words} words'
 
         ui.timer(0.2, update_outputs)
 
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index f679460..a9a20bb 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -4,7 +4,7 @@ Complete Sidebar Component with Help and File Upload
 Control panel with all settings and controls
 """
 
-from nicegui import ui
+from nicegui import ui, app
 import core
 from whispering_ui.state import AppState
 from whispering_ui.bridge import ProcessingBridge
@@ -115,102 +115,113 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
 
         # Enable AI checkbox
         ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled).props('dense')
-        ai_cb.on_value_change(lambda e: setattr(state, 'ai_enabled', e.value))
+        ai_section = ui.column().classes('w-full gap-1')
+
+        def on_ai_toggle(e):
+            state.ai_enabled = e.value
+            _set_section_visual_state(ai_section, e.value)
+
+        ai_cb.on_value_change(on_ai_toggle)
         if not state.ai_available:
             ai_cb.disable()
 
         # AI controls
         if state.ai_available:
-            try:
-                from ai_config import load_ai_config
-                ai_config = load_ai_config()
-                if ai_config:
-                    # Task selection
-                    personas = ai_config.get_personas()
-                    persona_names = [p['name'] for p in personas]
-
-                    with ui.row().classes('items-center w-full gap-1'):
-                        ui.label('Task:').classes('text-xs w-12')
-                        task_select = ui.select(
-                            options=persona_names,
-                            value=persona_names[min(state.ai_persona_index, len(persona_names)-1)]
-                        ).classes('flex-grow').props('dense')
-                        task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index',
-                                                    persona_names.index(e.value) if e.value in persona_names else 0))
-
-                    # Translate checkboxes - compact
-                    with ui.row().classes('items-center w-full gap-2'):
-                        ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense')
-                        ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
-
-                        ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense')
-                        ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value))
-
-                    # Model selection
-                    models = ai_config.get_models()
-                    model_names = [m['name'] for m in models]
-
-                    with ui.row().classes('items-center w-full gap-1'):
-                        ui.label('Model:').classes('text-xs w-12')
-                        ai_model_combo = ui.select(
-                            options=model_names,
-                            value=model_names[min(state.ai_model_index, len(model_names)-1)]
-                        ).classes('flex-grow').props('dense')
-                        ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index',
-                                                       model_names.index(e.value) if e.value in model_names else 0))
-
-                    # Trigger controls - compact layout
-                    ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense')
-
-                    ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense')
-                    ai_process_btn.set_enabled(state.ai_manual_mode)
-
-                    # Trigger mode and settings
-                    with ui.row().classes('items-center w-full gap-1'):
-                        ui.label('Trigger:').classes('text-xs')
-                        ai_trigger_select = ui.select(
-                            options=["Time", "Words"],
-                            value=state.ai_trigger_mode.capitalize()
-                        ).classes('w-16').props('dense')
-                        ai_trigger_select.set_enabled(not state.ai_manual_mode)
-
-                        # Interval control
-                        interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"]
-                        interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120]
-                        interval_map = dict(zip(interval_labels, interval_values))
-
-                        current_label = "20s"
-                        for lbl, val in interval_map.items():
-                            if val == state.ai_process_interval:
-                                current_label = lbl
-                                break
-
-                        ui.label('Int:').classes('text-xs')
-                        ai_interval_select = ui.select(
-                            options=interval_labels,
-                            value=current_label
-                        ).classes('w-14').props('dense')
-                        def on_interval_change(e):
-                            state.ai_process_interval = interval_map.get(e.value, 20)
-                        ai_interval_select.on_value_change(on_interval_change)
-                        ai_interval_select.set_enabled(not state.ai_manual_mode)
-                        ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
-
-                        ui.label('W:').classes('text-xs')
-                        ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense')
-                        ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150)))
-                        ai_words_num.set_enabled(not state.ai_manual_mode)
-                        ai_words_num.set_visibility(state.ai_trigger_mode == "words")
-
-                    # Wire up event handlers
-                    ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(
-                        state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num))
-
-                    ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(
-                        state, e.value, ai_interval_select, ai_words_num))
-
-            except Exception as e:
-                print(f"Error loading AI config: {e}")
+            with ai_section:
+                try:
+                    from ai_config import load_ai_config
+                    ai_config = load_ai_config()
+                    if ai_config:
+                        # Task selection
+                        personas = ai_config.get_personas()
+                        persona_names = [p['name'] for p in personas]
+
+                        with ui.row().classes('items-center w-full gap-1'):
+                            ui.label('Task:').classes('text-xs w-12')
+                            task_select = ui.select(
+                                options=persona_names,
+                                value=persona_names[min(state.ai_persona_index, len(persona_names)-1)]
+                            ).classes('flex-grow').props('dense')
+                            task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index',
+                                                        persona_names.index(e.value) if e.value in persona_names else 0))
+
+                        # Translate checkboxes - compact
+                        with ui.row().classes('items-center w-full gap-2'):
+                            ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense')
+                            ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
+
+                            ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense')
+                            ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value))
+
+                        # Model selection
+                        models = ai_config.get_models()
+                        model_names = [m['name'] for m in models]
+
+                        with ui.row().classes('items-center w-full gap-1'):
+                            ui.label('Model:').classes('text-xs w-12')
+                            ai_model_combo = ui.select(
+                                options=model_names,
+                                value=model_names[min(state.ai_model_index, len(model_names)-1)]
+                            ).classes('flex-grow').props('dense')
+                            ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index',
+                                                           model_names.index(e.value) if e.value in model_names else 0))
+
+                        # Trigger controls - compact layout
+                        ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense')
+
+                        ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense')
+                        ai_process_btn.set_enabled(state.ai_manual_mode)
+
+                        # Trigger mode and settings
+                        with ui.row().classes('items-center w-full gap-1'):
+                            ui.label('Trigger:').classes('text-xs')
+                            ai_trigger_select = ui.select(
+                                options=["Time", "Words"],
+                                value=state.ai_trigger_mode.capitalize()
+                            ).classes('w-16').props('dense')
+                            ai_trigger_select.set_enabled(not state.ai_manual_mode)
+
+                            # Interval control
+                            interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"]
+                            interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120]
+                            interval_map = dict(zip(interval_labels, interval_values))
+
+                            current_label = "20s"
+                            for lbl, val in interval_map.items():
+                                if val == state.ai_process_interval:
+                                    current_label = lbl
+                                    break
+
+                            ui.label('Int:').classes('text-xs')
+                            ai_interval_select = ui.select(
+                                options=interval_labels,
+                                value=current_label
+                            ).classes('w-14').props('dense')
+
+                            def on_interval_change(e):
+                                state.ai_process_interval = interval_map.get(e.value, 20)
+
+                            ai_interval_select.on_value_change(on_interval_change)
+                            ai_interval_select.set_enabled(not state.ai_manual_mode)
+                            ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
+
+                            ui.label('W:').classes('text-xs')
+                            ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense')
+                            ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150)))
+                            ai_words_num.set_enabled(not state.ai_manual_mode)
+                            ai_words_num.set_visibility(state.ai_trigger_mode == "words")
+
+                        # Wire up event handlers
+                        ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(
+                            state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num))
+
+                        ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(
+                            state, e.value, ai_interval_select, ai_words_num))
+
+                except Exception as e:
+                    print(f"Error loading AI config: {e}")
+
+        _set_section_visual_state(ai_section, state.ai_enabled and state.ai_available)
 
         ui.separator().classes('my-1')
 
@@ -222,58 +233,66 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
 
         # Enable TTS
         tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled).props('dense')
-        tts_cb.on_value_change(lambda e: setattr(state, 'tts_enabled', e.value))
+        tts_section = ui.column().classes('w-full gap-1')
+
+        def on_tts_toggle(e):
+            state.tts_enabled = e.value
+            _set_section_visual_state(tts_section, e.value)
+
+        tts_cb.on_value_change(on_tts_toggle)
         if not state.tts_available:
             tts_cb.disable()
 
         if state.tts_available:
-            # Source selection - compact
-            with ui.row().classes('items-center w-full gap-1'):
-                ui.label('Src:').classes('text-xs w-10')
+            with tts_section:
+                # Source selection - compact
+                with ui.row().classes('items-center w-full gap-1'):
+                    ui.label('Src:').classes('text-xs w-10')
 
-                tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense')
-                tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+                    tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense')
+                    tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense')
+                    tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense')
 
-                tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense')
-                tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+                    tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+                    tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+                    tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
 
-                tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense')
-                tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
+                # Voice selection with streamlined upload
+                with ui.row().classes('items-center w-full gap-1'):
+                    ui.label('Voice:').classes('text-xs w-10')
+                    tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate')
 
-            # Voice selection with file upload
-            with ui.row().classes('items-center w-full gap-1'):
-                ui.label('Voice:').classes('text-xs w-10')
-                tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate')
+                    upload = ui.upload(
+                        on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label),
+                        auto_upload=True,
+                        max_file_size=50_000_000,
+                        max_files=1
+                    ).props('accept=audio/*').classes('hidden')
 
-                # File upload button
-                upload = ui.upload(
-                    on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label),
-                    auto_upload=True,
-                    max_file_size=50_000_000  # 50 MB max
-                ).props('flat dense icon=folder_open accept=audio/*').classes('w-8').style('max-width: 32px')
-                upload.set_visibility(True)
+                    ui.button(icon='folder_open', on_click=lambda u=upload: u.run_method('pickFiles')).props('flat dense round size=sm')
+                    ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm')
 
-                tts_clear_btn = ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm')
+                # Output options - compact
+                with ui.row().classes('items-center w-full gap-1'):
+                    ui.label('Out:').classes('text-xs w-10')
+                    tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense')
+                    tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value))
 
-            # Output options - compact
-            with ui.row().classes('items-center w-full gap-1'):
-                ui.label('Out:').classes('text-xs w-10')
-                tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense')
-                tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value))
+                    tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense')
+                    tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value))
 
-                tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense')
-                tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value))
+                # TTS status - compact
+                tts_status_label = ui.label('').classes('text-xs text-blue-400')
 
-            # TTS status - compact
-            tts_status_label = ui.label('').classes('text-xs text-blue-400')
+                def update_tts_status():
+                    if state.tts_status_message:
+                        tts_status_label.text = state.tts_status_message[:50]
+                    else:
+                        tts_status_label.text = ''
 
-            def update_tts_status():
-                if state.tts_status_message:
-                    tts_status_label.text = state.tts_status_message[:50]
-                else:
-                    tts_status_label.text = ''
+                ui.timer(0.2, update_tts_status)
 
-            ui.timer(0.2, update_tts_status)
+        _set_section_visual_state(tts_section, state.tts_enabled and state.tts_available)
 
         ui.separator().classes('my-1')
 
@@ -332,13 +351,7 @@ def _toggle_text(state: AppState, btn, sidebar_container):
     state.text_visible = not state.text_visible
     btn.text = 'Hide Text ◀' if state.text_visible else 'Show Text ▶'
 
-    # Get output container from sidebar
-    if hasattr(sidebar_container, '_output_container'):
-        output_container = sidebar_container._output_container
-        if output_container:
-            output_container.set_visibility(state.text_visible)
-
-    ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}')
+    sync_text_layout(state, sidebar_container, notify=True)
 
 
 def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, status):
@@ -426,3 +439,46 @@ def _clear_voice(state: AppState, bridge: ProcessingBridge, voice_label):
         bridge.tts_controller.set_reference_voice(None)
 
     ui.notify("Voice cleared - using default")
+
+
+def sync_text_layout(state: AppState, sidebar_container, *, notify: bool = False):
+    """Apply sidebar/output layout updates when text panes toggle."""
+    output_container = getattr(sidebar_container, '_output_container', None)
+    if output_container:
+        output_container.set_visibility(state.text_visible)
+
+    layout_row = getattr(sidebar_container, '_layout_row', None)
+    if layout_row:
+        if state.text_visible:
+            layout_row.classes(remove='workspace-collapsed')
+        else:
+            layout_row.classes(add='workspace-collapsed')
+
+    _resize_native_window(state.text_visible)
+
+    if notify:
+        ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}')
+
+
+def _resize_native_window(text_visible: bool):
+    """Resize native pywebview window to match sidebar/text mode."""
+    try:
+        window = getattr(app.native, 'main_window', None)
+        if not window:
+            return
+        target_width = 1200 if text_visible else 420
+        current_height = getattr(window, 'height', 800) or 800
+        window.resize(target_width, current_height)
+    except Exception:
+        # Ignore resize errors in browser mode or unsupported backends
+        return
+
+
+def _set_section_visual_state(section, enabled: bool):
+    """Dim or undim a configuration section without disabling inputs."""
+    if not section:
+        return
+    if enabled:
+        section.classes(remove='section-muted')
+    else:
+        section.classes(add='section-muted')
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index ff37329..526f418 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -4,6 +4,7 @@ Whispering NiceGUI Application
 Main entry point for the NiceGUI-based UI
 """
 
+import os
 import sys
 from pathlib import Path
 
@@ -15,8 +16,9 @@ import core
 from settings import Settings
 from whispering_ui.state import AppState
 from whispering_ui.bridge import ProcessingBridge
-from whispering_ui.components.sidebar import create_sidebar
+from whispering_ui.components.sidebar import create_sidebar, sync_text_layout
 from whispering_ui.components.output import create_output_panels
+from debug import set_debug_enabled
 
 
 def main():
@@ -31,6 +33,7 @@ def main():
 
     # Apply loaded settings to state
     state.debug_enabled = settings.get("debug_enabled", False)
+    set_debug_enabled(state.debug_enabled)
     state.text_visible = settings.get("text_visible", False)
     state.model = settings.get("model", "large-v3")
     state.vad_enabled = settings.get("vad", True)
@@ -116,6 +119,15 @@ def main():
                 padding: 0 !important;
             }
 
+            .workspace-row {
+                transition: max-width 0.25s ease;
+            }
+
+            .workspace-row.workspace-collapsed {
+                max-width: 420px;
+                margin: 0 auto;
+            }
+
             /* Sidebar - fixed width, dark background */
             .sidebar-container {
                 background: #1e1e1e;
@@ -129,7 +141,55 @@ def main():
             .output-container {
                 background: #121212;
                 flex: 1;
-                overflow-y: auto;
+                display: flex;
+                flex-direction: column;
+                min-height: 0;
+                height: 100%;
+                overflow: hidden;
+                padding: 0;
+            }
+
+            .output-stack {
+                display: flex;
+                flex-direction: column;
+                gap: 0.75rem;
+                flex: 1;
+                min-height: 0;
+                height: 100%;
+                width: 100%;
+                padding: 0.75rem;
+            }
+
+            .output-panel {
+                background: #181818;
+                border: 1px solid #2a2a2a;
+                border-radius: 8px;
+                padding: 0.5rem 0.75rem;
+                display: flex;
+                flex-direction: column;
+                min-height: 0;
+                flex: 1 1 0;
+                width: 100%;
+            }
+
+            .output-panel .panel-header {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                margin-bottom: 0.25rem;
+            }
+
+            .output-textarea {
+                flex: 1 1 0;
+                min-height: 0;
+                width: 100%;
+                height: 100%;
+                resize: none;
+                font-family: Menlo, Consolas, 'Liberation Mono', monospace;
+            }
+
+            .section-muted {
+                opacity: 0.65;
             }
 
             /* Compact controls */
@@ -164,7 +224,7 @@ def main():
 
     # === UI LAYOUT ===
     # Horizontal split: sidebar (left) | output panels (right)
-    with ui.row().classes('w-full h-screen').style('margin: 0; padding: 0; gap: 0;'):
+    with ui.row().classes('workspace-row w-full h-screen').style('margin: 0; padding: 0; gap: 0;') as workspace_row:
         # Sidebar on left - fixed width
         sidebar_container = create_sidebar(state, bridge, None).classes('sidebar-container')
 
@@ -173,10 +233,10 @@ def main():
 
         # Connect sidebar to output for show/hide
         sidebar_container._output_container = output_container
+        sidebar_container._layout_row = workspace_row
 
         # Set initial visibility
-        if not state.text_visible:
-            output_container.set_visibility(False)
+        sync_text_layout(state, sidebar_container, notify=False)
 
     # === SAVE SETTINGS ON EXIT ===
     def save_settings_on_exit():
@@ -238,14 +298,27 @@ def main():
         print("   For native window, install: pip install PyQt6 PyQt6-WebEngine")
         print("   Starting web interface at http://127.0.0.1:8000\n")
 
-    ui.run(
-        title='Whispering',
-        native=native_mode,
-        port=8000,
-        window_size=(400 if not state.text_visible else 1200, 800),
-        reload=False,
-        show=not native_mode  # Only auto-open browser in browser mode
-    )
+    if native_mode:
+        os.environ.setdefault('PYWEBVIEW_GUI', 'qt')
+
+    def launch_ui(use_native: bool):
+        ui.run(
+            title='Whispering',
+            native=use_native,
+            port=8000,
+            window_size=(400 if not state.text_visible else 1200, 800),
+            reload=False,
+            show=not use_native  # Only auto-open browser in browser mode
+        )
+
+    try:
+        launch_ui(native_mode)
+    except ModuleNotFoundError as exc:
+        if native_mode and exc.name == 'gi':
+            print("\n⚠️  GTK bindings (gi) are missing; falling back to browser mode.\n")
+            launch_ui(False)
+        else:
+            raise
 
 
 if __name__ == "__main__":