pygpt-net 2.6.22__py3-none-any.whl → 2.6.23__py3-none-any.whl

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 (38) hide show
  1. pygpt_net/CHANGELOG.txt +8 -0
  2. pygpt_net/__init__.py +1 -1
  3. pygpt_net/controller/agent/llama.py +3 -0
  4. pygpt_net/controller/chat/response.py +4 -0
  5. pygpt_net/controller/files/files.py +24 -55
  6. pygpt_net/controller/theme/theme.py +3 -3
  7. pygpt_net/core/agents/observer/evaluation.py +2 -2
  8. pygpt_net/core/agents/runners/loop.py +1 -0
  9. pygpt_net/core/bridge/bridge.py +2 -0
  10. pygpt_net/core/filesystem/opener.py +261 -0
  11. pygpt_net/core/filesystem/url.py +13 -10
  12. pygpt_net/core/platforms/platforms.py +5 -4
  13. pygpt_net/data/config/config.json +2 -2
  14. pygpt_net/data/config/models.json +2 -2
  15. pygpt_net/data/css/web-blocks.dark.css +7 -1
  16. pygpt_net/data/css/web-blocks.light.css +5 -2
  17. pygpt_net/data/css/web-chatgpt.dark.css +7 -1
  18. pygpt_net/data/css/web-chatgpt.light.css +3 -0
  19. pygpt_net/data/css/web-chatgpt_wide.dark.css +7 -1
  20. pygpt_net/data/css/web-chatgpt_wide.light.css +3 -0
  21. pygpt_net/data/locale/locale.de.ini +1 -0
  22. pygpt_net/data/locale/locale.en.ini +1 -0
  23. pygpt_net/data/locale/locale.es.ini +1 -0
  24. pygpt_net/data/locale/locale.fr.ini +1 -0
  25. pygpt_net/data/locale/locale.it.ini +1 -0
  26. pygpt_net/data/locale/locale.pl.ini +1 -0
  27. pygpt_net/data/locale/locale.uk.ini +1 -0
  28. pygpt_net/data/locale/locale.zh.ini +1 -0
  29. pygpt_net/provider/core/config/patch.py +12 -1
  30. pygpt_net/ui/layout/toolbox/agent_llama.py +2 -3
  31. pygpt_net/ui/widget/tabs/layout.py +6 -4
  32. pygpt_net/ui/widget/tabs/output.py +348 -13
  33. pygpt_net/ui/widget/textarea/input.py +74 -8
  34. {pygpt_net-2.6.22.dist-info → pygpt_net-2.6.23.dist-info}/METADATA +25 -25
  35. {pygpt_net-2.6.22.dist-info → pygpt_net-2.6.23.dist-info}/RECORD +38 -37
  36. {pygpt_net-2.6.22.dist-info → pygpt_net-2.6.23.dist-info}/LICENSE +0 -0
  37. {pygpt_net-2.6.22.dist-info → pygpt_net-2.6.23.dist-info}/WHEEL +0 -0
  38. {pygpt_net-2.6.22.dist-info → pygpt_net-2.6.23.dist-info}/entry_points.txt +0 -0
@@ -6,11 +6,11 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.08.25 18:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6.QtWidgets import QTabWidget, QMenu, QPushButton
13
- from PySide6.QtCore import Qt, Slot
12
+ from PySide6.QtWidgets import QTabWidget, QMenu, QPushButton, QToolButton, QTabBar
13
+ from PySide6.QtCore import Qt, Slot, QTimer, QEvent
14
14
  from PySide6.QtGui import QAction, QIcon, QGuiApplication
15
15
 
16
16
  from pygpt_net.core.tabs.tab import Tab
@@ -18,6 +18,7 @@ from pygpt_net.utils import trans
18
18
 
19
19
  _ICON_CACHE = {}
20
20
 
21
+
21
22
  def icon(path: str) -> QIcon:
22
23
  if QGuiApplication.instance() is None:
23
24
  return QIcon()
@@ -27,6 +28,7 @@ def icon(path: str) -> QIcon:
27
28
  _ICON_CACHE[path] = cached
28
29
  return cached
29
30
 
31
+
30
32
  ICON_PATH_ADD = ':/icons/add.svg'
31
33
  ICON_PATH_EDIT = ':/icons/edit.svg'
32
34
  ICON_PATH_CLOSE = ':/icons/close.svg'
@@ -35,6 +37,267 @@ ICON_PATH_FORWARD = ':/icons/forward'
35
37
  ICON_PATH_BACK = ':/icons/back'
36
38
 
37
39
 
40
+ class OutputTabBar(QTabBar):
41
+ def __init__(
42
+ self,
43
+ window=None,
44
+ column=None,
45
+ tabs=None,
46
+ corner_button=None,
47
+ parent=None
48
+ ):
49
+ super().__init__(parent)
50
+ self.window = window
51
+ self.column = column
52
+ self.tabs = tabs
53
+ self.corner_button = corner_button
54
+
55
+ # inline [+] just after the last tab (only when there is real free space)
56
+ self.add_btn_inline = AddButton(window, column, tabs)
57
+ self.add_btn_inline.setParent(self)
58
+ self.add_btn_inline.setVisible(False)
59
+ self.add_btn_inline.raise_()
60
+
61
+ # visual gap between last tab and [+]
62
+ self._spacing = 3
63
+
64
+ # add button vertical offset (to align with text)
65
+ self._inline_y_offset = 2 # px up
66
+
67
+ # keep tabs natural width (do not stretch)
68
+ self.setExpanding(False)
69
+
70
+ # allow scroll buttons when needed
71
+ self.setUsesScrollButtons(True)
72
+
73
+ # ensure the tab bar stays visible even with 0 tabs
74
+ self._min_bar_height = max(self.add_btn_inline.sizeHint().height() + 6, 28)
75
+ self.setMinimumHeight(self._min_bar_height)
76
+
77
+ if hasattr(self.tabs, "setTabBarAutoHide"):
78
+ self.tabs.setTabBarAutoHide(False)
79
+
80
+ # state
81
+ self._inline_mode = False
82
+ self._corner_current = None # track where the corner [+] currently is
83
+ self._last_inline_pos = None # track last inline position to avoid useless moves
84
+
85
+ # coalesced updates (debounce to 1 per event-loop turn)
86
+ self._update_timer = QTimer(self)
87
+ self._update_timer.setSingleShot(True)
88
+ self._update_timer.timeout.connect(self.updateAddButtonPlacement)
89
+
90
+ # re-layout updates
91
+ self.currentChanged.connect(lambda _: self._queue_update())
92
+ self.tabMoved.connect(lambda _from, _to: self._queue_update())
93
+
94
+ # initial placement
95
+ QTimer.singleShot(0, self.updateAddButtonPlacement)
96
+
97
+ def sizeHint(self):
98
+ """
99
+ Override sizeHint to
100
+
101
+ :return: QSize
102
+ """
103
+ # keep a non-zero height even with 0 tabs
104
+ sh = super().sizeHint()
105
+ sh.setHeight(max(sh.height(), self._min_bar_height))
106
+ return sh
107
+
108
+ def minimumSizeHint(self):
109
+ """
110
+ Override minimumSizeHint
111
+
112
+ :return: QSize
113
+ """
114
+ m = super().minimumSizeHint()
115
+ m.setHeight(max(m.height(), self._min_bar_height))
116
+ return m
117
+
118
+ def _queue_update(self):
119
+ """Queue an update to recompute [+] placement (debounced)."""
120
+ # Coalesce many triggers into a single call at the end of the event loop.
121
+ self._update_timer.start(0)
122
+
123
+ def _visible_scroll_buttons(self):
124
+ """
125
+ Find the left and right scroll buttons if they are visible.
126
+
127
+ :return: (left_button, right_button) or (None, None) if not found
128
+ """
129
+ # find the scroll arrow buttons created by QTabBar
130
+ left = right = None
131
+ for btn in self.findChildren(QToolButton):
132
+ if not btn.isVisible():
133
+ continue
134
+ if not btn.autoRepeat():
135
+ continue
136
+ if left is None or btn.x() < left.x():
137
+ left = btn
138
+ if right is None or btn.x() > right.x():
139
+ right = btn
140
+ return left, right
141
+
142
+ def _set_corner_target(self, corner: Qt.Corner | None) -> bool:
143
+ """
144
+ Move the corner_button to a given corner (or detach it) only when it changes.
145
+
146
+ :param corner: target corner or None to detach
147
+ :return: True if changed, False if no change was needed
148
+ """
149
+ if self.corner_button is None:
150
+ return False
151
+
152
+ if self._corner_current == corner:
153
+ return False # nothing to do
154
+
155
+ # detach only from the previously used corner
156
+ if self._corner_current is not None:
157
+ self.tabs.setCornerWidget(None, self._corner_current)
158
+
159
+ if corner is not None:
160
+ self.tabs.setCornerWidget(self.corner_button, corner)
161
+
162
+ self._corner_current = corner
163
+ return True
164
+
165
+ def _column_index(self) -> int:
166
+ """
167
+ Get the column index this tab bar belongs to (0 or 1).
168
+
169
+ :return: Column index
170
+ """
171
+ idx = 0
172
+ if self.column is None:
173
+ return idx
174
+ return int(self.column.get_idx())
175
+
176
+ def updateAddButtonPlacement(self):
177
+ """
178
+ Recompute where the [+] button should be placed (inline or corner).
179
+
180
+ This method is called automatically on relevant events.
181
+ 1. If there are no tabs, show [+] in the left or right corner based on column index.
182
+ 2. If there are tabs but scroll buttons are visible (overflow), show [+] in the top-right corner.
183
+ 3. If there are tabs and no scroll buttons, show [+] inline after the last tab if there's room.
184
+ 4. Otherwise, show [+] in the top-right corner.
185
+ """
186
+ # CASE 1: no tabs -> show [+] in left or right corner based on column index
187
+ if self.count() == 0:
188
+ idx = self._column_index()
189
+ corner = Qt.TopLeftCorner if idx == 0 else Qt.TopRightCorner
190
+
191
+ changed = False
192
+ changed |= self._set_corner_target(corner)
193
+
194
+ if self._inline_mode:
195
+ self._inline_mode = False
196
+ changed = True
197
+
198
+ if self.add_btn_inline.isVisible():
199
+ self.add_btn_inline.setVisible(False)
200
+ changed = True
201
+
202
+ if self.corner_button is not None and not self.corner_button.isVisible():
203
+ self.corner_button.setVisible(True)
204
+ changed = True
205
+ return
206
+
207
+ # CASE 2: tabs exist
208
+ # if scroll buttons are visible we are in overflow -> use top-right corner [+]
209
+ left_sb, right_sb = self._visible_scroll_buttons()
210
+ if left_sb or right_sb:
211
+ changed = False
212
+ if self._inline_mode:
213
+ self._inline_mode = False
214
+ changed = True
215
+ if self.add_btn_inline.isVisible():
216
+ self.add_btn_inline.setVisible(False)
217
+ changed = True
218
+ changed |= self._set_corner_target(Qt.TopRightCorner)
219
+ if self.corner_button is not None and not self.corner_button.isVisible():
220
+ self.corner_button.setVisible(True)
221
+ changed = True
222
+ return
223
+
224
+ # otherwise, put [+] inline (right after the last tab) if there's real room
225
+ last_rect = self.tabRect(self.count() - 1)
226
+ x = last_rect.right() + 1 + self._spacing
227
+ want_inline = (x + self.add_btn_inline.width()) <= (self.width() - 1)
228
+
229
+ if want_inline:
230
+ changed = False
231
+
232
+ if not self._inline_mode:
233
+ self._inline_mode = True
234
+ changed = True
235
+
236
+ # hide any corner [+]
237
+ if self.corner_button is not None and self.corner_button.isVisible():
238
+ self.corner_button.setVisible(False)
239
+ changed = True
240
+ changed |= self._set_corner_target(None)
241
+
242
+ # compute position
243
+ y = (self.height() - self.add_btn_inline.height()) // 2
244
+ # NOTE: lift inline [+] slightly to align with tabs
245
+ y = max(0, y - self._inline_y_offset) # clamp to avoid negative
246
+ x = min(x, self.width() - self.add_btn_inline.width() - 1) # clamp inside the bar
247
+ new_pos = (x, y)
248
+ if self._last_inline_pos != new_pos:
249
+ self.add_btn_inline.move(x, y)
250
+ self._last_inline_pos = new_pos
251
+ changed = True
252
+
253
+ if not self.add_btn_inline.isVisible():
254
+ self.add_btn_inline.setVisible(True)
255
+ changed = True
256
+
257
+ self.add_btn_inline.raise_()
258
+ else:
259
+ # not enough room -> top-right corner
260
+ changed = False
261
+ if self._inline_mode:
262
+ self._inline_mode = False
263
+ changed = True
264
+ if self.add_btn_inline.isVisible():
265
+ self.add_btn_inline.setVisible(False)
266
+ changed = True
267
+ changed |= self._set_corner_target(Qt.TopRightCorner)
268
+ if self.corner_button is not None and not self.corner_button.isVisible():
269
+ self.corner_button.setVisible(True)
270
+ changed = True
271
+
272
+ def resizeEvent(self, event):
273
+ """Resize event handler to recompute [+] placement."""
274
+ super().resizeEvent(event)
275
+ self._queue_update()
276
+
277
+ def showEvent(self, event):
278
+ """Show event handler to recompute [+] placement."""
279
+ super().showEvent(event)
280
+ self._queue_update()
281
+
282
+ def tabInserted(self, index):
283
+ """Tab inserted event handler to recompute [+] placement."""
284
+ super().tabInserted(index)
285
+ self._queue_update()
286
+
287
+ def tabRemoved(self, index):
288
+ """Tab removed event handler to recompute [+] placement."""
289
+ super().tabRemoved(index)
290
+ self._queue_update()
291
+
292
+ def event(self, e):
293
+ """Event handler to catch layout/style changes that may affect [+] placement."""
294
+ res = super().event(e)
295
+ # only queue updates
296
+ if e.type() in (QEvent.LayoutRequest, QEvent.StyleChange, QEvent.PolishRequest, QEvent.FontChange):
297
+ self._queue_update()
298
+ return res
299
+
300
+
38
301
  class AddButton(QPushButton):
39
302
  def __init__(self, window=None, column=None, tabs=None):
40
303
  super(AddButton, self).__init__(icon(ICON_PATH_ADD), "", window)
@@ -100,6 +363,7 @@ class AddButton(QPushButton):
100
363
 
101
364
  return menu
102
365
 
366
+
103
367
  class OutputTabs(QTabWidget):
104
368
  def __init__(self, window=None, column=None):
105
369
  super(OutputTabs, self).__init__(window)
@@ -111,14 +375,6 @@ class OutputTabs(QTabWidget):
111
375
  self.setMovable(True)
112
376
  self.init()
113
377
 
114
- def set_active(self, active: bool):
115
- """Set the active state of the tab bar."""
116
- self.active = active
117
- if self.active:
118
- self.setStyleSheet("QTabBar::tab { border-bottom-width: 2px; }")
119
- else:
120
- self.setStyleSheet("QTabBar::tab { border-bottom-width: 0px; }")
121
-
122
378
  def init(self):
123
379
  """Initialize"""
124
380
  # create the [+] button
@@ -127,6 +383,38 @@ class OutputTabs(QTabWidget):
127
383
  # add the button to the top right corner of the tab bar
128
384
  self.setCornerWidget(add_button, corner=Qt.TopRightCorner)
129
385
 
