Add auto-accept prompts and Ctrl+Shift+V paste for terminal
- Add auto-accept feature to automatically handle Y/yes prompts from CLI tools like Claude Code, triggered via action menu with configurable duration - Shows countdown toast (5s) with Skip/Accept buttons before confirming - Tab badge displays remaining time (e.g., ⏱4:32) - Detects prompts by reading pyte screen buffer (works with TUI apps) - Uses marker-based parsing to avoid re-triggering on same prompt - Add Ctrl+Shift+V paste support in terminal display - Update CLAUDE.md with feature documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
10d7baff81
commit
43f7deb5a6
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -57,8 +57,10 @@ src/development_hub/
|
||||||
| `WorkspaceManager` | workspace_manager.py | Manages splittable pane layout |
|
| `WorkspaceManager` | workspace_manager.py | Manages splittable pane layout |
|
||||||
| `PaneWidget` | pane_widget.py | Tab container with action menu |
|
| `PaneWidget` | pane_widget.py | Tab container with action menu |
|
||||||
| `DraggableTabWidget` | draggable_tab_widget.py | Tab widget supporting cross-pane drag |
|
| `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 |
|
| `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 |
|
| `DashboardDataStore` | views/dashboard/data_store.py | Data persistence with file watching |
|
||||||
| `UndoManager` | views/dashboard/undo_manager.py | Undo/redo action management |
|
| `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
|
- **External File Watching**: Dashboard auto-reloads when files modified externally
|
||||||
- **New Project Dialog**: Integrates with Ramble for voice input
|
- **New Project Dialog**: Integrates with Ramble for voice input
|
||||||
- **Progress Reports**: Export weekly progress summaries from daily standups
|
- **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
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
|
@ -104,12 +107,29 @@ src/development_hub/
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `Ctrl+Shift+F` | Open search bar |
|
| `Ctrl+Shift+F` | Open search bar |
|
||||||
|
| `Ctrl+Shift+V` | Paste from clipboard |
|
||||||
| `Shift+PageUp/Down` | Scroll through history |
|
| `Shift+PageUp/Down` | Scroll through history |
|
||||||
| `Enter` (in search) | Next match |
|
| `Enter` (in search) | Next match |
|
||||||
| `Shift+Enter` (in search) | Previous match |
|
| `Shift+Enter` (in search) | Previous match |
|
||||||
| `F3` / `Shift+F3` | Next/previous match |
|
| `F3` / `Shift+F3` | Next/previous match |
|
||||||
| `Escape` | Close search bar |
|
| `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)
|
#### Dashboard Shortcuts (when dashboard tab is active)
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|
|
|
||||||
|
|
@ -1849,3 +1849,101 @@ class WeeklyReportDialog(QDialog):
|
||||||
"Copied",
|
"Copied",
|
||||||
"Report copied to clipboard!"
|
"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
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class PaneWidget(QFrame):
|
||||||
self._pane_id = str(uuid.uuid4())
|
self._pane_id = str(uuid.uuid4())
|
||||||
self._is_focused = False
|
self._is_focused = False
|
||||||
self._current_project: Project | None = None
|
self._current_project: Project | None = None
|
||||||
|
self._terminal_base_titles: dict[int, str] = {} # tab_index -> original title
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -79,6 +80,8 @@ class PaneWidget(QFrame):
|
||||||
self.action_menu.dashboard_requested.connect(self._on_dashboard_clicked)
|
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.global_dashboard_requested.connect(self._on_global_dashboard_clicked)
|
||||||
self.action_menu.terminal_requested.connect(self.terminal_requested.emit)
|
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.launch_discussion_requested.connect(self.launch_discussion_requested.emit)
|
||||||
self.action_menu.standup_requested.connect(self.standup_requested.emit)
|
self.action_menu.standup_requested.connect(self.standup_requested.emit)
|
||||||
self.action_menu.update_docs_requested.connect(self.update_docs_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)
|
pane._on_terminal_closed(terminal)
|
||||||
terminal.closed.connect(on_closed)
|
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)
|
idx = self.tab_widget.addTab(terminal, title)
|
||||||
|
self._terminal_base_titles[idx] = title
|
||||||
self.tab_widget.setCurrentIndex(idx)
|
self.tab_widget.setCurrentIndex(idx)
|
||||||
terminal.setFocus()
|
terminal.setFocus()
|
||||||
return terminal
|
return terminal
|
||||||
|
|
@ -219,6 +230,52 @@ class PaneWidget(QFrame):
|
||||||
self.add_dashboard(project)
|
self.add_dashboard(project)
|
||||||
return
|
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):
|
def _close_tab(self, index: int):
|
||||||
"""Close tab at index."""
|
"""Close tab at index."""
|
||||||
widget = self.tab_widget.widget(index)
|
widget = self.tab_widget.widget(index)
|
||||||
|
|
@ -229,6 +286,18 @@ class PaneWidget(QFrame):
|
||||||
|
|
||||||
widget.deleteLater()
|
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 pane is now empty, add welcome tab instead of emitting empty
|
||||||
if self.tab_widget.count() == 0:
|
if self.tab_widget.count() == 0:
|
||||||
self.add_welcome_tab()
|
self.add_welcome_tab()
|
||||||
|
|
@ -243,9 +312,12 @@ class PaneWidget(QFrame):
|
||||||
try:
|
try:
|
||||||
for i in range(self.tab_widget.count()):
|
for i in range(self.tab_widget.count()):
|
||||||
if self.tab_widget.widget(i) == terminal:
|
if self.tab_widget.widget(i) == terminal:
|
||||||
current_title = self.tab_widget.tabText(i)
|
# Use base title if available, strip any badge
|
||||||
if not current_title.endswith(" (exited)"):
|
base_title = self._terminal_base_titles.get(i, "Terminal")
|
||||||
self.tab_widget.setTabText(i, f"{current_title} (exited)")
|
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
|
break
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Widget was deleted
|
# Widget was deleted
|
||||||
|
|
|
||||||
|
|
@ -694,6 +694,12 @@ class TerminalDisplay(QWidget):
|
||||||
self.scroll_down(1)
|
self.scroll_down(1)
|
||||||
return
|
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
|
# Handle special key combinations
|
||||||
if modifiers == Qt.KeyboardModifier.ControlModifier:
|
if modifiers == Qt.KeyboardModifier.ControlModifier:
|
||||||
if key == Qt.Key.Key_C:
|
if key == Qt.Key.Key_C:
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,18 @@
|
||||||
import fcntl
|
import fcntl
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import termios
|
import termios
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pty
|
import pty
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import QKeySequence, QShortcut
|
from PySide6.QtGui import QKeySequence, QShortcut
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
|
@ -22,6 +25,121 @@ from PySide6.QtWidgets import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Import from split modules
|
||||||
from development_hub.pty_manager import PtyReaderThread
|
from development_hub.pty_manager import PtyReaderThread
|
||||||
from development_hub.terminal_display import (
|
from development_hub.terminal_display import (
|
||||||
|
|
@ -45,6 +163,22 @@ class TerminalWidget(QWidget):
|
||||||
|
|
||||||
closed = Signal()
|
closed = Signal()
|
||||||
shell_error = Signal(str) # Emitted when shell fails to start or crashes
|
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):
|
def __init__(self, cwd: Path | None = None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -56,6 +190,19 @@ class TerminalWidget(QWidget):
|
||||||
self._shell_running = False
|
self._shell_running = False
|
||||||
self._intentional_close = 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._setup_ui()
|
||||||
self._start_shell()
|
self._start_shell()
|
||||||
|
|
||||||
|
|
@ -80,6 +227,9 @@ class TerminalWidget(QWidget):
|
||||||
)
|
)
|
||||||
layout.addWidget(self.display)
|
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)
|
# Set up search shortcut (Ctrl+Shift+F)
|
||||||
self._search_shortcut = QShortcut(QKeySequence("Ctrl+Shift+F"), self)
|
self._search_shortcut = QShortcut(QKeySequence("Ctrl+Shift+F"), self)
|
||||||
self._search_shortcut.activated.connect(self.toggle_search)
|
self._search_shortcut.activated.connect(self.toggle_search)
|
||||||
|
|
@ -456,6 +606,10 @@ class TerminalWidget(QWidget):
|
||||||
"""Handle output from PTY."""
|
"""Handle output from PTY."""
|
||||||
self.display.feed(data)
|
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):
|
def _on_shell_exit(self):
|
||||||
"""Handle shell process exit."""
|
"""Handle shell process exit."""
|
||||||
self._shell_running = False
|
self._shell_running = False
|
||||||
|
|
@ -572,6 +726,9 @@ class TerminalWidget(QWidget):
|
||||||
self._intentional_close = True
|
self._intentional_close = True
|
||||||
self._shell_running = False
|
self._shell_running = False
|
||||||
|
|
||||||
|
# Stop auto-accept
|
||||||
|
self.stop_auto_accept()
|
||||||
|
|
||||||
if self.reader_thread:
|
if self.reader_thread:
|
||||||
self.reader_thread.stop()
|
self.reader_thread.stop()
|
||||||
self.reader_thread.wait(1000)
|
self.reader_thread.wait(1000)
|
||||||
|
|
@ -602,6 +759,201 @@ class TerminalWidget(QWidget):
|
||||||
"""Set focus to the display widget."""
|
"""Set focus to the display widget."""
|
||||||
self.display.setFocus()
|
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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the shell is currently running."""
|
"""Check if the shell is currently running."""
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ class ActionMenu(QToolButton):
|
||||||
dashboard_requested = Signal()
|
dashboard_requested = Signal()
|
||||||
global_dashboard_requested = Signal()
|
global_dashboard_requested = Signal()
|
||||||
terminal_requested = Signal()
|
terminal_requested = Signal()
|
||||||
|
auto_accept_requested = Signal()
|
||||||
|
auto_accept_stop_requested = Signal()
|
||||||
launch_discussion_requested = Signal()
|
launch_discussion_requested = Signal()
|
||||||
standup_requested = Signal()
|
standup_requested = Signal()
|
||||||
update_docs_requested = Signal()
|
update_docs_requested = Signal()
|
||||||
|
|
@ -90,6 +92,11 @@ class ActionMenu(QToolButton):
|
||||||
terminal_action.triggered.connect(self.terminal_requested.emit)
|
terminal_action.triggered.connect(self.terminal_requested.emit)
|
||||||
menu.addAction(terminal_action)
|
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()
|
menu.addSeparator()
|
||||||
|
|
||||||
launch_discussion_action = QAction("💬 Launch Discussion", self)
|
launch_discussion_action = QAction("💬 Launch Discussion", self)
|
||||||
|
|
@ -130,3 +137,22 @@ class ActionMenu(QToolButton):
|
||||||
self.setToolTip(f"Actions for {project_name}")
|
self.setToolTip(f"Actions for {project_name}")
|
||||||
else:
|
else:
|
||||||
self.setToolTip("Global actions")
|
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...")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue