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''
+ 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