386
+ self.setDocumentMode(True)
387
+
388
+ # use a custom tab bar that shows an inline [+] right after the tabs
389
+ tab_bar = OutputTabBar(
390
+ window=self.window,
391
+ column=self.column,
392
+ tabs=self,
393
+ corner_button=add_button,
394
+ parent=self,
395
+ )
396
+ self.setTabBar(tab_bar)
397
+ self.setMovable(True)
398
+ self.tabBar().setMovable(True)
399
+
400
+ self.setDocumentMode(True) # QT Material fix
401
+ self.tabBar().setDrawBase(False) # QT Material fix
402
+
403
+ # the custom tab bar decides when to show inline or corner [+]
404
+ add_button.setVisible(False)
405
+
406
+ # ensure initial recompute happens after the first layout pass
407
+ QTimer.singleShot(0, self._refresh_plus_button)
408
+
409
+ # tab bar visible even when empty
410
+ if hasattr(self, "setTabBarAutoHide"):
411
+ self.setTabBarAutoHide(False)
412
+
413
+ # IMPORTANT: reserve vertical space for the bar even with 0 tabs
414
+ # (prevents the whole widget from collapsing)
415
+ mh = max(self.tabBar().minimumSizeHint().height() + 2, 30) # +2
416
+ self.setMinimumHeight(mh)
417
+
130
418
  # connect signals
131
419
  self.currentChanged.connect(self._on_current_changed)
132
420
  self.tabBarClicked.connect(self._on_tabbar_clicked)
@@ -134,6 +422,47 @@ class OutputTabs(QTabWidget):
134
422
  self.tabCloseRequested.connect(self._on_tab_close_requested)
135
423
  self.tabBar().tabMoved.connect(self._on_tab_moved)
136
424
 
425
+ def set_active(self, active: bool):
426
+ """
427
+ Set the active state of the tab bar.
428
+
429
+ :param active: True to activate, False to deactivate
430
+ """
431
+ self.active = active
432
+
433
+ def _refresh_plus_button(self):
434
+ """Force the tab bar to recompute [+] placement after tab changes."""
435
+ tb = self.tabBar()
436
+ if hasattr(tb, "updateAddButtonPlacement"):
437
+ tb.updateAddButtonPlacement()
438
+
439
+ def addTab(self, *args, **kwargs):
440
+ """Add a new tab and refresh [+] placement."""
441
+ idx = super().addTab(*args, **kwargs)
442
+ QTimer.singleShot(0, self._refresh_plus_button) # defer until layout is done
443
+ return idx
444
+
445
+ def insertTab(self, *args, **kwargs):
446
+ """Insert a new tab at a specific index and refresh [+] placement."""
447
+ idx = super().insertTab(*args, **kwargs)
448
+ QTimer.singleShot(0, self._refresh_plus_button)
449
+ return idx
450
+
451
+ def removeTab(self, index):
452
+ """Remove a tab and refresh [+] placement."""
453
+ super().removeTab(index)
454
+ QTimer.singleShot(0, self._refresh_plus_button)
455
+
456
+ def setTabText(self, index: int, text: str):
457
+ """Set tab text and refresh [+] placement."""
458
+ super().setTabText(index, text)
459
+ QTimer.singleShot(0, self._refresh_plus_button)
460
+
461
+ def clear(self):
462
+ """Clear all tabs and refresh [+] placement."""
463
+ super().clear()
464
+ QTimer.singleShot(0, self._refresh_plus_button)
465
+
137
466
  def get_column(self):
138
467
  """
139
468
  Get column
@@ -170,7 +499,7 @@ class OutputTabs(QTabWidget):
170
499
  elif tab.type == Tab.TAB_TOOL:
171
500
  self.show_tool_menu(idx, column_idx, event.globalPos()) # tool
172
501
  else:
173
- self.show_default_menu(idx, column_idx, event.globalPos()) # default
502
+ self.show_default_menu(idx, column_idx, event.globalPos()) # default
174
503
  super(OutputTabs, self).mousePressEvent(event)
175
504
 
176
505
  def prepare_menu(self, index: int, column_idx: int) -> QMenu:
@@ -313,22 +642,28 @@ class OutputTabs(QTabWidget):
313
642
 
314
643
  @Slot(int)
315
644
  def _on_current_changed(self, _idx: int):
645
+ """On current tab changed"""
316
646
  self.window.controller.ui.tabs.on_tab_changed(self.currentIndex(), self.column.get_idx())
317
647
 
318
648
  @Slot(int)
319
649
  def _on_tabbar_clicked(self, _idx: int):
650
+ """On tab bar clicked"""
320
651
  self.window.controller.ui.tabs.on_tab_clicked(self.currentIndex(), self.column.get_idx())
321
652
 
322
653
  @Slot(int)
323
654
  def _on_tabbar_dbl_clicked(self, _idx: int):
655
+ """On tab bar double clicked"""
324
656
  self.window.controller.ui.tabs.on_tab_dbl_clicked(self.currentIndex(), self.column.get_idx())
325
657
 
326
658
  @Slot(int)
327
659
  def _on_tab_close_requested(self, _idx: int):
660
+ """On tab close requested"""
328
661
  self.window.controller.ui.tabs.on_tab_closed(self.currentIndex(), self.column.get_idx())
662
+ QTimer.singleShot(0, self._refresh_plus_button) # defer until layout is done
329
663
 
330
664
  @Slot(int, int)
331
665
  def _on_tab_moved(self, _from: int, _to: int):
666
+ """On tab moved"""
332
667
  self.window.controller.ui.tabs.on_tab_moved(self.currentIndex(), self.column.get_idx())
333
668
 
334
669
  @Slot()
@@ -375,7 +710,7 @@ class OutputTabs(QTabWidget):
375
710
  index = self.window.core.tabs.get_max_idx_by_column(column_idx)
376
711
  if index == -1:
377
712
  index = 0
378
-
713
+
379
714
  self.window.controller.ui.tabs.append(
380
715
  type=type,
381
716
  tool_id=tool_id,
@@ -6,13 +6,12 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.08.25 20:00:00 #
10
10
  # ================================================== #
11
11
 
12
- from PySide6 import QtCore
13
- from PySide6.QtCore import Qt
12
+ from PySide6.QtCore import Qt, QSize
14
13
  from PySide6.QtGui import QAction, QIcon, QImage
15
- from PySide6.QtWidgets import QTextEdit, QApplication
14
+ from PySide6.QtWidgets import QTextEdit, QApplication, QPushButton
16
15
 
17
16
  from pygpt_net.utils import trans
18
17
 
@@ -21,6 +20,7 @@ class ChatInput(QTextEdit):
21
20
  ICON_PASTE = QIcon(":/icons/paste.svg")
22
21
  ICON_VOLUME = QIcon(":/icons/volume.svg")
23
22
  ICON_SAVE = QIcon(":/icons/save.svg")
23
+ ICON_ATTACHMENT = QIcon(":/icons/add.svg")
24
24
 
25
25
  def __init__(self, window=None):
26
26
  """
@@ -35,9 +35,24 @@ class ChatInput(QTextEdit):
35
35
  self.value = self.window.core.config.data['font_size.input']
36
36
  self.max_font_size = 42
37
37
  self.min_font_size = 8
38
+ self._text_top_padding = 10
38
39
  self.textChanged.connect(self.window.controller.ui.update_tokens)
39
40
  self.setProperty('class', 'layout-input')
40
41
 
42
+ # Add a "+" button in the top-left corner to add attachments
43
+ self._init_attachment_button()
44
+ self._apply_text_top_padding()
45
+
46
+ def _apply_text_top_padding(self):
47
+ """Apply extra top padding inside the text area by using viewport margins."""
48
+ m = self.viewportMargins()
49
+ self.setViewportMargins(m.left(), self._text_top_padding, m.right(), m.bottom())
50
+
51
+ def set_text_top_padding(self, px: int):
52
+ """Public helper to adjust top padding at runtime."""
53
+ self._text_top_padding = max(0, int(px))
54
+ self._apply_text_top_padding()
55
+
41
56
  def insertFromMimeData(self, source):
