diff --git a/gui.py b/gui.py index 1f23b82..414e01f 100644 --- a/gui.py +++ b/gui.py @@ -29,12 +29,14 @@ QFont, QFontDatabase, QImage, + QKeySequence, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap, QRadialGradient, + QShortcut, QTextCharFormat, QTextCursor, ) @@ -1094,6 +1096,7 @@ def setup_ui(self): # Export button self.export_button = QPushButton("Export") + self.export_button.setToolTip("Export conversation to file (Ctrl+E)") self.export_button.setStyleSheet(f""" QPushButton {{ background-color: {COLORS["bg_light"]}; @@ -1115,6 +1118,7 @@ def setup_ui(self): # View HTML button (removed) self.view_html_button = QPushButton("View HTML") + self.view_html_button.setToolTip("View shared HTML document (Ctrl+H)") self.view_html_button.setStyleSheet(f""" QPushButton {{ background-color: {COLORS["accent_green"]}; @@ -1140,6 +1144,7 @@ def setup_ui(self): # View Full HTML button self.view_full_html_button = QPushButton("View Dark HTML") + self.view_full_html_button.setToolTip("View full conversation in dark mode (Ctrl+Shift+H)") self.view_full_html_button.setStyleSheet(""" QPushButton { background-color: #212121; @@ -1364,9 +1369,20 @@ def setup_ui(self): padding: 2px; """) + # Help indicator for keyboard shortcuts + self.help_indicator = QLabel("Press F1 for shortcuts") + self.help_indicator.setStyleSheet(f""" + color: {COLORS["accent_green"]}; + font-size: 10px; + padding: 2px; + font-style: italic; + """) + self.help_indicator.setToolTip("Press F1 or Ctrl+/ to see all keyboard shortcuts") + title_layout.addWidget(self.title_label) title_layout.addStretch() title_layout.addWidget(self.info_label) + title_layout.addWidget(self.help_indicator) layout.addLayout(title_layout) @@ -1454,6 +1470,7 @@ def setup_ui(self): # Clear button self.clear_button = QPushButton("Clear") + self.clear_button.setToolTip("Clear input field (Ctrl+K)") self.clear_button.setStyleSheet(f""" QPushButton {{ background-color: {COLORS["bg_light"]}; @@ -1475,6 +1492,7 @@ def setup_ui(self): # Submit button with modern styling self.submit_button = QPushButton("Propagate") + self.submit_button.setToolTip("Send message and start AI conversation (Ctrl+Enter)") self.submit_button.setStyleSheet(f""" QPushButton {{ background-color: {COLORS["accent_blue"]}; @@ -1996,6 +2014,414 @@ def export_conversation(self): print(error_msg) +class SearchDialog(QWidget): + """Dialog for searching through conversations""" + + searchRequested = pyqtSignal(str, bool, bool) # query, case_sensitive, whole_word + resultSelected = pyqtSignal(str, int) # branch_id, message_index + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Search Conversations") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint) + self.search_results = [] + self.current_result_index = -1 + self.setup_ui() + + def setup_ui(self): + """Set up the search dialog UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + # Title + title = QLabel("Search Conversations") + title.setStyleSheet(f""" + color: {COLORS["text_bright"]}; + font-size: 16px; + font-weight: bold; + padding-bottom: 5px; + """) + layout.addWidget(title) + + # Search input + search_input_layout = QHBoxLayout() + self.search_input = QTextEdit() + self.search_input.setPlaceholderText("Enter search query...") + self.search_input.setMaximumHeight(60) + self.search_input.setStyleSheet(f""" + QTextEdit {{ + background-color: {COLORS["bg_light"]}; + color: {COLORS["text_normal"]}; + border: 2px solid {COLORS["border"]}; + border-radius: 4px; + padding: 8px; + font-size: 12px; + }} + QTextEdit:focus {{ + border: 2px solid {COLORS["accent_blue"]}; + }} + """) + search_input_layout.addWidget(self.search_input) + + # Search button + self.search_button = QPushButton("Search") + self.search_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS["accent_blue"]}; + color: {COLORS["text_bright"]}; + border: none; + border-radius: 4px; + padding: 10px 20px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS["accent_blue_hover"]}; + }} + """) + self.search_button.clicked.connect(self.perform_search) + search_input_layout.addWidget(self.search_button) + + layout.addLayout(search_input_layout) + + # Search options + options_layout = QHBoxLayout() + + self.case_sensitive_check = QCheckBox("Case sensitive") + self.case_sensitive_check.setStyleSheet(f""" + QCheckBox {{ + color: {COLORS["text_normal"]}; + spacing: 5px; + }} + QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid {COLORS["border"]}; + border-radius: 3px; + background-color: {COLORS["bg_light"]}; + }} + QCheckBox::indicator:checked {{ + background-color: {COLORS["accent_blue"]}; + border: 1px solid {COLORS["accent_blue"]}; + }} + """) + options_layout.addWidget(self.case_sensitive_check) + + self.whole_word_check = QCheckBox("Whole word") + self.whole_word_check.setStyleSheet(self.case_sensitive_check.styleSheet()) + options_layout.addWidget(self.whole_word_check) + + self.search_all_branches = QCheckBox("Search all branches") + self.search_all_branches.setStyleSheet(self.case_sensitive_check.styleSheet()) + self.search_all_branches.setChecked(True) + options_layout.addWidget(self.search_all_branches) + + options_layout.addStretch() + layout.addLayout(options_layout) + + # Results display + results_label = QLabel("Results:") + results_label.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 12px;") + layout.addWidget(results_label) + + self.results_list = QTextEdit() + self.results_list.setReadOnly(True) + self.results_list.setStyleSheet(f""" + QTextEdit {{ + background-color: {COLORS["bg_medium"]}; + color: {COLORS["text_normal"]}; + border: 1px solid {COLORS["border"]}; + border-radius: 4px; + padding: 10px; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 11px; + }} + """) + layout.addWidget(self.results_list) + + # Navigation buttons + nav_layout = QHBoxLayout() + + self.prev_button = QPushButton("← Previous") + self.prev_button.setEnabled(False) + self.prev_button.clicked.connect(self.go_to_previous_result) + self.prev_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS["bg_light"]}; + color: {COLORS["text_normal"]}; + border: 1px solid {COLORS["border"]}; + border-radius: 4px; + padding: 6px 12px; + }} + QPushButton:hover:enabled {{ + background-color: {COLORS["border"]}; + }} + QPushButton:disabled {{ + color: {COLORS["text_dim"]}; + }} + """) + nav_layout.addWidget(self.prev_button) + + self.result_counter = QLabel("0 / 0") + self.result_counter.setStyleSheet(f"color: {COLORS['text_dim']}; font-size: 11px;") + self.result_counter.setAlignment(Qt.AlignmentFlag.AlignCenter) + nav_layout.addWidget(self.result_counter) + + self.next_button = QPushButton("Next →") + self.next_button.setEnabled(False) + self.next_button.clicked.connect(self.go_to_next_result) + self.next_button.setStyleSheet(self.prev_button.styleSheet()) + nav_layout.addWidget(self.next_button) + + layout.addLayout(nav_layout) + + # Close button + close_button = QPushButton("Close") + close_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS["bg_light"]}; + color: {COLORS["text_normal"]}; + border: 1px solid {COLORS["border"]}; + border-radius: 4px; + padding: 8px 20px; + }} + QPushButton:hover {{ + background-color: {COLORS["border"]}; + }} + """) + close_button.clicked.connect(self.close) + layout.addWidget(close_button, alignment=Qt.AlignmentFlag.AlignCenter) + + # Set dialog size + self.setFixedSize(650, 550) + + # Apply dark theme + self.setStyleSheet(f""" + QWidget {{ + background-color: {COLORS["bg_dark"]}; + color: {COLORS["text_normal"]}; + }} + """) + + # Connect enter key to search + self.search_input.installEventFilter(self) + + def eventFilter(self, obj, event): + """Handle Enter key in search input""" + if obj is self.search_input and event.type() == QEvent.Type.KeyPress: + if event.key() == Qt.Key.Key_Return and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + self.perform_search() + return True + return super().eventFilter(obj, event) + + def perform_search(self): + """Perform the search with current query and options""" + query = self.search_input.toPlainText().strip() + if not query: + return + + case_sensitive = self.case_sensitive_check.isChecked() + whole_word = self.whole_word_check.isChecked() + + # Emit search request signal + self.searchRequested.emit(query, case_sensitive, whole_word) + + def display_results(self, results): + """Display search results in the results list""" + self.search_results = results + self.current_result_index = 0 if results else -1 + + if not results: + self.results_list.setHtml(f""" +
+ No results found +
+ """) + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + self.result_counter.setText("0 / 0") + return + + # Format results as HTML + html = "" + + for i, result in enumerate(results): + branch_name = result.get('branch_name', 'Main') + context = result.get('context', '') + message_index = result.get('message_index', 0) + + html += f'
' + html += f'
📍 {branch_name} (Message {message_index + 1})
' + html += f'
{context}
' + html += '
' + + self.results_list.setHtml(html) + + # Update navigation + self.update_navigation() + + def update_navigation(self): + """Update navigation buttons and counter""" + if not self.search_results: + self.prev_button.setEnabled(False) + self.next_button.setEnabled(False) + self.result_counter.setText("0 / 0") + return + + total = len(self.search_results) + current = self.current_result_index + 1 + + self.result_counter.setText(f"{current} / {total}") + self.prev_button.setEnabled(self.current_result_index > 0) + self.next_button.setEnabled(self.current_result_index < total - 1) + + def go_to_previous_result(self): + """Navigate to previous search result""" + if self.current_result_index > 0: + self.current_result_index -= 1 + self.jump_to_current_result() + self.update_navigation() + + def go_to_next_result(self): + """Navigate to next search result""" + if self.current_result_index < len(self.search_results) - 1: + self.current_result_index += 1 + self.jump_to_current_result() + self.update_navigation() + + def jump_to_current_result(self): + """Jump to the currently selected result""" + if 0 <= self.current_result_index < len(self.search_results): + result = self.search_results[self.current_result_index] + branch_id = result.get('branch_id', 'main') + message_index = result.get('message_index', 0) + self.resultSelected.emit(branch_id, message_index) + + +class KeyboardShortcutsDialog(QWidget): + """Dialog showing available keyboard shortcuts""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Keyboard Shortcuts") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint) + self.setup_ui() + + def setup_ui(self): + """Set up the shortcuts help UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + # Title + title = QLabel("Keyboard Shortcuts") + title.setStyleSheet(f""" + color: {COLORS["text_bright"]}; + font-size: 18px; + font-weight: bold; + padding-bottom: 10px; + """) + layout.addWidget(title) + + # Shortcuts list + shortcuts_text = QTextEdit() + shortcuts_text.setReadOnly(True) + shortcuts_text.setStyleSheet(f""" + QTextEdit {{ + background-color: {COLORS["bg_medium"]}; + color: {COLORS["text_normal"]}; + border: 1px solid {COLORS["border"]}; + border-radius: 4px; + padding: 10px; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 11px; + }} + """) + + # Platform-specific modifier key + modifier = "Ctrl" if os.name != "posix" or "darwin" not in os.sys.platform else "Cmd" + + shortcuts_html = f""" + + +
+
🚀 Conversation Actions
+
{modifier}+Enter - Send message / Propagate
+
{modifier}+K - Clear input field
+
{modifier}+M - Return to main conversation
+
{modifier}+N - New/Clear conversation
+
+ +
+
🔍 Search
+
{modifier}+F - Search conversations
+
+ +
+
🌳 Branching
+
{modifier}+R - Rabbithole from selection
+
{modifier}+Shift+F - Fork from selection
+
+ +
+
💾 Export & View
+
{modifier}+E - Export conversation
+
{modifier}+H - View HTML (shared)
+
{modifier}+Shift+H - View Dark HTML
+
+ +
+
⌨️ UI Navigation
+
Escape - Close dialogs / Cancel
+
F1 or {modifier}+/ - Show this help
+
+ """ + + shortcuts_text.setHtml(shortcuts_html) + layout.addWidget(shortcuts_text) + + # Close button + close_button = QPushButton("Close") + close_button.setStyleSheet(f""" + QPushButton {{ + background-color: {COLORS["accent_blue"]}; + color: {COLORS["text_bright"]}; + border: none; + border-radius: 4px; + padding: 8px 20px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {COLORS["accent_blue_hover"]}; + }} + """) + close_button.clicked.connect(self.close) + layout.addWidget(close_button, alignment=Qt.AlignmentFlag.AlignCenter) + + # Set dialog size + self.setFixedSize(500, 450) + + # Apply dark theme + self.setStyleSheet(f""" + QWidget {{ + background-color: {COLORS["bg_dark"]}; + color: {COLORS["text_normal"]}; + }} + """) + + class LiminalBackroomsApp(QMainWindow): """Main application window""" @@ -2016,6 +2442,9 @@ def __init__(self): # Connect signals and slots self.connect_signals() + # Set up keyboard shortcuts + self.setup_keyboard_shortcuts() + # Dark theme self.apply_dark_theme() @@ -2108,6 +2537,227 @@ def connect_signals(self): # Save splitter state when it moves self.splitter.splitterMoved.connect(self.save_splitter_state) + def setup_keyboard_shortcuts(self): + """Set up keyboard shortcuts for the application""" + # Propagate / Send message - Ctrl+Enter + self.shortcut_propagate = QShortcut(QKeySequence("Ctrl+Return"), self) + self.shortcut_propagate.activated.connect(self.left_pane.handle_propagate_click) + + # Clear input - Ctrl+K + self.shortcut_clear = QShortcut(QKeySequence("Ctrl+K"), self) + self.shortcut_clear.activated.connect(self.left_pane.clear_input) + + # Return to main conversation - Ctrl+M + self.shortcut_main = QShortcut(QKeySequence("Ctrl+M"), self) + self.shortcut_main.activated.connect(lambda: self.on_branch_select("main")) + + # New/Clear conversation - Ctrl+N + self.shortcut_new = QShortcut(QKeySequence("Ctrl+N"), self) + self.shortcut_new.activated.connect(self.clear_all_conversations) + + # Rabbithole from selection - Ctrl+R + self.shortcut_rabbithole = QShortcut(QKeySequence("Ctrl+R"), self) + self.shortcut_rabbithole.activated.connect(self.rabbithole_from_selection) + + # Fork from selection - Ctrl+Shift+F + self.shortcut_fork = QShortcut(QKeySequence("Ctrl+Shift+F"), self) + self.shortcut_fork.activated.connect(self.fork_from_selection_shortcut) + + # Search conversations - Ctrl+F + self.shortcut_search = QShortcut(QKeySequence("Ctrl+F"), self) + self.shortcut_search.activated.connect(self.show_search_dialog) + + # Export conversation - Ctrl+E + self.shortcut_export = QShortcut(QKeySequence("Ctrl+E"), self) + self.shortcut_export.activated.connect(self.export_conversation) + + # View HTML (shared) - Ctrl+H + self.shortcut_view_html = QShortcut(QKeySequence("Ctrl+H"), self) + self.shortcut_view_html.activated.connect( + lambda: open_html_in_browser("shared_document.html") + ) + + # View Dark HTML - Ctrl+Shift+H + self.shortcut_view_dark_html = QShortcut(QKeySequence("Ctrl+Shift+H"), self) + self.shortcut_view_dark_html.activated.connect( + lambda: open_html_in_browser("conversation_full.html") + ) + + # Show keyboard shortcuts help - F1 and Ctrl+/ + self.shortcut_help_f1 = QShortcut(QKeySequence("F1"), self) + self.shortcut_help_f1.activated.connect(self.show_keyboard_shortcuts) + + self.shortcut_help_slash = QShortcut(QKeySequence("Ctrl+/"), self) + self.shortcut_help_slash.activated.connect(self.show_keyboard_shortcuts) + + def show_keyboard_shortcuts(self): + """Show the keyboard shortcuts help dialog""" + if not hasattr(self, 'shortcuts_dialog'): + self.shortcuts_dialog = KeyboardShortcutsDialog(self) + self.shortcuts_dialog.show() + self.shortcuts_dialog.raise_() + self.shortcuts_dialog.activateWindow() + + def rabbithole_from_selection(self): + """Create rabbithole from current selection""" + cursor = self.left_pane.conversation_display.textCursor() + selected_text = cursor.selectedText() + if selected_text: + self.branch_from_selection(selected_text) + else: + self.statusBar().showMessage("Please select text first to create a rabbithole", 3000) + + def fork_from_selection_shortcut(self): + """Create fork from current selection""" + cursor = self.left_pane.conversation_display.textCursor() + selected_text = cursor.selectedText() + if selected_text: + self.fork_from_selection(selected_text) + else: + self.statusBar().showMessage("Please select text first to create a fork", 3000) + + def clear_all_conversations(self): + """Clear all conversations and reset to initial state""" + reply = QMessageBox.question( + self, + "Clear All Conversations", + "Are you sure you want to clear all conversations and branches? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + # Clear main conversation + if hasattr(self, 'main_conversation'): + self.main_conversation = [] + self.conversation = [] + + # Clear all branches + self.branch_conversations = {} + self.active_branch = None + + # Clear images + self.images = [] + self.image_paths = [] + + # Reset turn count + self.turn_count = 0 + + # Reset network graph + self.right_pane.graph.clear() + self.right_pane.node_positions = {} + self.right_pane.node_colors = {} + self.right_pane.node_labels = {} + self.right_pane.node_sizes = {} + + # Re-add main node + self.right_pane.add_node("main", "Seed", "main") + + # Clear conversation display + self.left_pane.clear_conversation() + + # Update status + self.statusBar().showMessage("All conversations cleared", 3000) + + def show_search_dialog(self): + """Show the search dialog""" + if not hasattr(self, 'search_dialog'): + self.search_dialog = SearchDialog(self) + # Connect signals + self.search_dialog.searchRequested.connect(self.perform_search) + self.search_dialog.resultSelected.connect(self.jump_to_search_result) + + self.search_dialog.show() + self.search_dialog.raise_() + self.search_dialog.activateWindow() + self.search_dialog.search_input.setFocus() + + def perform_search(self, query, case_sensitive, whole_word): + """Perform search across all conversations and branches""" + import re + + results = [] + + # Helper function to search in a conversation + def search_conversation(conversation, branch_id, branch_name): + for i, message in enumerate(conversation): + content = message.get('content', '') + if not content: + continue + + # Skip branch indicators and hidden messages + if message.get('_type') == 'branch_indicator' or message.get('hidden'): + continue + + # Prepare search pattern + pattern = re.escape(query) if whole_word else query + if whole_word: + pattern = r'\b' + pattern + r'\b' + + flags = 0 if case_sensitive else re.IGNORECASE + + # Search for matches + matches = list(re.finditer(pattern, content, flags)) + + if matches: + # Extract context around first match (50 chars before and after) + match = matches[0] + start = max(0, match.start() - 50) + end = min(len(content), match.end() + 50) + context = content[start:end] + + # Add ellipsis if truncated + if start > 0: + context = '...' + context + if end < len(content): + context = context + '...' + + # Highlight the match in context + match_text = content[match.start():match.end()] + context = context.replace(match_text, f'{match_text}') + + # Add result + results.append({ + 'branch_id': branch_id, + 'branch_name': branch_name, + 'message_index': i, + 'context': context, + 'match_count': len(matches) + }) + + # Search main conversation + if hasattr(self, 'main_conversation'): + search_conversation(self.main_conversation, 'main', 'Main Conversation') + + # Search all branches if option is selected + if hasattr(self.search_dialog, 'search_all_branches') and self.search_dialog.search_all_branches.isChecked(): + for branch_id, branch_data in self.branch_conversations.items(): + branch_type = branch_data.get('type', 'branch') + selected_text = branch_data.get('selected_text', '') + branch_name = f"{branch_type.capitalize()}: {selected_text[:30]}..." + + conversation = branch_data.get('conversation', []) + search_conversation(conversation, branch_id, branch_name) + + # Display results in search dialog + if hasattr(self, 'search_dialog'): + self.search_dialog.display_results(results) + + # Update status bar + if results: + self.statusBar().showMessage(f"Found {len(results)} results for '{query}'", 5000) + else: + self.statusBar().showMessage(f"No results found for '{query}'", 5000) + + def jump_to_search_result(self, branch_id, message_index): + """Jump to a specific search result""" + # Switch to the branch + self.on_branch_select(branch_id) + + # Scroll to the message (if possible) + # This is a simplified version - you could enhance it to scroll to the exact message + self.statusBar().showMessage(f"Jumped to message {message_index + 1} in {branch_id}", 3000) + def handle_user_input(self, text): """Handle user input from the conversation pane""" # Add user message to conversation