diff --git a/CLAUDE.md b/CLAUDE.md index b355c9a..c5458b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,10 @@ src/development_hub/ | `WorkspaceManager` | workspace_manager.py | Manages splittable pane layout | | `PaneWidget` | pane_widget.py | Tab container with action menu | | `DraggableTabWidget` | draggable_tab_widget.py | Tab widget supporting cross-pane drag | -| `TerminalWidget` | terminal_widget.py | PTY terminal with search functionality | +| `TerminalWidget` | terminal_widget.py | PTY terminal with search and auto-accept | | `TerminalDisplay` | terminal_display.py | Terminal rendering with scrollback buffer | +| `AutoAcceptToast` | terminal_widget.py | Countdown toast for auto-accept prompts | +| `AutoAcceptDialog` | dialogs.py | Duration selection for auto-accept | | `DashboardDataStore` | views/dashboard/data_store.py | Data persistence with file watching | | `UndoManager` | views/dashboard/undo_manager.py | Undo/redo action management | @@ -78,6 +80,7 @@ src/development_hub/ - **External File Watching**: Dashboard auto-reloads when files modified externally - **New Project Dialog**: Integrates with Ramble for voice input - **Progress Reports**: Export weekly progress summaries from daily standups +- **Auto-accept Prompts**: Automatically accept Y/yes prompts in terminals for CLI tools (Claude, Codex, etc.) ### Keyboard Shortcuts @@ -104,12 +107,29 @@ src/development_hub/ | Shortcut | Action | |----------|--------| | `Ctrl+Shift+F` | Open search bar | +| `Ctrl+Shift+V` | Paste from clipboard | | `Shift+PageUp/Down` | Scroll through history | | `Enter` (in search) | Next match | | `Shift+Enter` (in search) | Previous match | | `F3` / `Shift+F3` | Next/previous match | | `Escape` | Close search bar | +### Auto-accept Prompts + +The terminal includes an auto-accept feature for handling Y/yes confirmation prompts from CLI tools like Claude Code, Codex, etc. This is useful when running long-running AI tasks that may ask many permission questions. + +**How to use:** +1. Click ☰ (action menu) → "Auto-accept prompts..." +2. Select a duration (5 min, 15 min, 30 min, 1 hour, or custom) +3. When a Y/yes prompt is detected, a toast appears with a 5-second countdown +4. Click "Skip" to ignore, "Accept" to confirm immediately, or wait for countdown +5. Tab badge shows remaining time (e.g., `⏱4:32`) +6. Click ☰ → "Stop auto-accept" to disable early + +**Supported patterns:** +- Standard `[Y/n]`, `[y/N]`, `(y/n)` prompts +- Claude Code permission dialogs with `❯ 1. Yes` menu selection + #### Dashboard Shortcuts (when dashboard tab is active) | Shortcut | Action | diff --git a/src/development_hub/dialogs.py b/src/development_hub/dialogs.py index 934cd7c..d2a7c74 100644 --- a/src/development_hub/dialogs.py +++ b/src/development_hub/dialogs.py @@ -1849,3 +1849,101 @@ class WeeklyReportDialog(QDialog): "Copied", "Report copied to clipboard!" ) + + +class AutoAcceptDialog(QDialog): + """Dialog to configure auto-accept duration for terminal prompts.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Auto-accept Prompts") + self.setMinimumWidth(350) + self._duration_seconds = 0 + self._setup_ui() + + def _setup_ui(self): + """Set up the dialog UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(16) + + # Description + desc = QLabel( + "Automatically accept Y/yes prompts in this terminal.\n" + "Useful when running CLI tools that ask many confirmation questions." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #aaa;") + layout.addWidget(desc) + + # Preset duration buttons + preset_label = QLabel("Duration:") + layout.addWidget(preset_label) + + preset_layout = QHBoxLayout() + preset_layout.setSpacing(8) + + presets = [ + ("5 min", 5 * 60), + ("15 min", 15 * 60), + ("30 min", 30 * 60), + ("1 hour", 60 * 60), + ] + + for label, seconds in presets: + btn = QPushButton(label) + btn.setMinimumWidth(70) + btn.clicked.connect(lambda checked, s=seconds: self._accept_duration(s)) + preset_layout.addWidget(btn) + + preset_layout.addStretch() + layout.addLayout(preset_layout) + + # Custom duration + custom_layout = QHBoxLayout() + custom_layout.setSpacing(8) + + custom_label = QLabel("Custom:") + custom_layout.addWidget(custom_label) + + self._custom_input = QLineEdit() + self._custom_input.setPlaceholderText("minutes") + self._custom_input.setMaximumWidth(80) + self._custom_input.returnPressed.connect(self._accept_custom) + custom_layout.addWidget(self._custom_input) + + custom_btn = QPushButton("Start") + custom_btn.clicked.connect(self._accept_custom) + custom_layout.addWidget(custom_btn) + + custom_layout.addStretch() + layout.addLayout(custom_layout) + + # Cancel button + layout.addSpacing(8) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + layout.addWidget(cancel_btn, alignment=Qt.AlignmentFlag.AlignRight) + + def _accept_duration(self, seconds: int): + """Accept with the given duration.""" + self._duration_seconds = seconds + self.accept() + + def _accept_custom(self): + """Accept with custom duration from input field.""" + text = self._custom_input.text().strip() + if not text: + return + + try: + minutes = float(text) + if minutes <= 0: + return + self._duration_seconds = int(minutes * 60) + self.accept() + except ValueError: + pass + + def get_duration(self) -> int: + """Get the selected duration in seconds.""" + return self._duration_seconds diff --git a/src/development_hub/pane_widget.py b/src/development_hub/pane_widget.py index 952bba4..a2c1cb8 100644 --- a/src/development_hub/pane_widget.py +++ b/src/development_hub/pane_widget.py @@ -54,6 +54,7 @@ class PaneWidget(QFrame): self._pane_id = str(uuid.uuid4()) self._is_focused = False self._current_project: Project | None = None + self._terminal_base_titles: dict[int, str] = {} # tab_index -> original title self._setup_ui() @property @@ -79,6 +80,8 @@ class PaneWidget(QFrame): self.action_menu.dashboard_requested.connect(self._on_dashboard_clicked) self.action_menu.global_dashboard_requested.connect(self._on_global_dashboard_clicked) self.action_menu.terminal_requested.connect(self.terminal_requested.emit) + self.action_menu.auto_accept_requested.connect(self._on_auto_accept_requested) + self.action_menu.auto_accept_stop_requested.connect(self._on_auto_accept_stop_requested) self.action_menu.launch_discussion_requested.connect(self.launch_discussion_requested.emit) self.action_menu.standup_requested.connect(self.standup_requested.emit) self.action_menu.update_docs_requested.connect(self.update_docs_requested.emit) @@ -152,7 +155,15 @@ class PaneWidget(QFrame): pane._on_terminal_closed(terminal) terminal.closed.connect(on_closed) + # Connect auto-accept signal + def on_auto_accept_changed(remaining: int): + pane = weak_self() + if pane is not None: + pane._update_terminal_badge(terminal, remaining) + terminal.auto_accept_changed.connect(on_auto_accept_changed) + idx = self.tab_widget.addTab(terminal, title) + self._terminal_base_titles[idx] = title self.tab_widget.setCurrentIndex(idx) terminal.setFocus() return terminal @@ -219,6 +230,52 @@ class PaneWidget(QFrame): self.add_dashboard(project) return + def _on_auto_accept_requested(self): + """Handle auto-accept menu action.""" + terminal = self.get_current_terminal() + if not terminal: + return + + from development_hub.dialogs import AutoAcceptDialog + dialog = AutoAcceptDialog(self) + if dialog.exec() == dialog.DialogCode.Accepted: + duration = dialog.get_duration() + if duration > 0: + terminal.start_auto_accept(duration) + self.action_menu.set_auto_accept_active(True) + + def _on_auto_accept_stop_requested(self): + """Handle auto-accept stop menu action.""" + terminal = self.get_current_terminal() + if terminal: + terminal.stop_auto_accept() + self.action_menu.set_auto_accept_active(False) + + def _update_terminal_badge(self, terminal: TerminalWidget, remaining_seconds: int): + """Update terminal tab title with auto-accept badge.""" + # Find the tab index for this terminal + for i in range(self.tab_widget.count()): + if self.tab_widget.widget(i) == terminal: + base_title = self._terminal_base_titles.get(i, "Terminal") + # Strip any existing badge suffix + if " ⏱" in base_title: + base_title = base_title.split(" ⏱")[0] + + if remaining_seconds > 0: + # Format time as MM:SS + mins = remaining_seconds // 60 + secs = remaining_seconds % 60 + badge = f" ⏱{mins}:{secs:02d}" + self.tab_widget.setTabText(i, base_title + badge) + else: + # Remove badge + self.tab_widget.setTabText(i, base_title) + + # Update menu state if this is the current terminal + if self.tab_widget.currentWidget() == terminal: + self.action_menu.set_auto_accept_active(remaining_seconds > 0) + break + def _close_tab(self, index: int): """Close tab at index.""" widget = self.tab_widget.widget(index) @@ -229,6 +286,18 @@ class PaneWidget(QFrame): widget.deleteLater() + # Clean up title tracking - shift indices after removed tab + if index in self._terminal_base_titles: + del self._terminal_base_titles[index] + # Shift remaining indices + new_titles = {} + for idx, title in self._terminal_base_titles.items(): + if idx > index: + new_titles[idx - 1] = title + else: + new_titles[idx] = title + self._terminal_base_titles = new_titles + # If pane is now empty, add welcome tab instead of emitting empty if self.tab_widget.count() == 0: self.add_welcome_tab() @@ -243,9 +312,12 @@ class PaneWidget(QFrame): try: for i in range(self.tab_widget.count()): if self.tab_widget.widget(i) == terminal: - current_title = self.tab_widget.tabText(i) - if not current_title.endswith(" (exited)"): - self.tab_widget.setTabText(i, f"{current_title} (exited)") + # Use base title if available, strip any badge + base_title = self._terminal_base_titles.get(i, "Terminal") + if " ⏱" in base_title: + base_title = base_title.split(" ⏱")[0] + if not base_title.endswith(" (exited)"): + self.tab_widget.setTabText(i, f"{base_title} (exited)") break except RuntimeError: # Widget was deleted diff --git a/src/development_hub/terminal_display.py b/src/development_hub/terminal_display.py index 59b6e04..a3c632a 100644 --- a/src/development_hub/terminal_display.py +++ b/src/development_hub/terminal_display.py @@ -694,6 +694,12 @@ class TerminalDisplay(QWidget): self.scroll_down(1) return + # Handle Ctrl+Shift combinations + if modifiers == (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier): + if key == Qt.Key.Key_V: + self._paste_clipboard() + return + # Handle special key combinations if modifiers == Qt.KeyboardModifier.ControlModifier: if key == Qt.Key.Key_C: diff --git a/src/development_hub/terminal_widget.py b/src/development_hub/terminal_widget.py index 876d3b6..91833ea 100644 --- a/src/development_hub/terminal_widget.py +++ b/src/development_hub/terminal_widget.py @@ -3,15 +3,18 @@ import fcntl import logging import os +import re import struct import subprocess import termios +import time from pathlib import Path import pty from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QLabel, QLineEdit, @@ -22,6 +25,121 @@ from PySide6.QtWidgets import ( logger = logging.getLogger(__name__) +# Default countdown seconds for auto-accept toast +AUTO_ACCEPT_COUNTDOWN = 5 + + +class AutoAcceptToast(QFrame): + """Toast widget showing auto-accept countdown with Skip/Accept buttons.""" + + accepted = Signal() # User clicked Accept or countdown expired + skipped = Signal() # User clicked Skip + + def __init__(self, response_preview: str, parent=None): + super().__init__(parent) + self._countdown = AUTO_ACCEPT_COUNTDOWN + self._response_preview = response_preview + self._timer = QTimer() + self._timer.timeout.connect(self._on_tick) + self._setup_ui() + + def _setup_ui(self): + """Set up the toast UI.""" + self.setStyleSheet(""" + AutoAcceptToast { + background: #2d4a2d; + border: 1px solid #4a9e4a; + border-radius: 4px; + } + QLabel { + color: #e0e0e0; + font-size: 12px; + } + QPushButton { + padding: 2px 8px; + border: 1px solid #3d3d3d; + border-radius: 3px; + color: #e0e0e0; + font-size: 11px; + } + QPushButton#skip { + background: #4a3d3d; + border-color: #6a4d4d; + } + QPushButton#skip:hover { + background: #5a4d4d; + } + QPushButton#accept { + background: #3d5a3d; + border-color: #4d7a4d; + } + QPushButton#accept:hover { + background: #4d6a4d; + } + """) + self.setMaximumHeight(32) + + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + + # Icon and message + self._label = QLabel() + self._update_label() + layout.addWidget(self._label, 1) + + # Skip button - don't steal focus from terminal + skip_btn = QPushButton("Skip") + skip_btn.setObjectName("skip") + skip_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + skip_btn.clicked.connect(self._on_skip) + layout.addWidget(skip_btn) + + # Accept button - don't steal focus from terminal + accept_btn = QPushButton("Accept") + accept_btn.setObjectName("accept") + accept_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + accept_btn.clicked.connect(self._on_accept) + layout.addWidget(accept_btn) + + def _update_label(self): + """Update the countdown label.""" + self._label.setText( + f"⏱ Auto-accept in {self._countdown}s → sending '{self._response_preview}'" + ) + + def start(self): + """Start the countdown.""" + self._timer.start(1000) + self.show() + + def stop(self): + """Stop the countdown without triggering accept/skip.""" + self._timer.stop() + self.hide() + + def _on_tick(self): + """Handle countdown tick.""" + self._countdown -= 1 + if self._countdown <= 0: + self._timer.stop() + self.accepted.emit() + self.hide() + else: + self._update_label() + + def _on_skip(self): + """Handle skip button click.""" + self._timer.stop() + self.skipped.emit() + self.hide() + + def _on_accept(self): + """Handle accept button click.""" + self._timer.stop() + self.accepted.emit() + self.hide() + # Import from split modules from development_hub.pty_manager import PtyReaderThread from development_hub.terminal_display import ( @@ -45,6 +163,22 @@ class TerminalWidget(QWidget): closed = Signal() shell_error = Signal(str) # Emitted when shell fails to start or crashes + auto_accept_changed = Signal(int) # Emits remaining seconds (0 = disabled) + + # Patterns for auto-accept detection + # Each tuple: (compiled_regex, response_to_send) + # Patterns should be specific to avoid false positives on code display + AUTO_ACCEPT_PATTERNS = [ + # Standard [Y/n] prompts - require question context + (re.compile(r'\?\s*\[Y/n\]\s*$', re.MULTILINE), b'y\n'), + (re.compile(r'\?\s*\[y/N\]\s*$', re.MULTILINE), b'y\n'), + (re.compile(r'\?\s*\(y/n\)\s*$', re.MULTILINE), b'y\n'), + # Claude Code style: "Do you want" followed by menu with Yes as option 1 + # The ❯ indicates the selected option - just need Enter to confirm + (re.compile(r'Do you want.*\?\s*\n\s*[❯>]\s*1\.\s*Yes', re.IGNORECASE | re.DOTALL), b'\r'), + # Simpler Claude prompt: just the menu indicator on Yes line + (re.compile(r'^\s*[❯>]\s*1\.\s*Yes\s*$', re.MULTILINE), b'\r'), + ] def __init__(self, cwd: Path | None = None, parent=None): super().__init__(parent) @@ -56,6 +190,19 @@ class TerminalWidget(QWidget): self._shell_running = False self._intentional_close = False + # Auto-accept state + self._auto_accept_enabled = False + self._auto_accept_end_time = 0 + self._auto_accept_timer = QTimer() + self._auto_accept_timer.timeout.connect(self._update_auto_accept) + self._pending_response: bytes | None = None + self._auto_accept_toast: AutoAcceptToast | None = None + self._last_parsed_marker = "" # Last 2-3 lines we parsed, to find our place + # Debounce timer - wait for output to settle before checking patterns + self._pattern_check_timer = QTimer() + self._pattern_check_timer.setSingleShot(True) + self._pattern_check_timer.timeout.connect(self._do_pattern_check) + self._setup_ui() self._start_shell() @@ -80,6 +227,9 @@ class TerminalWidget(QWidget): ) layout.addWidget(self.display) + # Auto-accept toast (hidden by default, created on demand) + # Will be added to layout when shown + # Set up search shortcut (Ctrl+Shift+F) self._search_shortcut = QShortcut(QKeySequence("Ctrl+Shift+F"), self) self._search_shortcut.activated.connect(self.toggle_search) @@ -456,6 +606,10 @@ class TerminalWidget(QWidget): """Handle output from PTY.""" self.display.feed(data) + # Schedule auto-accept pattern check if enabled + if self._auto_accept_enabled: + self._schedule_auto_accept_check() + def _on_shell_exit(self): """Handle shell process exit.""" self._shell_running = False @@ -572,6 +726,9 @@ class TerminalWidget(QWidget): self._intentional_close = True self._shell_running = False + # Stop auto-accept + self.stop_auto_accept() + if self.reader_thread: self.reader_thread.stop() self.reader_thread.wait(1000) @@ -602,6 +759,201 @@ class TerminalWidget(QWidget): """Set focus to the display widget.""" self.display.setFocus() + # === Auto-accept functionality === + + def start_auto_accept(self, duration_seconds: int): + """Start auto-accepting Y/yes prompts for the specified duration. + + Args: + duration_seconds: How long to auto-accept prompts + """ + self._auto_accept_enabled = True + self._auto_accept_end_time = time.time() + duration_seconds + + # Update every second for countdown + self._auto_accept_timer.start(1000) + + # Emit initial remaining time + self.auto_accept_changed.emit(duration_seconds) + + def stop_auto_accept(self): + """Stop auto-accepting prompts.""" + self._auto_accept_enabled = False + self._auto_accept_end_time = 0 + self._auto_accept_timer.stop() + self._pattern_check_timer.stop() + self._pending_response = None + + # Hide and clean up toast if showing + if self._auto_accept_toast is not None: + self._auto_accept_toast.stop() + self._auto_accept_toast.deleteLater() + self._auto_accept_toast = None + + self.auto_accept_changed.emit(0) + + def _update_auto_accept(self): + """Update auto-accept timer and emit remaining time.""" + if not self._auto_accept_enabled: + self._auto_accept_timer.stop() + return + + remaining = int(self._auto_accept_end_time - time.time()) + if remaining <= 0: + self.stop_auto_accept() + else: + self.auto_accept_changed.emit(remaining) + + def _schedule_auto_accept_check(self): + """Schedule pattern check after output settles. + + Debounces to wait for TUI rendering to complete before + checking the screen buffer for prompt patterns. + """ + if not self._auto_accept_enabled: + return + + # Don't check if toast is already showing + if self._auto_accept_toast is not None and self._auto_accept_toast.isVisible(): + return + + # Debounce: wait 200ms after last output before checking screen + self._pattern_check_timer.start(200) + + def _do_pattern_check(self): + """Actually check patterns after output has settled.""" + if not self._auto_accept_enabled: + return + + if self._auto_accept_toast is not None and self._auto_accept_toast.isVisible(): + return + + # Read directly from pyte screen buffer - this captures TUI-rendered content + screen_content = self._get_screen_text() + + # Find where we last parsed and only check content after that + content_to_check = screen_content + found_marker = False + if self._last_parsed_marker and self._last_parsed_marker in screen_content: + # Find the marker and only check content after it + marker_pos = screen_content.rfind(self._last_parsed_marker) + content_to_check = screen_content[marker_pos + len(self._last_parsed_marker):] + found_marker = True + + # If nothing to check, we're done + if not content_to_check.strip(): + return + + # Check patterns against the new content + for pattern, response in self.AUTO_ACCEPT_PATTERNS: + match = pattern.search(content_to_check) + if match: + # If we found our marker and the match is right at the start, + # this is the same prompt we already processed - skip it and + # search again from after this match + if found_marker and match.start() < 20: + # Search for another occurrence after this one + match = pattern.search(content_to_check, match.end()) + if not match: + continue + + # Save marker: include the match itself so we skip past it next time + content_through_match = content_to_check[:match.end()] + lines = content_through_match.rstrip().split('\n') + if len(lines) >= 2: + self._last_parsed_marker = '\n'.join(lines[-2:]) + else: + self._last_parsed_marker = content_through_match.strip() + + # Show toast with countdown + self._pending_response = response + self._show_auto_accept_toast(response) + return + + # No prompt found - advance marker to end of content so we don't re-scan + lines = content_to_check.rstrip().split('\n') + if len(lines) >= 3: + self._last_parsed_marker = '\n'.join(lines[-3:]) + elif content_to_check.strip(): + self._last_parsed_marker = content_to_check.strip() + + def _get_screen_text(self) -> str: + """Get the current visible screen content as text.""" + lines = [] + screen = self.display.screen + for row in range(screen.lines): + line = "" + for col in range(screen.columns): + char = screen.buffer[row][col] + line += char.data if char.data else " " + lines.append(line.rstrip()) + return "\n".join(lines) + + def _show_auto_accept_toast(self, response: bytes): + """Show the auto-accept toast with countdown.""" + # Get human-readable response preview + response_preview = response.decode('utf-8', errors='replace').strip() + if response_preview == 'y': + response_preview = 'y (yes)' + elif response_preview == '1': + response_preview = '1 (Yes)' + + # Create toast if needed + if self._auto_accept_toast is not None: + self._auto_accept_toast.stop() + self._auto_accept_toast.deleteLater() + + self._auto_accept_toast = AutoAcceptToast(response_preview, self) + self._auto_accept_toast.accepted.connect(self._on_toast_accepted) + self._auto_accept_toast.skipped.connect(self._on_toast_skipped) + + # Add to layout at bottom + self.layout().addWidget(self._auto_accept_toast) + self._auto_accept_toast.start() + + # Keep focus on terminal display + self.display.setFocus() + + def _on_toast_accepted(self): + """Handle toast accept (countdown expired or Accept clicked).""" + # Ensure terminal has focus before sending + self.display.setFocus() + + if self._pending_response: + # Delay sending to let focus settle - Qt focus is async + response = self._pending_response + self._pending_response = None + QTimer.singleShot(50, lambda: self._send_auto_accept_response(response)) + else: + self._pending_response = None + + def _send_auto_accept_response(self, response: bytes): + """Send the auto-accept response after focus has settled.""" + if self.master_fd is not None: + try: + os.write(self.master_fd, response) + except OSError: + pass + + def _on_toast_skipped(self): + """Handle toast skip (user clicked Skip).""" + # Just clear the pending response, auto-accept stays enabled for next prompt + self._pending_response = None + # Restore focus to terminal + self.display.setFocus() + + @property + def auto_accept_remaining(self) -> int: + """Get remaining auto-accept time in seconds.""" + if not self._auto_accept_enabled: + return 0 + return max(0, int(self._auto_accept_end_time - time.time())) + + @property + def is_auto_accept_enabled(self) -> bool: + """Check if auto-accept is currently enabled.""" + return self._auto_accept_enabled + @property def is_running(self) -> bool: """Check if the shell is currently running.""" diff --git a/src/development_hub/widgets/action_menu.py b/src/development_hub/widgets/action_menu.py index 8c78e68..6791048 100644 --- a/src/development_hub/widgets/action_menu.py +++ b/src/development_hub/widgets/action_menu.py @@ -20,6 +20,8 @@ class ActionMenu(QToolButton): dashboard_requested = Signal() global_dashboard_requested = Signal() terminal_requested = Signal() + auto_accept_requested = Signal() + auto_accept_stop_requested = Signal() launch_discussion_requested = Signal() standup_requested = Signal() update_docs_requested = Signal() @@ -90,6 +92,11 @@ class ActionMenu(QToolButton): terminal_action.triggered.connect(self.terminal_requested.emit) menu.addAction(terminal_action) + self.auto_accept_action = QAction("⏱ Auto-accept prompts...", self) + self.auto_accept_action.triggered.connect(self._on_auto_accept_triggered) + menu.addAction(self.auto_accept_action) + self._auto_accept_active = False + menu.addSeparator() launch_discussion_action = QAction("💬 Launch Discussion", self) @@ -130,3 +137,22 @@ class ActionMenu(QToolButton): self.setToolTip(f"Actions for {project_name}") else: self.setToolTip("Global actions") + + def _on_auto_accept_triggered(self): + """Handle auto-accept action click.""" + if self._auto_accept_active: + self.auto_accept_stop_requested.emit() + else: + self.auto_accept_requested.emit() + + def set_auto_accept_active(self, active: bool): + """Update the auto-accept menu item based on state. + + Args: + active: Whether auto-accept is currently active + """ + self._auto_accept_active = active + if active: + self.auto_accept_action.setText("⏹ Stop auto-accept") + else: + self.auto_accept_action.setText("⏱ Auto-accept prompts...")