42
57
  """
43
58
  Insert from mime data
@@ -136,16 +151,16 @@ class ChatInput(QTextEdit):
136
151
  """
137
152
  handled = False
138
153
  key = event.key()
139
- if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
154
+ if key in (Qt.Key_Return, Qt.Key_Enter):
140
155
  mode = self.window.core.config.get('send_mode')
141
156
  if mode > 0:
142
157
  modifiers = event.modifiers()
143
158
  if mode == 2:
144
- if modifiers == QtCore.Qt.ShiftModifier or modifiers == QtCore.Qt.ControlModifier:
159
+ if modifiers == Qt.ShiftModifier or modifiers == Qt.ControlModifier:
145
160
  self.window.controller.chat.input.send_input()
146
161
  handled = True
147
162
  else:
148
- if modifiers != QtCore.Qt.ShiftModifier and modifiers != QtCore.Qt.ControlModifier:
163
+ if modifiers != Qt.ShiftModifier and modifiers != Qt.ControlModifier:
149
164
  self.window.controller.chat.input.send_input()
150
165
  handled = True
151
166
  self.setFocus()
@@ -178,4 +193,55 @@ class ChatInput(QTextEdit):
178
193
  self.window.controller.ui.update_font_size()
179
194
  event.accept()
180
195
  return
181
- super().wheelEvent(event)
196
+ super().wheelEvent(event)
197
+
198
+ # --- Added: attachment button (top-left) ---------------------------------
199
+
200
+ def _init_attachment_button(self):
201
+ """Create and place the '+' attachment button pinned in the top-left corner."""
202
+ self._attach_margin = 6 # inner padding around the button
203
+ self._attach_offset_y = -6 # shift the button 2px up
204
+
205
+ self._attach_btn = QPushButton(self)
206
+ self._attach_btn.setObjectName("chatInputAttachBtn")
207
+ self._attach_btn.setIconSize(QSize(18, 18)) # icon size (slightly larger for visibility)
208
+ self._attach_btn.setFixedSize(24, 24) # full button size
209
+ self._attach_btn.setCursor(Qt.PointingHandCursor)
210
+ self._attach_btn.setToolTip(trans("attachments.btn.input.add"))
211
+ self._attach_btn.setFocusPolicy(Qt.NoFocus)
212
+ self._attach_btn.setFlat(True) # flat button style
213
+
214
+ self._attach_btn.setIcon(self.ICON_ATTACHMENT)
215
+ self._attach_btn.clicked.connect(self.action_add_attachment)
216
+ self._update_viewport_margins_for_attachment()
217
+ self._reposition_attachment_button()
218
+
219
+ def _update_viewport_margins_for_attachment(self):
220
+ """Reserve space for the attachment button on the left and apply top text padding."""
221
+ top = self._text_top_padding
222
+ left = self._attach_btn.width() + self._attach_margin * 2 if hasattr(self, "_attach_btn") else self.viewportMargins().left()
223
+ self.setViewportMargins(left, top, 0, 0)
224
+
225
+ def _reposition_attachment_button(self):
226
+ """Keep the attachment button pinned to the top-left corner."""
227
+ if hasattr(self, "_attach_btn"):
228
+ fw = self.frameWidth()
229
+ x = fw + self._attach_margin
230
+ y = fw + self._attach_margin + self._attach_offset_y # shift up by ~2px
231
+ if y < 0:
232
+ y = 0
233
+ self._attach_btn.move(x, y)
234
+ self._attach_btn.raise_()
235
+
236
+ def resizeEvent(self, event):
237
+ """Resize event keeps the attachment button in place."""
238
+ super().resizeEvent(event)
239
+ # Keep the attachment button pinned when resizing
240
+ try:
241
+ self._reposition_attachment_button()
242
+ except Exception:
243
+ pass
244
+
245
+ def action_add_attachment(self):
246
+ """Add attachment (button click)."""
247
+ self.window.controller.attachment.open_add()