pyqt-code-editor 0.0.53__tar.gz → 0.0.55__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/PKG-INFO +2 -2
  2. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/__init__.py +1 -1
  3. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/_settings.py +1 -0
  4. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/app.py +4 -0
  5. pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/__init__.py +1 -0
  6. pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/confirm_run_code_dialog.py +59 -0
  7. pyqt_code_editor-0.0.53/pyqt_code_editor/components/sigmund.py → pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/editor_workspace.py +5 -26
  8. pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/sigmund.py +28 -0
  9. pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/sigmund_analyst_chat_widget.py +35 -0
  10. pyqt_code_editor-0.0.55/pyqt_code_editor/components/sigmund/sigmund_analyst_widget.py +191 -0
  11. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/auto_pair.py +0 -1
  12. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/base.py +4 -2
  13. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/comment.py +4 -0
  14. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/merge_undo_actions.py +1 -6
  15. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/search_replace.py +83 -28
  16. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/readme.md +1 -1
  17. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/COPYING +0 -0
  18. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyproject.toml +0 -0
  19. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/__init__.py +0 -0
  20. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/code_editors.py +0 -0
  21. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/__init__.py +0 -0
  22. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/generic.py +0 -0
  23. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/javascript.py +0 -0
  24. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/markdown.py +0 -0
  25. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/python.py +0 -0
  26. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/code_editors/languages/text.py +0 -0
  27. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/__init__.py +0 -0
  28. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/editor_panel.py +0 -0
  29. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/find_in_files.py +0 -0
  30. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/jupyter_console.py +0 -0
  31. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/project_explorer.py +0 -0
  32. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/settings_panel.py +0 -0
  33. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/components/workspace_explorer.py +0 -0
  34. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/environment_manager.py +0 -0
  35. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/__init__.py +0 -0
  36. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/auto_indent.py +0 -0
  37. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/check.py +0 -0
  38. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/complete.py +0 -0
  39. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/execute.py +0 -0
  40. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/file_link.py +0 -0
  41. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/highlight_matching_brackets.py +0 -0
  42. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/line_number.py +0 -0
  43. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/python_auto_indent.py +0 -0
  44. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/shortcuts.py +0 -0
  45. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/smart_backspace_delete.py +0 -0
  46. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/symbols.py +0 -0
  47. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/theme.py +0 -0
  48. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/widgets/__init__.py +0 -0
  49. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/mixins/zoom.py +0 -0
  50. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/sigmund-analyst.png +0 -0
  51. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/signal_router.py +0 -0
  52. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
  53. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
  54. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/syntax_highlighters/languages/generic.py +0 -0
  55. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/syntax_highlighters/languages/python.py +0 -0
  56. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +0 -0
  57. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/themes.py +0 -0
  58. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/__init__.py +0 -0
  59. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/languages/__init__.py +0 -0
  60. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/languages/python/__init__.py +0 -0
  61. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/languages/python/_auto_indent.py +0 -0
  62. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/languages/python/_extract_cells.py +0 -0
  63. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/utils/languages/python/_mask_str_in_code.py +0 -0
  64. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/watchdog.py +0 -0
  65. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/__init__.py +0 -0
  66. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/calltip_widget.py +0 -0
  67. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/completion_popup.py +0 -0
  68. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/dock.py +0 -0
  69. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/quick_open_dialog.py +0 -0
  70. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/quick_open_file_dialog.py +0 -0
  71. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/quick_symbol_dialog.py +0 -0
  72. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/search_replace_frame.py +0 -0
  73. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/tab_splitter.py +0 -0
  74. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/widgets/tabbed_editor.py +0 -0
  75. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/__init__.py +0 -0
  76. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/languages/__init__.py +0 -0
  77. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/languages/generic.py +0 -0
  78. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/languages/python.py +0 -0
  79. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/manager.py +0 -0
  80. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/process.py +0 -0
  81. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/__init__.py +0 -0
  82. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/codestral.py +0 -0
  83. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/jedi.py +0 -0
  84. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/ruff.py +0 -0
  85. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/sigmund.py +0 -0
  86. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/symbol.py +0 -0
  87. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/providers/tree_sitter.py +0 -0
  88. {pyqt_code_editor-0.0.53 → pyqt_code_editor-0.0.55}/pyqt_code_editor/worker/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyqt_code_editor
3
- Version: 0.0.53
3
+ Version: 0.0.55
4
4
  Summary: Fully featured code-editor widgets for PyQt
5
5
  Keywords: code editor,pyqt,ide
6
6
  Author-email: Sebastiaan Mathôt <s.mathot@cogsci.nl>
@@ -32,7 +32,7 @@ Project-URL: Source, https://github.com/open-cogsci/sigmund-analyst
32
32
 
33
33
  # PyQt Code Editor / Sigmund Analyst
34
34
 
35
- Copyright 2025 Sebastiaan Mathôt
35
+ Copyright 2025-2026 Sebastiaan Mathôt
36
36
 
37
37
 
38
38
  ## About
@@ -2,4 +2,4 @@
2
2
 
3
3
  from ._settings import settings
4
4
 
5
- __version__ = '0.0.53'
5
+ __version__ = '0.0.55'
@@ -120,6 +120,7 @@ class Settings(QObject):
120
120
  check_interval_delay = SettingProperty(5000, "Checking")
121
121
 
122
122
  # Sigmund provider
123
+ sigmund_review_actions = SettingProperty(True, "Sigmund")
123
124
  sigmund_max_context = SettingProperty(2000, "Sigmund")
124
125
  sigmund_fim_endpoint = SettingProperty('http://localhost:5000/code_completion/fim', "Sigmund")
125
126
  sigmund_token = SettingProperty(None, "Sigmund")
@@ -349,6 +349,10 @@ class SigmundAnalyst(QMainWindow):
349
349
  self._jupyter_console.execute_code(code)
350
350
 
351
351
  def _execute_file(self, path):
352
+ working_directory = os.path.dirname(path)
353
+ if os.path.isdir(working_directory):
354
+ self._jupyter_console.change_directory(working_directory)
355
+ self._toggle_dock_widget(self._jupyter_console, show=True)
352
356
  self._jupyter_console.execute_file(path)
353
357
 
354
358
  def _find_in_files(self):
@@ -0,0 +1 @@
1
+ from .sigmund import Sigmund
@@ -0,0 +1,59 @@
1
+ from qtpy.QtWidgets import QVBoxLayout, QDialogButtonBox, QLabel, \
2
+ QSizePolicy, QDialog, QSplitter
3
+ from qtpy.QtCore import Qt
4
+ import logging
5
+ from sigmund_qtwidget.chat_browser import ChatBrowser
6
+ from pyqt_code_editor.code_editors import create_editor
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ MAX_MESSAGE_HEIGHT = 200
11
+
12
+ class ConfirmRunCodeDialog(QDialog):
13
+ """
14
+ A modal dialog that displays code to be executed and asks the user to confirm or cancel.
15
+ """
16
+
17
+ def __init__(self, parent, code: str, language: str):
18
+ super().__init__(parent)
19
+
20
+ self.setWindowTitle(f"Confirm {language} code execution")
21
+
22
+ layout = QVBoxLayout()
23
+ self.setLayout(layout)
24
+
25
+ # Use ChatBrowser to display the AI message
26
+ self.message_browser = ChatBrowser(self)
27
+ self.message_browser.append_message(
28
+ 'ai', 'Sigmund wants to run the following code:')
29
+ self.message_browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
30
+ self.message_browser.setMaximumHeight(MAX_MESSAGE_HEIGHT)
31
+
32
+ # Create code editor for the code to be executed
33
+ self.code_view = create_editor(language=language)
34
+ self.code_view.setPlainText(code)
35
+ self.code_view.setReadOnly(True)
36
+
37
+ # Create a vertical splitter to hold the message browser and the code
38
+ splitter = QSplitter(Qt.Vertical)
39
+ splitter.addWidget(self.message_browser)
40
+ splitter.addWidget(self.code_view)
41
+ layout.addWidget(splitter)
42
+
43
+ # The disclaimer label
44
+ disclaimer_label = QLabel(
45
+ "Carefully review the code before execution. Executing code can have unintended consequences.",
46
+ self
47
+ )
48
+ disclaimer_label.setWordWrap(True)
49
+ disclaimer_label.setObjectName('control-warning')
50
+ disclaimer_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
51
+ layout.addWidget(disclaimer_label)
52
+
53
+ # Dialog buttons
54
+ self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
55
+ self.button_box.accepted.connect(self.accept)
56
+ self.button_box.rejected.connect(self.reject)
57
+ layout.addWidget(self.button_box)
58
+
59
+ self.resize(800, 600)
@@ -1,6 +1,3 @@
1
- from sigmund_qtwidget.sigmund_widget import SigmundWidget
2
- from ..widgets import Dock
3
- from .. import watchdog, settings
4
1
  import textwrap
5
2
  import logging
6
3
  logger = logging.getLogger(__name__)
@@ -76,11 +73,14 @@ class EditorWorkspace:
76
73
 
77
74
  def set(self, content, language):
78
75
  text_cursor = self._editor.textCursor()
76
+ text_cursor.beginEditBlock()
79
77
  if text_cursor.hasSelection():
80
78
  text_cursor.insertText(content)
81
- self._editor.setTextCursor(text_cursor)
82
79
  else:
83
- self._editor.setPlainText(content)
80
+ text_cursor.select(text_cursor.Document)
81
+ text_cursor.insertText(content)
82
+ text_cursor.endEditBlock()
83
+ self._editor.setTextCursor(text_cursor)
84
84
  self._editor.set_modified(True)
85
85
 
86
86
  def has_changed(self, content, language):
@@ -98,24 +98,3 @@ class EditorWorkspace:
98
98
  if content is None:
99
99
  return ''
100
100
  return content
101
-
102
-
103
- class Sigmund(Dock):
104
- def __init__(self, parent, editor_panel):
105
- super().__init__('Sigmund', parent)
106
- self.setObjectName("sigmund")
107
- workspace = EditorWorkspace(editor_panel)
108
- self.sigmund_widget = SigmundWidget(self, application='Sigmund Analyst')
109
- self.sigmund_widget.setStyleSheet(f'font-size: {settings.font_size}pt')
110
- self.sigmund_widget.set_workspace_manager(workspace)
111
- self.setWidget(self.sigmund_widget)
112
-
113
- def setVisible(self, visible):
114
- if visible:
115
- logger.info('starting sigmund connector')
116
- self.sigmund_widget.start_server()
117
- watchdog.register_subprocess(self.sigmund_widget.server_pid)
118
- else:
119
- logger.info('stopping sigmund connector')
120
- self.sigmund_widget.stop_server()
121
- super().setVisible(visible)
@@ -0,0 +1,28 @@
1
+ from ...widgets import Dock
2
+ from ... import watchdog, settings
3
+ from .editor_workspace import EditorWorkspace
4
+ from .sigmund_analyst_widget import SigmundAnalystWidget
5
+ import logging
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class Sigmund(Dock):
10
+ def __init__(self, parent, editor_panel):
11
+ super().__init__('Sigmund', parent)
12
+ self.setObjectName("sigmund")
13
+ workspace = EditorWorkspace(editor_panel)
14
+ self.sigmund_widget = SigmundAnalystWidget(
15
+ self, editor_panel)
16
+ self.sigmund_widget.setStyleSheet(f'font-size: {settings.font_size}pt')
17
+ self.sigmund_widget.set_workspace_manager(workspace)
18
+ self.setWidget(self.sigmund_widget)
19
+
20
+ def setVisible(self, visible):
21
+ if visible:
22
+ logger.info('starting sigmund connector')
23
+ self.sigmund_widget.start_server()
24
+ watchdog.register_subprocess(self.sigmund_widget.server_pid)
25
+ else:
26
+ logger.info('stopping sigmund connector')
27
+ self.sigmund_widget.stop_server()
28
+ super().setVisible(visible)
@@ -0,0 +1,35 @@
1
+ from qtpy.QtWidgets import QCheckBox, QWidget, QVBoxLayout
2
+ from sigmund_qtwidget.chat_widget import ChatWidget
3
+ from ... import settings
4
+
5
+
6
+ class SigmundAnalystChatWidget(ChatWidget):
7
+ """Extended chat widget with Sigmund Analyst-specific options"""
8
+
9
+ def __init__(self, parent=None):
10
+ super().__init__(parent)
11
+ # Create a container for the checkboxes
12
+ options_container = QWidget()
13
+ options_layout = QVBoxLayout(options_container)
14
+ options_layout.setContentsMargins(5, 5, 5, 5)
15
+ options_layout.setSpacing(2)
16
+ # "Review proposed changes" checkbox
17
+ self._review_actions_checkbox = QCheckBox(
18
+ "Review Sigmund's actions (recommended)")
19
+ self._review_actions_checkbox.setChecked(
20
+ settings.sigmund_review_actions)
21
+ self._review_actions_checkbox.stateChanged.connect(
22
+ self._on_review_actions_changed)
23
+ options_layout.addWidget(self._review_actions_checkbox)
24
+ # Insert the options container before the input container
25
+ main_layout = self.layout()
26
+ main_layout.insertWidget(main_layout.count(), options_container)
27
+
28
+ def _on_review_actions_changed(self, state):
29
+ """Store the review changes setting."""
30
+ settings.sigmund_review_actions = bool(state)
31
+
32
+ def append_message(self, msg_type, text, scroll=True):
33
+ if msg_type == 'ai' and '(Suggesting IDE action)' in text:
34
+ text = '⚙️ Sigmund is working …'
35
+ self._chat_browser.append_message(msg_type, text, scroll)
@@ -0,0 +1,191 @@
1
+ import os
2
+ import time
3
+ from sigmund_qtwidget.sigmund_widget import SigmundWidget
4
+ from qtpy.QtCore import QEventLoop, QTimer
5
+ from qtpy.QtWidgets import QMessageBox, QDialog
6
+ from .sigmund_analyst_chat_widget import SigmundAnalystChatWidget
7
+ from .confirm_run_code_dialog import ConfirmRunCodeDialog
8
+ from ... import settings
9
+ import logging
10
+ logger = logging.getLogger(__name__)
11
+
12
+ ACTION_CANCELLED = 'I do not approve this action.'
13
+
14
+
15
+ class SigmundAnalystWidget(SigmundWidget):
16
+ """Extends the default Sigmund widget with Sigmund Analyst-specific
17
+ functionality.
18
+ """
19
+ chat_widget_cls = SigmundAnalystChatWidget
20
+
21
+ def __init__(self, parent, editor_panel):
22
+ super().__init__(parent, application='Sigmund Analyst')
23
+ try:
24
+ self._app = parent.parent()
25
+ self._jupyter_console = self._app._jupyter_console
26
+ # Connect the code_executed signal to a slot
27
+ except AttributeError:
28
+ logger.warning('No Jupyter console found.')
29
+ self._jupyter_console = None
30
+ else:
31
+ self._jupyter_console.execution_complete.connect(
32
+ self._handle_execution_result)
33
+ self._editor_panel = editor_panel
34
+ self._transient_settings = {
35
+ 'tool_ide_open_file': 'true',
36
+ 'tool_ide_execute_code': 'true'
37
+ }
38
+ # Store execution results
39
+ self._execution_result = None
40
+ self._execution_loop = None
41
+
42
+ def _confirm_action(self, action_type, details):
43
+ """Show a confirmation dialog for the proposed action.
44
+
45
+ Args:
46
+ action_type (str): Type of action (e.g., "open file", "execute code")
47
+ details (str): Detailed information about the action
48
+
49
+ Returns:
50
+ bool: True if action is approved, False otherwise
51
+ """
52
+ if not settings.sigmund_review_actions:
53
+ return True
54
+
55
+ if action_type == "open file":
56
+ return QMessageBox.question(
57
+ self,
58
+ "Open file",
59
+ f"Sigmund wants to open: \n\n{details}",
60
+ QMessageBox.Ok | QMessageBox.Cancel,
61
+ QMessageBox.Ok
62
+ ) == QMessageBox.Ok
63
+ dialog = ConfirmRunCodeDialog(
64
+ self,
65
+ action_type,
66
+ details
67
+ )
68
+ return dialog.exec_() == QDialog.Accepted
69
+
70
+ def _handle_execution_result(self, output, result):
71
+ """Slot that handles the code_executed signal."""
72
+ self._execution_result = (output, result)
73
+ if self._execution_loop and self._execution_loop.isRunning():
74
+ time.sleep(0.5) # Wait for errors to appear
75
+ self._execution_loop.quit()
76
+
77
+ def run_command_open_file(self, path):
78
+ if settings.sigmund_review_actions:
79
+ if not QMessageBox.question(self,
80
+ "Confirm open file",
81
+ f"Sigmund wants to open: \n\n{path}",
82
+ QMessageBox.Ok | QMessageBox.Cancel,
83
+ QMessageBox.Ok) == QMessageBox.Ok:
84
+ return ACTION_CANCELLED
85
+ try:
86
+ self._editor_panel.open_file(path)
87
+ except Exception as e:
88
+ return f'Failed to open file: {e}'
89
+ return f'Opened file: {path}'
90
+
91
+ def run_command_execute_code(self, code, language):
92
+ if settings.sigmund_review_actions:
93
+ if not ConfirmRunCodeDialog(self, code, language).exec() \
94
+ == ConfirmRunCodeDialog.Accepted:
95
+ return ACTION_CANCELLED
96
+ if self._jupyter_console is None:
97
+ return 'Code execution not supported.'
98
+ self._app._toggle_dock_widget(self._jupyter_console, show=True)
99
+ self._execution_result = None
100
+ self._execution_loop = QEventLoop()
101
+ QTimer.singleShot(30000, self._execution_loop.quit) # 30 second timeout
102
+ self._jupyter_console.execute_code(code)
103
+ self._execution_loop.exec_()
104
+ self._execution_loop = None
105
+ if self._execution_result is None:
106
+ return 'Code execution timed out or failed to return a result.'
107
+ try:
108
+ console_content = self._jupyter_console.get_current_console().jupyter_widget._control.toPlainText()
109
+ except Exception as e:
110
+ return f'Failed to get results from console content: {e}'
111
+ console_content = ''
112
+ console_content = '\n'.join(console_content.splitlines()[-100:])
113
+ return f'# Console output (may be truncated):\n\n{console_content}'
114
+ @property
115
+ def _editor(self):
116
+ return self._editor_panel.active_editor()
117
+
118
+ def send_user_message(self, text, *args, **kwargs):
119
+ current_path = self._editor.code_editor_file_path
120
+ # If the editor is not linked to a file, simply use the working
121
+ # directory.
122
+ if current_path is None:
123
+ current_path = '[unsaved file]'
124
+ working_directory = os.getcwd()
125
+ else:
126
+ working_directory = os.path.dirname(current_path)
127
+
128
+ # Initialize with default value
129
+ working_directory_contents = "(No directory contents available)"
130
+
131
+ # Get directory contents with priority to top-level items
132
+ top_level = []
133
+ all_items = []
134
+
135
+ # Only wrap file system operations in try-except
136
+ try:
137
+ # Get top-level items (depth=1)
138
+ top_level = [f for f in os.listdir(working_directory)
139
+ if os.path.isfile(os.path.join(working_directory, f))
140
+ or os.path.isdir(os.path.join(working_directory, f))]
141
+
142
+ # Then get deeper items if we haven't reached our limit
143
+ remaining_slots = max(0, 20 - len(top_level))
144
+ if remaining_slots > 0:
145
+ for root, dirs, files in os.walk(working_directory):
146
+ # Skip the top level since we already have it
147
+ if root == working_directory:
148
+ continue
149
+ # Add files first
150
+ for file in files:
151
+ if remaining_slots <= 0:
152
+ break
153
+ rel_path = os.path.relpath(os.path.join(root, file), working_directory)
154
+ all_items.append(f".\\{rel_path}")
155
+ remaining_slots -= 1
156
+ # Then add directories if we still have space
157
+ for dir in dirs:
158
+ if remaining_slots <= 0:
159
+ break
160
+ rel_path = os.path.relpath(os.path.join(root, dir), working_directory)
161
+ all_items.append(f".\\{rel_path}\\")
162
+ remaining_slots -= 1
163
+
164
+ # Combine top level and deeper items
165
+ working_directory_contents = "\n".join(
166
+ sorted([f".\\{item}" if os.path.isfile(os.path.join(working_directory, item))
167
+ else f".\\{item}\\" for item in top_level] +
168
+ all_items)
169
+ )
170
+
171
+ if len(top_level) + len(all_items) > 20:
172
+ working_directory_contents += "\n... (additional files truncated)"
173
+
174
+ except PermissionError:
175
+ working_directory_contents = "(Could not read some directory contents - permission denied)"
176
+ except OSError as e:
177
+ working_directory_contents = f"(Could not read directory contents: {str(e)})"
178
+
179
+ system_prompt = f'''## Working directory
180
+
181
+ The workspace corresponds to the following file: {current_path}
182
+ The working directory is: {working_directory}
183
+
184
+ Overview of working directory:
185
+
186
+ ```
187
+ {working_directory_contents}
188
+ ```
189
+ '''
190
+ self._transient_system_prompt = system_prompt
191
+ super().send_user_message(text, *args, **kwargs)
@@ -88,7 +88,6 @@ class AutoPair:
88
88
  if typed_char and len(typed_char) == 1:
89
89
  for pair in self.PAIRS:
90
90
  if typed_char == pair["close_seq"]:
91
- print(typed_char)
92
91
  new_cursor = self.textCursor()
93
92
  new_pos = new_cursor.position()
94
93
  new_doc_text = self.toPlainText()
@@ -106,7 +106,9 @@ class Base:
106
106
  if the worker died or the result queue is empty, keep polling.
107
107
  """
108
108
  to_remove = []
109
- for pid, queue in self._active_requests.items():
109
+ # Create a list of active requests to avoid the dictionary being
110
+ # changed during iteration
111
+ for pid, queue in list(self._active_requests.items()):
110
112
  # 1) Check if the worker is still alive
111
113
  if not manager.check_worker_alive(pid):
112
114
  logger.warning(f"Worker process {pid} no longer alive. Removing from active requests.")
@@ -143,7 +145,7 @@ class Base:
143
145
  # Cleanup: remove completed or dead requests. Also stop unused workers.
144
146
  # This function periodically prunes worker processes if they are unused.
145
147
  for pid in to_remove:
146
- self._active_requests.pop(pid, None)
148
+ self._active_requests.pop(pid, None)
147
149
  manager.stop_unused_workers()
148
150
 
149
151
  def set_modified(self, modified):
@@ -44,6 +44,7 @@ class Comment:
44
44
  def _toggle_comment(self):
45
45
  """Toggle comment on the current selection or current line."""
46
46
  cursor = self.textCursor()
47
+ cursor.beginEditBlock()
47
48
  if not cursor.hasSelection():
48
49
  # If no selection, just operate on the current line
49
50
  cursor.select(cursor.LineUnderCursor)
@@ -70,6 +71,7 @@ class Comment:
70
71
  self._uncomment_blocks(startBlock, endBlock)
71
72
  else:
72
73
  self._comment_blocks(startBlock, endBlock)
74
+ cursor.endEditBlock()
73
75
 
74
76
  def _comment_blocks(self, start_block: int, end_block: int):
75
77
  """Comment all lines from start_block to end_block."""
@@ -84,6 +86,7 @@ class Comment:
84
86
  insert_position = block.position() + leading_spaces
85
87
  cursor.setPosition(insert_position)
86
88
  cursor.insertText(self.code_editor_comment_string)
89
+ self.setTextCursor(cursor)
87
90
 
88
91
  def _uncomment_blocks(self, start_block: int, end_block: int):
89
92
  """Uncomment all lines from start_block to end_block."""
@@ -102,3 +105,4 @@ class Comment:
102
105
  cursor.setPosition(remove_position + strip_len, cursor.KeepAnchor)
103
106
  if cursor.selectedText() == self.code_editor_comment_string.rstrip('\r\n'):
104
107
  cursor.removeSelectedText()
108
+ self.setTextCursor(cursor)
@@ -20,11 +20,6 @@ class MergeUndoActions:
20
20
  key = event.key()
21
21
  modifiers = event.modifiers()
22
22
 
23
- is_navigation = (
24
- key in navigation_keys
25
- and not (modifiers & Qt.KeyboardModifier.ShiftModifier)
26
- )
27
-
28
23
  # Treat platform "command" modifier as well (Control on Windows/Linux, Meta on macOS)
29
24
  ctrl_or_meta = Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier
30
25
  has_ctrl_or_meta = bool(modifiers & ctrl_or_meta)
@@ -39,7 +34,7 @@ class MergeUndoActions:
39
34
  key == Qt.Key.Key_Y
40
35
  )
41
36
 
42
- if is_navigation or is_undo or is_redo:
37
+ if key in navigation_keys or is_undo or is_redo:
43
38
  # Do not wrap these in an edit block
44
39
  super().keyPressEvent(event)
45
40
  return
@@ -8,10 +8,9 @@ from qtpy.QtCore import Qt
8
8
  from .. import settings
9
9
  from ..widgets.search_replace_frame import SearchReplaceFrame
10
10
 
11
-
12
11
  class SearchReplaceHighlighter(QSyntaxHighlighter):
13
12
  """
