From 0d4c7ca065faa88951d684f056d531c9f6ef6108 Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:59:51 +0530 Subject: [PATCH 1/2] feat: add history manager to track AI responses and transformations --- Windows_and_Linux/WritingToolApp.py | 47 ++- Windows_and_Linux/history_manager.py | 370 ++++++++++++++++++++++ Windows_and_Linux/ui/HistoryWindow.py | 427 ++++++++++++++++++++++++++ 3 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 Windows_and_Linux/history_manager.py create mode 100644 Windows_and_Linux/ui/HistoryWindow.py diff --git a/Windows_and_Linux/WritingToolApp.py b/Windows_and_Linux/WritingToolApp.py index b230a12..71c79ec 100644 --- a/Windows_and_Linux/WritingToolApp.py +++ b/Windows_and_Linux/WritingToolApp.py @@ -15,6 +15,7 @@ import ui.ResponseWindow import ui.SettingsWindow from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from history_manager import HistoryManager from pynput import keyboard as pykeyboard from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import QLocale, Signal, Slot @@ -46,6 +47,7 @@ class WritingToolApp(QtWidgets.QApplication): show_message_signal = Signal(str, str) # a signal for showing message boxes hotkey_triggered_signal = Signal() followup_response_signal = Signal(str) + history_updated_signal = Signal() def __init__(self, argv): @@ -65,6 +67,11 @@ def __init__(self, argv): self.options = None self.options_path = None self.load_options() + self.history_manager = HistoryManager( + os.path.dirname(sys.argv[0]), + on_updated=self.history_updated_signal.emit + ) + self.history_updated_signal.connect(self.history_manager.refresh_window) self.onboarding_window = None self.popup_window = None self.tray_icon = None @@ -149,6 +156,7 @@ def setup_translations(self, lang=None): ui.ResponseWindow._ = self._ ui.OnboardingWindow._ = self._ ui.CustomPopupWindow._ = self._ + self.history_manager.set_translation_function(self._) def retranslate_ui(self): self.update_tray_menu() @@ -663,6 +671,8 @@ def process_option(self, option, custom_change=None): if hasattr(self, 'current_response_window'): delattr(self, 'current_response_window') + self.history_manager.clear_pending_inline_history() + threading.Thread( target=self.process_option_thread, args=(option, custom_change), @@ -710,7 +720,8 @@ def process_option_thread(self, option, custom_change=None): self.show_message_signal.emit('Error', 'Please select text to use this option.') return - if self.options[option]['open_in_window']: + open_in_window = self.options[option]['open_in_window'] + if open_in_window: QtCore.QMetaObject.invokeMethod( self, '_setup_response_window', @@ -718,6 +729,8 @@ def process_option_thread(self, option, custom_change=None): QtCore.Q_ARG(str, option), QtCore.Q_ARG(str, selected_text) ) + else: + self.history_manager.set_pending_inline_history(option, selected_text) try: selected_prompt = self.options.get(option, ('', '')) @@ -732,11 +745,28 @@ def process_option_thread(self, option, custom_change=None): logging.debug(f'Getting response from provider for option: {option}') - if self.options[option]['open_in_window']: + if open_in_window: logging.debug('Getting response for window display') response = self.current_provider.get_response(system_instruction, prompt, return_response=True) + response = response or '' logging.debug(f'Got response of length: {len(response) if response else 0}') + cleaned_response = response.rstrip('\n') + if cleaned_response.strip(): + history_entry_id = self.history_manager.record_entry( + option=option, + input_text=selected_text, + output_text=cleaned_response, + conversation=[ + {'role': 'user', 'content': selected_text}, + {'role': 'assistant', 'content': cleaned_response} + ] + ) + self.history_manager.attach_entry_to_response_window( + getattr(self, 'current_response_window', None), + history_entry_id + ) + if hasattr(self, 'current_response_window'): # noinspection PyTypeChecker QtCore.QMetaObject.invokeMethod( @@ -753,6 +783,7 @@ def process_option_thread(self, option, custom_change=None): except Exception as e: logging.error(f'An error occurred: {e}', exc_info=True) + self.history_manager.clear_pending_inline_history() if "Resource has been exhausted" in str(e): self.show_message_signal.emit('Error - Rate Limit Hit', 'Whoops! You\'ve hit the per-minute rate limit of the Gemini API. Please try again in a few moments.\n\nIf this happens often, simply switch to a Gemini model with a higher usage limit in Settings.') @@ -788,6 +819,7 @@ def replace_text(self, new_text): # If the new text is the error message, show a message box if current_output == error_message: + self.history_manager.clear_pending_inline_history() self.show_message_signal.emit('Error', 'The text is incompatible with the requested change.') return @@ -826,12 +858,14 @@ def press_ctrl_v(): press_ctrl_v() time.sleep(0.2) pyperclip.copy(clipboard_backup) + self.history_manager.consume_pending_inline_history(cleaned_text) if not hasattr(self, 'current_response_window'): self.output_queue = "" except Exception as e: logging.error(f'Error processing output: {e}') + self.history_manager.clear_pending_inline_history() else: logging.debug('No new text to process') @@ -870,6 +904,10 @@ def update_tray_menu(self): # Apply dark mode styles using darkdetect self.apply_dark_mode_styles(self.tray_menu) + # History menu item + history_action = self.tray_menu.addAction(self._('History')) + history_action.triggered.connect(self.history_manager.show_window) + # Settings menu item settings_action = self.tray_menu.addAction(self._('Settings')) settings_action.triggered.connect(self.show_settings) @@ -975,6 +1013,9 @@ def process_thread(): "role": "user", "content": question }) + history_entry_id = getattr(response_window, 'history_entry_id', None) + if history_entry_id: + self.history_manager.append_turn(history_entry_id, 'user', question) # Get chat history history = response_window.chat_history.copy() @@ -1037,6 +1078,8 @@ def process_thread(): "role": "assistant", "content": response_text }) + if history_entry_id and response_text: + self.history_manager.append_turn(history_entry_id, 'assistant', response_text) # Emit response via signal self.followup_response_signal.emit(response_text) diff --git a/Windows_and_Linux/history_manager.py b/Windows_and_Linux/history_manager.py new file mode 100644 index 0000000..ed75b89 --- /dev/null +++ b/Windows_and_Linux/history_manager.py @@ -0,0 +1,370 @@ +import json +import logging +import os +import threading +import uuid +from datetime import datetime + +from PySide6 import QtCore + +import ui.HistoryWindow + + +class HistoryManager: + """ + Handles history persistence, in-memory history state, and history window refresh. + """ + + def __init__(self, base_dir, on_updated=None): + self.history_entries = [] + self.history_path = os.path.join(base_dir, 'history.json') + self._history_lock = threading.Lock() + self._pending_inline_history = None + self._on_updated = on_updated + self.history_window = None + self.load_history() + + def set_translation_function(self, translate_func): + ui.HistoryWindow._ = translate_func + + def _emit_updated(self): + if callable(self._on_updated): + try: + self._on_updated() + except Exception as e: + logging.error(f'Failed to emit history update callback: {e}') + + def load_history(self): + """ + Load history entries from history.json and keep only the latest 50. + """ + logging.debug(f'Loading history from {self.history_path}') + + if not os.path.exists(self.history_path): + self.history_entries = [] + self._save_history_entries() + return + + try: + with open(self.history_path, 'r') as f: + data = json.load(f) + except Exception as e: + logging.error(f'Failed to load history: {e}') + self.history_entries = [] + return + + if not isinstance(data, list): + logging.warning('history.json is not a list. Resetting history.') + self.history_entries = [] + self._save_history_entries() + return + + normalized_entries = [] + for raw_entry in data: + normalized = self._normalize_history_entry(raw_entry) + if normalized is not None: + normalized_entries.append(normalized) + + with self._history_lock: + self.history_entries = normalized_entries[:50] + + self._save_history_entries() + + @staticmethod + def _history_timestamp(): + """ + Build a human-readable local timestamp for history rows. + """ + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + @staticmethod + def _sync_history_entry_fields(entry): + """ + Keep input/output fields consistent with the conversation payload. + """ + conversation = entry.get('conversation') + if not isinstance(conversation, list): + conversation = [] + + cleaned_conversation = [] + for turn in conversation: + if not isinstance(turn, dict): + continue + role = 'assistant' if turn.get('role') == 'assistant' else 'user' + content = str(turn.get('content') or '') + if content.strip(): + cleaned_conversation.append({'role': role, 'content': content}) + + if not cleaned_conversation: + input_text = str(entry.get('input') or '') + output_text = str(entry.get('output') or '') + if input_text: + cleaned_conversation.append({'role': 'user', 'content': input_text}) + if output_text: + cleaned_conversation.append({'role': 'assistant', 'content': output_text}) + + input_text = str(entry.get('input') or '') + if not input_text: + for turn in cleaned_conversation: + if turn['role'] == 'user': + input_text = turn['content'] + break + + output_text = str(entry.get('output') or '') + for turn in reversed(cleaned_conversation): + if turn['role'] == 'assistant': + output_text = turn['content'] + break + + entry['input'] = input_text + entry['output'] = output_text + entry['conversation'] = cleaned_conversation + + def _normalize_history_entry(self, entry): + """ + Normalize a raw entry loaded from disk into the expected shape. + """ + if not isinstance(entry, dict): + return None + + normalized = { + 'id': str(entry.get('id') or uuid.uuid4()), + 'timestamp': str(entry.get('timestamp') or self._history_timestamp()), + 'option': str(entry.get('option') or ''), + 'input': str(entry.get('input') or ''), + 'output': str(entry.get('output') or ''), + 'conversation': entry.get('conversation') if isinstance(entry.get('conversation'), list) else [] + } + self._sync_history_entry_fields(normalized) + return normalized + + def _save_history_entries(self): + """ + Persist the in-memory history list to history.json. + """ + with self._history_lock: + self.history_entries = self.history_entries[:50] + history_copy = [] + for entry in self.history_entries: + copied_entry = { + 'id': str(entry.get('id') or ''), + 'timestamp': str(entry.get('timestamp') or ''), + 'option': str(entry.get('option') or ''), + 'input': str(entry.get('input') or ''), + 'output': str(entry.get('output') or ''), + 'conversation': [] + } + raw_conversation = entry.get('conversation', []) + if isinstance(raw_conversation, list): + for turn in raw_conversation: + if not isinstance(turn, dict): + continue + role = 'assistant' if turn.get('role') == 'assistant' else 'user' + content = str(turn.get('content') or '') + if content.strip(): + copied_entry['conversation'].append({'role': role, 'content': content}) + history_copy.append(copied_entry) + + try: + with open(self.history_path, 'w') as f: + json.dump(history_copy, f, indent=2) + except Exception as e: + logging.error(f'Failed to save history: {e}') + + def snapshot(self): + """ + Return a copy for UI rendering without lock contention. + """ + with self._history_lock: + snapshot = [] + for entry in self.history_entries: + copied = { + 'id': str(entry.get('id') or ''), + 'timestamp': str(entry.get('timestamp') or ''), + 'option': str(entry.get('option') or ''), + 'input': str(entry.get('input') or ''), + 'output': str(entry.get('output') or ''), + 'conversation': [] + } + raw_conversation = entry.get('conversation', []) + if isinstance(raw_conversation, list): + for turn in raw_conversation: + if not isinstance(turn, dict): + continue + copied['conversation'].append({ + 'role': 'assistant' if turn.get('role') == 'assistant' else 'user', + 'content': str(turn.get('content') or '') + }) + snapshot.append(copied) + return snapshot + + def record_entry(self, option, input_text, output_text, conversation=None, entry_id=None): + """ + Insert a new history row at the top or update an existing row. + """ + with self._history_lock: + target_entry = None + if entry_id: + for existing_entry in self.history_entries: + if existing_entry.get('id') == entry_id: + target_entry = existing_entry + break + + if target_entry is None: + target_entry = { + 'id': str(entry_id or uuid.uuid4()), + 'timestamp': self._history_timestamp(), + 'option': str(option or ''), + 'input': str(input_text or ''), + 'output': str(output_text or ''), + 'conversation': [] + } + self.history_entries.insert(0, target_entry) + else: + if option is not None: + target_entry['option'] = str(option) + if input_text is not None: + target_entry['input'] = str(input_text) + if output_text is not None: + target_entry['output'] = str(output_text) + + if conversation is not None: + cleaned_conversation = [] + for turn in conversation: + if not isinstance(turn, dict): + continue + role = 'assistant' if turn.get('role') == 'assistant' else 'user' + content = str(turn.get('content') or '') + if content.strip(): + cleaned_conversation.append({'role': role, 'content': content}) + target_entry['conversation'] = cleaned_conversation + + self._sync_history_entry_fields(target_entry) + self.history_entries = self.history_entries[:50] + resolved_id = target_entry['id'] + + self._save_history_entries() + self._emit_updated() + return resolved_id + + def append_turn(self, entry_id, role, content): + """ + Append a single user/assistant turn to an existing history row. + """ + content = str(content or '') + if not entry_id or not content.strip(): + return + + with self._history_lock: + target_entry = None + for existing_entry in self.history_entries: + if existing_entry.get('id') == entry_id: + target_entry = existing_entry + break + + if target_entry is None: + return + + conversation = target_entry.get('conversation') + if not isinstance(conversation, list): + conversation = [] + + conversation.append({ + 'role': 'assistant' if role == 'assistant' else 'user', + 'content': content + }) + target_entry['conversation'] = conversation + self._sync_history_entry_fields(target_entry) + + self._save_history_entries() + self._emit_updated() + + def set_pending_inline_history(self, option, input_text): + """ + Cache inline request metadata until replacement output arrives. + """ + with self._history_lock: + self._pending_inline_history = { + 'option': str(option or ''), + 'input': str(input_text or '') + } + + def clear_pending_inline_history(self): + """ + Drop any stale inline history metadata. + """ + with self._history_lock: + self._pending_inline_history = None + + def consume_pending_inline_history(self, output_text): + """ + Finalize an inline history entry once we have output text. + """ + cleaned_output = str(output_text or '').rstrip('\n') + if not cleaned_output.strip(): + self.clear_pending_inline_history() + return + + with self._history_lock: + pending = self._pending_inline_history + self._pending_inline_history = None + + if not pending: + return + + input_text = pending.get('input', '') + self.record_entry( + option=pending.get('option', ''), + input_text=input_text, + output_text=cleaned_output, + conversation=[ + {'role': 'user', 'content': input_text}, + {'role': 'assistant', 'content': cleaned_output} + ] + ) + + @staticmethod + def attach_entry_to_response_window(response_window, entry_id): + if response_window is not None: + response_window.history_entry_id = entry_id + + def refresh_window(self): + if self.history_window: + self.history_window.set_history_entries(self.snapshot()) + + def show_window(self, *_args): + """ + Show (or focus) the history window. + + Defensive: any exception during construction or population is logged + with a full traceback so silent failures (which previously made the + window "never open") become visible in the application log. + + Positioning: the window is moved onto the screen containing the + cursor before being shown, so it can't accidentally appear + off-screen on multi-monitor setups. + """ + logging.debug('Showing history window') + try: + if not self.history_window: + self.history_window = ui.HistoryWindow.HistoryWindow() + self.history_window.set_history_entries(self.snapshot()) + + # Restore from minimized state if needed — `show()` alone won't + # un-minimize a window on all platforms. + if self.history_window.isMinimized(): + self.history_window.setWindowState( + self.history_window.windowState() & ~QtCore.Qt.WindowState.WindowMinimized + ) + + # Make sure the window lands on a visible screen. `restoreGeometry` + # may have brought it back at an off-screen position if monitor + # configuration changed between sessions. + ui.HistoryWindow.position_window_on_active_screen(self.history_window) + + self.history_window.show() + self.history_window.raise_() + self.history_window.activateWindow() + logging.debug('History window shown') + except Exception as e: + logging.error(f'Failed to show history window: {e}', exc_info=True) diff --git a/Windows_and_Linux/ui/HistoryWindow.py b/Windows_and_Linux/ui/HistoryWindow.py new file mode 100644 index 0000000..ea4177c --- /dev/null +++ b/Windows_and_Linux/ui/HistoryWindow.py @@ -0,0 +1,427 @@ +import logging + +from PySide6 import QtCore, QtGui, QtWidgets +from PySide6.QtCore import QSettings +from PySide6.QtGui import QCursor, QGuiApplication + +from ui.UIUtils import UIUtils, colorMode + +_ = lambda x: x + +# Max characters of the input snippet shown as the clickable "title". +_TITLE_MAX_CHARS = 80 +# Max characters of the secondary line (first line of the input, also used as +# fallback when no input is available). +_PREVIEW_SECONDARY_MAX_CHARS = 120 + + +class HistoryEntryWidget(QtWidgets.QWidget): + """ + Expandable widget for a single history entry. + + Renders the entry as a clickable "title" row (first line of the user's + input, with timestamp/option as secondary metadata). Clicking the row + expands the full conversation transcript. Expansion is coordinated by the + owning HistoryWindow so only one entry is expanded at a time. + """ + + def __init__(self, entry, parent=None): + super().__init__(parent) + self.entry = entry or {} + self.preview_button = None + self.details_widget = None + self.conversation_label = None + self.conversation_text = None + # Optional callback invoked whenever this entry's expanded state + # changes. The owning HistoryWindow uses it to implement the + # "one expanded at a time" accordion behavior. + self.on_expanded_changed = None + self._build_ui() + self.set_entry(self.entry) + + def _build_ui(self): + try: + root_layout = QtWidgets.QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(6) + + self.preview_button = QtWidgets.QToolButton() + self.preview_button.setCheckable(True) + self.preview_button.setChecked(False) + self.preview_button.setArrowType(QtCore.Qt.ArrowType.RightArrow) + self.preview_button.setToolButtonStyle( + QtCore.Qt.ToolButtonStyle.ToolButtonTextOnly + ) + self.preview_button.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.preview_button.setStyleSheet(f""" + QToolButton {{ + text-align: left; + padding: 10px 12px; + border-radius: 6px; + background-color: {'#2f2f2f' if colorMode == 'dark' else '#f4f4f4'}; + color: {'#ffffff' if colorMode == 'dark' else '#222222'}; + border: 1px solid {'#4a4a4a' if colorMode == 'dark' else '#d0d0d0'}; + font-size: 13px; + }} + QToolButton:hover {{ + background-color: {'#3a3a3a' if colorMode == 'dark' else '#ececec'}; + }} + QToolButton:checked {{ + background-color: {'#3a3a3a' if colorMode == 'dark' else '#e6e6e6'}; + }} + """) + self.preview_button.toggled.connect(self._toggle_expanded) + root_layout.addWidget(self.preview_button) + + self.details_widget = QtWidgets.QWidget() + self.details_widget.setVisible(False) + self.details_widget.setStyleSheet(f""" + QWidget {{ + border: 1px solid {'#4a4a4a' if colorMode == 'dark' else '#d6d6d6'}; + border-radius: 6px; + background-color: {'#252525' if colorMode == 'dark' else '#ffffff'}; + }} + """) + + details_layout = QtWidgets.QVBoxLayout(self.details_widget) + details_layout.setContentsMargins(12, 12, 12, 12) + details_layout.setSpacing(8) + + self.conversation_label = QtWidgets.QLabel(_("Conversation")) + self.conversation_label.setStyleSheet( + f"font-weight: bold; color: {'#ffffff' if colorMode == 'dark' else '#222222'};" + ) + details_layout.addWidget(self.conversation_label) + + self.conversation_text = QtWidgets.QPlainTextEdit() + self.conversation_text.setReadOnly(True) + self.conversation_text.setMinimumHeight(160) + self.conversation_text.setStyleSheet(f""" + QPlainTextEdit {{ + border: 1px solid {'#555555' if colorMode == 'dark' else '#d9d9d9'}; + border-radius: 4px; + background-color: {'#1e1e1e' if colorMode == 'dark' else '#fafafa'}; + color: {'#ffffff' if colorMode == 'dark' else '#222222'}; + font-size: 13px; + padding: 6px; + }} + """) + details_layout.addWidget(self.conversation_text) + + root_layout.addWidget(self.details_widget) + except Exception as e: + logging.error(f'HistoryEntryWidget._build_ui failed: {e}', exc_info=True) + raise + + def _toggle_expanded(self, expanded): + self.preview_button.setArrowType( + QtCore.Qt.ArrowType.DownArrow if expanded else QtCore.Qt.ArrowType.RightArrow + ) + self.details_widget.setVisible(expanded) + if self.on_expanded_changed is not None: + try: + self.on_expanded_changed(self, expanded) + except Exception as e: + logging.error(f'on_expanded_changed callback failed: {e}', exc_info=True) + + def _truncate(self, text, limit): + text = text.strip() + if len(text) <= limit: + return text + return text[: max(0, limit - 3)].rstrip() + '...' + + def _build_title(self, entry): + """ + Build the user-facing "title" line for the entry. + + Uses the first line of the user's input (or the first user turn in + the conversation) as the primary title — this is what the user + remembers the conversation by. Falls back to the first line of the + output when no user input is available. + """ + input_text = str(entry.get('input') or '').strip() + if not input_text: + conversation = entry.get('conversation') or [] + if isinstance(conversation, list): + for turn in conversation: + if isinstance(turn, dict) and turn.get('role') == 'user': + input_text = str(turn.get('content') or '').strip() + if input_text: + break + + if not input_text: + output_text = str(entry.get('output') or '').strip() + if output_text: + input_text = output_text + + if not input_text: + return _("(No content)") + + first_line = input_text.splitlines()[0] if input_text else '' + return self._truncate(first_line, _TITLE_MAX_CHARS) + + def _build_metadata(self, entry): + timestamp = str(entry.get('timestamp') or '').strip() + option = str(entry.get('option') or '').strip() + parts = [p for p in (timestamp, option) if p] + return ' • '.join(parts) + + @staticmethod + def _format_conversation(conversation): + if not isinstance(conversation, list): + return '' + + lines = [] + for turn in conversation: + if not isinstance(turn, dict): + continue + role = 'Assistant' if turn.get('role') == 'assistant' else 'User' + content = str(turn.get('content') or '').strip() + if not content: + continue + lines.append(f"{role}:") + lines.append(content) + lines.append('') + return '\n'.join(lines).strip() + + def set_entry(self, entry): + try: + self.entry = entry or {} + + title = self._build_title(self.entry) + metadata = self._build_metadata(self.entry) + if metadata: + # Two-line title row: main title on the first line, + # timestamp + option as small secondary text below. + self.preview_button.setText(f"{title}\n{metadata}") + else: + self.preview_button.setText(title) + + conversation_text = self._format_conversation(self.entry.get('conversation', [])) + self.conversation_text.setPlainText(conversation_text) + + show_conversation = bool(conversation_text) + self.conversation_label.setVisible(show_conversation) + self.conversation_text.setVisible(show_conversation) + except Exception as e: + logging.error(f'HistoryEntryWidget.set_entry failed: {e}', exc_info=True) + + def retranslate_ui(self): + self.set_entry(self.entry) + + +class HistoryWindow(QtWidgets.QWidget): + """ + Standalone window for browsing the latest history entries. + + Renders a scrollable list of conversation titles. Clicking a title + expands the full conversation in place. Only one entry can be expanded + at a time (accordion). + """ + + def __init__(self): + super().__init__() + self.history_entries = [] + self.title_label = None + self.subtitle_label = None + self.empty_label = None + self.scroll_area = None + self.scroll_content = None + self.entries_layout = None + self.entry_widgets = [] + # Persist size/position between openings so the window reliably + # re-appears where the user last left it (and not off-screen on + # multi-monitor setups). + self._settings = QSettings('WritingTools', 'HistoryWindow') + self._build_ui() + self._restore_geometry() + # When the window closes, snapshot the current geometry for next time. + self.destroyed.connect(self._save_geometry_on_destroy) + + # --- Geometry persistence ------------------------------------------------- + + def _restore_geometry(self): + try: + size = self._settings.value('size') + if size is not None: + self.resize(size) + pos = self._settings.value('pos') + if pos is not None: + self.move(pos) + except Exception as e: + logging.warning(f'Failed to restore HistoryWindow geometry: {e}') + + def _save_geometry(self): + try: + self._settings.setValue('size', self.size()) + self._settings.setValue('pos', self.pos()) + except Exception as e: + logging.warning(f'Failed to save HistoryWindow geometry: {e}') + + def _save_geometry_on_destroy(self, *_args): + self._save_geometry() + + def closeEvent(self, event): + self._save_geometry() + super().closeEvent(event) + + # --- UI construction ------------------------------------------------------ + + def _build_ui(self): + try: + self.setWindowTitle(_("History")) + self.setWindowFlag(QtCore.Qt.WindowType.Window, True) + self.resize(820, 680) + + UIUtils.setup_window_and_layout(self) + + content_layout = QtWidgets.QVBoxLayout(self.background) + content_layout.setContentsMargins(20, 20, 20, 20) + content_layout.setSpacing(10) + + self.title_label = QtWidgets.QLabel(_("History")) + self.title_label.setStyleSheet( + f"font-size: 24px; font-weight: bold; color: {'#ffffff' if colorMode == 'dark' else '#222222'};" + ) + content_layout.addWidget(self.title_label) + + self.subtitle_label = QtWidgets.QLabel( + _("Click a title to expand the full conversation. Only one entry is open at a time.") + ) + self.subtitle_label.setStyleSheet( + f"font-size: 13px; color: {'#c0c0c0' if colorMode == 'dark' else '#555555'};" + ) + self.subtitle_label.setWordWrap(True) + content_layout.addWidget(self.subtitle_label) + + self.empty_label = QtWidgets.QLabel(_("No history yet.")) + self.empty_label.setStyleSheet( + f"font-size: 14px; color: {'#d0d0d0' if colorMode == 'dark' else '#666666'};" + ) + self.empty_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + content_layout.addWidget(self.empty_label) + + self.scroll_area = QtWidgets.QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.scroll_area.setStyleSheet("QScrollArea { background: transparent; }") + + self.scroll_content = QtWidgets.QWidget() + self.entries_layout = QtWidgets.QVBoxLayout(self.scroll_content) + self.entries_layout.setContentsMargins(0, 0, 0, 0) + self.entries_layout.setSpacing(8) + self.entries_layout.addStretch() + + self.scroll_area.setWidget(self.scroll_content) + content_layout.addWidget(self.scroll_area, 1) + + self._render_entries() + except Exception as e: + logging.error(f'HistoryWindow._build_ui failed: {e}', exc_info=True) + raise + + # --- Data ----------------------------------------------------------------- + + def set_history_entries(self, entries): + self.history_entries = entries or [] + self._render_entries() + + def _render_entries(self): + # Wipe any existing entry widgets. + try: + UIUtils.clear_layout(self.entries_layout) + except Exception as e: + logging.error(f'Failed to clear entries layout: {e}', exc_info=True) + self.entry_widgets = [] + + if not self.history_entries: + self.empty_label.setVisible(True) + # Re-add the trailing stretch that clear_layout removed. + self.entries_layout.addStretch() + return + + self.empty_label.setVisible(False) + + for entry in self.history_entries: + entry_widget = HistoryEntryWidget(entry) + # Wire the accordion: when this entry expands, collapse all + # sibling entries that are currently expanded. + entry_widget.on_expanded_changed = self._on_entry_expanded_changed + self.entry_widgets.append(entry_widget) + self.entries_layout.addWidget(entry_widget) + + self.entries_layout.addStretch() + + def _on_entry_expanded_changed(self, expanded_widget, expanded): + """ + Accordion behavior: if `expanded_widget` just expanded, collapse any + other entry that is currently expanded so only one is open at a time. + """ + if not expanded: + return + for entry_widget in self.entry_widgets: + if entry_widget is expanded_widget: + continue + if entry_widget.preview_button.isChecked(): + # Block signals so we don't recursively re-enter this handler. + entry_widget.preview_button.blockSignals(True) + try: + entry_widget.preview_button.setChecked(False) + finally: + entry_widget.preview_button.blockSignals(False) + # Manually update the arrow + details visibility, since + # blocking signals suppresses the toggled() slot. + entry_widget._toggle_expanded(False) + + def retranslate_ui(self): + self.setWindowTitle(_("History")) + self.title_label.setText(_("History")) + self.subtitle_label.setText( + _("Click a title to expand the full conversation. Only one entry is open at a time.") + ) + self.empty_label.setText(_("No history yet.")) + + for entry_widget in self.entry_widgets: + entry_widget.retranslate_ui() + + +# --- Helpers used by HistoryManager.show_window ------------------------------ + +def position_window_on_active_screen(window): + """ + Move `window` so it is fully visible on the screen containing the cursor. + + Falls back to the primary screen if the cursor's screen can't be + determined. Used by HistoryManager.show_window to make sure the history + window never opens off-screen. + """ + try: + cursor_pos = QCursor.pos() + screen = QGuiApplication.screenAt(cursor_pos) + if screen is None: + screen = QGuiApplication.primaryScreen() + if screen is None: + return + + screen_geometry = screen.availableGeometry() + window_size = window.sizeHint() + # Use the current window size if sizeHint collapsed to (0, 0) + # (which can happen for a freshly-constructed widget). + if window_size.width() <= 0 or window_size.height() <= 0: + window_size = window.size() + + # Default: center on the active screen. + x = screen_geometry.x() + (screen_geometry.width() - window_size.width()) // 2 + y = screen_geometry.y() + (screen_geometry.height() - window_size.height()) // 4 + + # Clamp to screen bounds (just in case the window is larger than the + # screen or the centered position overshoots). + x = max(screen_geometry.x(), min(x, screen_geometry.right() - window_size.width() + 1)) + y = max(screen_geometry.y(), min(y, screen_geometry.bottom() - window_size.height() + 1)) + + window.move(x, y) + except Exception as e: + logging.warning(f'position_window_on_active_screen failed: {e}', exc_info=True) \ No newline at end of file From d30a6f6faee15db4c469f8383da59b444a9ae754 Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:12:24 +0530 Subject: [PATCH 2/2] feat(history): add delete button per entry and search filter --- Windows_and_Linux/history_manager.py | 21 ++++ Windows_and_Linux/ui/HistoryWindow.py | 140 +++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/Windows_and_Linux/history_manager.py b/Windows_and_Linux/history_manager.py index ed75b89..aa26d93 100644 --- a/Windows_and_Linux/history_manager.py +++ b/Windows_and_Linux/history_manager.py @@ -279,6 +279,26 @@ def append_turn(self, entry_id, role, content): self._save_history_entries() self._emit_updated() + def delete_entry(self, entry_id): + """ + Remove a single history entry by its id. + """ + if not entry_id: + return False + + with self._history_lock: + original_len = len(self.history_entries) + self.history_entries = [ + e for e in self.history_entries if e.get('id') != entry_id + ] + deleted = len(self.history_entries) < original_len + + if deleted: + self._save_history_entries() + self._emit_updated() + self.refresh_window() + return deleted + def set_pending_inline_history(self, option, input_text): """ Cache inline request metadata until replacement output arrives. @@ -348,6 +368,7 @@ def show_window(self, *_args): try: if not self.history_window: self.history_window = ui.HistoryWindow.HistoryWindow() + self.history_window.set_on_delete_entry(self.delete_entry) self.history_window.set_history_entries(self.snapshot()) # Restore from minimized state if needed — `show()` alone won't diff --git a/Windows_and_Linux/ui/HistoryWindow.py b/Windows_and_Linux/ui/HistoryWindow.py index ea4177c..c5e680b 100644 --- a/Windows_and_Linux/ui/HistoryWindow.py +++ b/Windows_and_Linux/ui/HistoryWindow.py @@ -25,6 +25,9 @@ class HistoryEntryWidget(QtWidgets.QWidget): owning HistoryWindow so only one entry is expanded at a time. """ + # Emitted when the user requests deletion of this entry. + on_delete_requested = None + def __init__(self, entry, parent=None): super().__init__(parent) self.entry = entry or {} @@ -45,6 +48,34 @@ def _build_ui(self): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(6) + # Horizontal row: [delete button] [preview/title button] + self.row_widget = QtWidgets.QWidget() + row_layout = QtWidgets.QHBoxLayout(self.row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(6) + + self.delete_button = QtWidgets.QPushButton("×") + self.delete_button.setFixedSize(28, 28) + self.delete_button.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.PointingHandCursor)) + self.delete_button.setToolTip(_("Delete this entry")) + self.delete_button.setStyleSheet(f""" + QPushButton {{ + text-align: center; + font-size: 16px; + font-weight: bold; + border-radius: 6px; + background-color: {'#3a3a3a' if colorMode == 'dark' else '#e0e0e0'}; + color: {'#ff6b6b' if colorMode == 'dark' else '#cc0000'}; + border: 1px solid {'#555555' if colorMode == 'dark' else '#cccccc'}; + }} + QPushButton:hover {{ + background-color: {'#ff6b6b' if colorMode == 'dark' else '#ff6b6b'}; + color: {'#ffffff' if colorMode == 'dark' else '#ffffff'}; + }} + """) + self.delete_button.clicked.connect(self._on_delete_clicked) + row_layout.addWidget(self.delete_button) + self.preview_button = QtWidgets.QToolButton() self.preview_button.setCheckable(True) self.preview_button.setChecked(False) @@ -74,7 +105,9 @@ def _build_ui(self): }} """) self.preview_button.toggled.connect(self._toggle_expanded) - root_layout.addWidget(self.preview_button) + row_layout.addWidget(self.preview_button, 1) + + root_layout.addWidget(self.row_widget) self.details_widget = QtWidgets.QWidget() self.details_widget.setVisible(False) @@ -116,6 +149,11 @@ def _build_ui(self): logging.error(f'HistoryEntryWidget._build_ui failed: {e}', exc_info=True) raise + def _on_delete_clicked(self): + entry_id = self.entry.get('id') + if entry_id and callable(self.on_delete_requested): + self.on_delete_requested(entry_id) + def _toggle_expanded(self, expanded): self.preview_button.setArrowType( QtCore.Qt.ArrowType.DownArrow if expanded else QtCore.Qt.ArrowType.RightArrow @@ -232,10 +270,13 @@ def __init__(self): self.scroll_content = None self.entries_layout = None self.entry_widgets = [] + self.search_input = None # Persist size/position between openings so the window reliably # re-appears where the user last left it (and not off-screen on # multi-monitor setups). self._settings = QSettings('WritingTools', 'HistoryWindow') + # Callback wired by HistoryManager to handle deletion. + self._on_delete_entry = None self._build_ui() self._restore_geometry() # When the window closes, snapshot the current geometry for next time. @@ -297,6 +338,22 @@ def _build_ui(self): self.subtitle_label.setWordWrap(True) content_layout.addWidget(self.subtitle_label) + # Search bar + self.search_input = QtWidgets.QLineEdit() + self.search_input.setPlaceholderText(_("Search history...")) + self.search_input.setStyleSheet(f""" + QLineEdit {{ + padding: 8px 12px; + border-radius: 6px; + border: 1px solid {'#555555' if colorMode == 'dark' else '#cccccc'}; + background-color: {'#2a2a2a' if colorMode == 'dark' else '#ffffff'}; + color: {'#ffffff' if colorMode == 'dark' else '#222222'}; + font-size: 13px; + }} + """) + self.search_input.textChanged.connect(self._on_search_text_changed) + content_layout.addWidget(self.search_input) + self.empty_label = QtWidgets.QLabel(_("No history yet.")) self.empty_label.setStyleSheet( f"font-size: 14px; color: {'#d0d0d0' if colorMode == 'dark' else '#666666'};" @@ -329,6 +386,40 @@ def set_history_entries(self, entries): self.history_entries = entries or [] self._render_entries() + def set_on_delete_entry(self, callback): + """ + Set the callback invoked when the user confirms deletion of an entry. + Signature: callback(entry_id: str) -> None + """ + self._on_delete_entry = callback + + def _filtered_entries(self): + query = (self.search_input.text() or '').strip().lower() if self.search_input else '' + if not query: + return self.history_entries + + filtered = [] + for entry in self.history_entries: + # Search across all relevant text fields + searchable_parts = [ + str(entry.get('input') or ''), + str(entry.get('output') or ''), + str(entry.get('option') or ''), + str(entry.get('timestamp') or ''), + ] + # Also include conversation content + conversation = entry.get('conversation') or [] + if isinstance(conversation, list): + for turn in conversation: + if isinstance(turn, dict): + searchable_parts.append(str(turn.get('content') or '')) + + full_text = ' '.join(searchable_parts).lower() + if query in full_text: + filtered.append(entry) + + return filtered + def _render_entries(self): # Wipe any existing entry widgets. try: @@ -337,19 +428,28 @@ def _render_entries(self): logging.error(f'Failed to clear entries layout: {e}', exc_info=True) self.entry_widgets = [] - if not self.history_entries: + entries = self._filtered_entries() + + if not entries: self.empty_label.setVisible(True) + # Update label text based on whether there is history but no match + if self.history_entries and (self.search_input and self.search_input.text().strip()): + self.empty_label.setText(_("No matching entries.")) + else: + self.empty_label.setText(_("No history yet.")) # Re-add the trailing stretch that clear_layout removed. self.entries_layout.addStretch() return self.empty_label.setVisible(False) - for entry in self.history_entries: + for entry in entries: entry_widget = HistoryEntryWidget(entry) # Wire the accordion: when this entry expands, collapse all # sibling entries that are currently expanded. entry_widget.on_expanded_changed = self._on_entry_expanded_changed + # Wire deletion + entry_widget.on_delete_requested = self._on_entry_delete_requested self.entry_widgets.append(entry_widget) self.entries_layout.addWidget(entry_widget) @@ -376,13 +476,45 @@ def _on_entry_expanded_changed(self, expanded_widget, expanded): # blocking signals suppresses the toggled() slot. entry_widget._toggle_expanded(False) + def _on_entry_delete_requested(self, entry_id): + """ + Show a confirmation dialog before proceeding with deletion. + """ + reply = QtWidgets.QMessageBox.question( + self, + _("Delete Entry"), + _("Are you sure you want to delete this history entry?"), + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + if callable(self._on_delete_entry): + try: + self._on_delete_entry(entry_id) + except Exception as e: + logging.error(f'on_delete_entry callback failed: {e}', exc_info=True) + + def _on_search_text_changed(self): + self._render_entries() + def retranslate_ui(self): self.setWindowTitle(_("History")) self.title_label.setText(_("History")) self.subtitle_label.setText( _("Click a title to expand the full conversation. Only one entry is open at a time.") ) - self.empty_label.setText(_("No history yet.")) + if self.search_input: + self.search_input.setPlaceholderText(_("Search history...")) + + # Retranslate based on current state + entries = self._filtered_entries() + if not entries: + if self.history_entries and (self.search_input and self.search_input.text().strip()): + self.empty_label.setText(_("No matching entries.")) + else: + self.empty_label.setText(_("No history yet.")) + else: + self.empty_label.setText(_("No history yet.")) for entry_widget in self.entry_widgets: entry_widget.retranslate_ui()