14
- Simple syntax highlighter that highlights all matches of a given pattern.
13
+ Syntax highlighter that highlights all matches of a given pattern.
15
14
  Updated whenever the pattern or text changes.
16
15
  """
17
16
  def __init__(self, parent=None):
@@ -20,46 +19,87 @@ class SearchReplaceHighlighter(QSyntaxHighlighter):
20
19
  self._use_regex = False
21
20
  self._case_sensitive = False
22
21
  self._whole_word = False
23
-
22
+ self._matches = [] # Stores all matches in the document as (block_number, start, length)
23
+
24
24
  # Highlight style
25
25
  self.highlight_format = QTextCharFormat()
26
26
  self.highlight_format.setBackground(QColor(settings.search_replace_background))
27
27
  self.highlight_format.setForeground(QColor(settings.search_replace_foreground))
28
-
28
+
29
29
  def setSearchOptions(self, pattern, use_regex, case_sensitive, whole_word):
30
30
  self._pattern = pattern
31
31
  self._use_regex = use_regex
32
32
  self._case_sensitive = case_sensitive
33
33
  self._whole_word = whole_word
34
34
  self.rehighlight() # Trigger a re-scan
35
-
36
- def highlightBlock(self, text):
37
- if not self._pattern:
35
+
36
+ def rehighlight(self):
37
+ """Find all matches in the entire document and store them"""
38
+ if not self._pattern or not self.document():
39
+ self._matches = []
40
+ super().rehighlight()
38
41
  return
39
-
42
+
40
43
  # Build the pattern
41
44
  pattern = self._pattern
42
45
  flags = 0 if self._case_sensitive else re.IGNORECASE
43
-
46
+
44
47
  if self._use_regex:
45
- # If whole_word is asked for, add \b on both sides (can be tricky with punctuation).
46
48
  if self._whole_word:
47
49
  pattern = rf"\b{pattern}\b"
48
50
  try:
49
51
  regex = re.compile(pattern, flags)
50
52
  except re.error:
51
- return # invalid regex, just skip
53
+ self._matches = []
54
+ super().rehighlight()
55
+ return
52
56
  else:
53
- # Escape user text if not in regex mode
54
57
  pattern = re.escape(pattern)
55
58
  if self._whole_word:
56
59
  pattern = rf"\b{pattern}\b"
57
60
  regex = re.compile(pattern, flags)
58
-
59
- for match in regex.finditer(text):
61
+
62
+ # Find all matches in the entire document
63
+ self._matches = []
64
+ full_text = self.document().toPlainText()
65
+ for match in regex.finditer(full_text):
60
66
  start = match.start()
61
- length = match.end() - match.start()
62
- self.setFormat(start, length, self.highlight_format)
67
+ end = match.end()
68
+
69
+ # Convert character positions to block/position
70
+ block = self.document().findBlock(start)
71
+ block_number = block.blockNumber()
72
+ block_start = block.position()
73
+ block_end = block_start + block.length() - 1
74
+
75
+ # Handle matches that span multiple blocks
76
+ while start <= block_end and end > block_start:
77
+ match_start_in_block = max(start - block_start, 0)
78
+ match_end_in_block = min(end - block_start, block.length() - 1)
79
+ match_length = match_end_in_block - match_start_in_block
80
+
81
+ if match_length > 0:
82
+ self._matches.append((block_number, match_start_in_block, match_length))
83
+
84
+ # Move to next block if match spans multiple blocks
85
+ if end > block_end:
86
+ block = block.next()
87
+ if not block.isValid():
88
+ break
89
+ block_number = block.blockNumber()
90
+ block_start = block.position()
91
+ block_end = block_start + block.length() - 1
92
+ else:
93
+ break
94
+
95
+ super().rehighlight()
96
+
97
+ def highlightBlock(self, text):
98
+ """Highlight matches that belong to this block"""
99
+ block_number = self.currentBlock().blockNumber()
100
+ for match_block, start, length in self._matches:
101
+ if match_block == block_number:
102
+ self.setFormat(start, length, self.highlight_format)
63
103
 
64
104
 
65
105
  class SearchReplace:
@@ -325,13 +365,14 @@ class SearchReplace:
325
365
  needle = self._searchFrame.findEdit.text()
326
366
  if not needle:
327
367
  return
328
-
368
+
329
369
  replacement = self._searchFrame.replaceEdit.text()
330
-
331
- # Save cursor
370
+
371
+ # Save cursor and get current text
332
372
  saved_cursor = self.textCursor()
373
+ cursor_pos = saved_cursor.position()
333
374
  text = self.toPlainText()
334
-
375
+
335
376
  # Build regex pattern
336
377
  flags = 0 if self._searchFrame.caseBox.isChecked() else re.IGNORECASE
337
378
  patt = needle
@@ -339,20 +380,34 @@ class SearchReplace:
339
380
  patt = re.escape(patt)
340
381
  if self._searchFrame.wholeWordBox.isChecked():
341
382
  patt = rf"\b{patt}\b"
342
-
383
+
343
384
  try:
344
385
  compiled = re.compile(patt, flags)
345
386
  except re.error:
346
387
  return
347
-
388
+
389
+ # Get text before cursor
390
+ text_before = text[:cursor_pos]
391
+
392
+ # Apply replacement to full text
348
393
  new_text, num_replacements = compiled.subn(replacement, text)
349
- self.setPlainText(new_text)
350
-
351
- # After replace all, set the needle to the replacement text
394
+
395
+ # Apply replacement to just the before portion to calculate position change
396
+ new_text_before, _ = compiled.subn(replacement, text_before)
397
+ length_change = len(new_text_before) - len(text_before)
398
+ new_cursor_pos = cursor_pos + length_change
399
+
400
+ # Use a single undoable operation
401
+ cursor = self.textCursor()
402
+ cursor.beginEditBlock()
403
+ cursor.select(QTextCursor.Document)
404
+ cursor.insertText(new_text)
405
+ cursor.setPosition(new_cursor_pos)
406
+ cursor.endEditBlock()
407
+ self.setTextCursor(cursor)
408
+
409
+ # Update UI
352
410
  self._searchFrame.findEdit.setText(replacement)
353
-
354
- # Restore cursor
355
- self.setTextCursor(saved_cursor)
356
411
  self.updateHighlighter()
357
412
  self.set_modified(True)
358
413
 
@@ -1,6 +1,6 @@
1
1
  # PyQt Code Editor / Sigmund Analyst
2
2
 
3
- Copyright 2025 Sebastiaan Mathôt
3
+ Copyright 2025-2026 Sebastiaan Mathôt
4
4
 
5
5
 
6
6
  ## About