pygpt-net 2.7.8__py3-none-any.whl → 2.7.10__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 (112) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/LICENSE +1 -1
  3. pygpt_net/__init__.py +3 -3
  4. pygpt_net/config.py +15 -1
  5. pygpt_net/controller/chat/common.py +5 -4
  6. pygpt_net/controller/chat/image.py +3 -3
  7. pygpt_net/controller/chat/stream.py +76 -41
  8. pygpt_net/controller/chat/stream_worker.py +3 -3
  9. pygpt_net/controller/ctx/extra.py +3 -1
  10. pygpt_net/controller/dialogs/debug.py +37 -8
  11. pygpt_net/controller/kernel/kernel.py +3 -7
  12. pygpt_net/controller/lang/custom.py +25 -12
  13. pygpt_net/controller/lang/lang.py +45 -3
  14. pygpt_net/controller/lang/mapping.py +15 -2
  15. pygpt_net/controller/notepad/notepad.py +68 -25
  16. pygpt_net/controller/presets/editor.py +5 -1
  17. pygpt_net/controller/presets/presets.py +17 -5
  18. pygpt_net/controller/realtime/realtime.py +13 -1
  19. pygpt_net/controller/theme/theme.py +11 -2
  20. pygpt_net/controller/ui/tabs.py +1 -1
  21. pygpt_net/core/ctx/output.py +38 -12
  22. pygpt_net/core/db/database.py +4 -2
  23. pygpt_net/core/debug/console/console.py +30 -2
  24. pygpt_net/core/debug/context.py +2 -1
  25. pygpt_net/core/debug/ui.py +26 -4
  26. pygpt_net/core/filesystem/filesystem.py +6 -2
  27. pygpt_net/core/notepad/notepad.py +2 -2
  28. pygpt_net/core/tabs/tabs.py +79 -19
  29. pygpt_net/data/config/config.json +4 -3
  30. pygpt_net/data/config/models.json +37 -22
  31. pygpt_net/data/config/settings.json +12 -0
  32. pygpt_net/data/locale/locale.ar.ini +1833 -0
  33. pygpt_net/data/locale/locale.bg.ini +1833 -0
  34. pygpt_net/data/locale/locale.cs.ini +1833 -0
  35. pygpt_net/data/locale/locale.da.ini +1833 -0
  36. pygpt_net/data/locale/locale.de.ini +4 -1
  37. pygpt_net/data/locale/locale.en.ini +70 -67
  38. pygpt_net/data/locale/locale.es.ini +4 -1
  39. pygpt_net/data/locale/locale.fi.ini +1833 -0
  40. pygpt_net/data/locale/locale.fr.ini +4 -1
  41. pygpt_net/data/locale/locale.he.ini +1833 -0
  42. pygpt_net/data/locale/locale.hi.ini +1833 -0
  43. pygpt_net/data/locale/locale.hu.ini +1833 -0
  44. pygpt_net/data/locale/locale.it.ini +4 -1
  45. pygpt_net/data/locale/locale.ja.ini +1833 -0
  46. pygpt_net/data/locale/locale.ko.ini +1833 -0
  47. pygpt_net/data/locale/locale.nl.ini +1833 -0
  48. pygpt_net/data/locale/locale.no.ini +1833 -0
  49. pygpt_net/data/locale/locale.pl.ini +5 -2
  50. pygpt_net/data/locale/locale.pt.ini +1833 -0
  51. pygpt_net/data/locale/locale.ro.ini +1833 -0
  52. pygpt_net/data/locale/locale.ru.ini +1833 -0
  53. pygpt_net/data/locale/locale.sk.ini +1833 -0
  54. pygpt_net/data/locale/locale.sv.ini +1833 -0
  55. pygpt_net/data/locale/locale.tr.ini +1833 -0
  56. pygpt_net/data/locale/locale.uk.ini +4 -1
  57. pygpt_net/data/locale/locale.zh.ini +4 -1
  58. pygpt_net/item/notepad.py +8 -2
  59. pygpt_net/migrations/Version20260121190000.py +25 -0
  60. pygpt_net/migrations/Version20260122140000.py +25 -0
  61. pygpt_net/migrations/__init__.py +5 -1
  62. pygpt_net/preload.py +246 -3
  63. pygpt_net/provider/api/__init__.py +16 -2
  64. pygpt_net/provider/api/anthropic/__init__.py +21 -7
  65. pygpt_net/provider/api/google/__init__.py +21 -7
  66. pygpt_net/provider/api/google/image.py +89 -2
  67. pygpt_net/provider/api/google/realtime/client.py +70 -24
  68. pygpt_net/provider/api/google/realtime/realtime.py +48 -12
  69. pygpt_net/provider/api/google/video.py +2 -2
  70. pygpt_net/provider/api/openai/__init__.py +26 -11
  71. pygpt_net/provider/api/openai/image.py +79 -3
  72. pygpt_net/provider/api/openai/realtime/realtime.py +26 -6
  73. pygpt_net/provider/api/openai/responses.py +11 -31
  74. pygpt_net/provider/api/openai/video.py +2 -2
  75. pygpt_net/provider/api/x_ai/__init__.py +21 -10
  76. pygpt_net/provider/api/x_ai/realtime/client.py +185 -146
  77. pygpt_net/provider/api/x_ai/realtime/realtime.py +30 -15
  78. pygpt_net/provider/api/x_ai/remote_tools.py +83 -0
  79. pygpt_net/provider/api/x_ai/tools.py +51 -0
  80. pygpt_net/provider/core/config/patch.py +12 -1
  81. pygpt_net/provider/core/model/patch.py +36 -1
  82. pygpt_net/provider/core/notepad/db_sqlite/storage.py +53 -10
  83. pygpt_net/tools/agent_builder/ui/dialogs.py +2 -1
  84. pygpt_net/tools/audio_transcriber/ui/dialogs.py +2 -1
  85. pygpt_net/tools/code_interpreter/ui/dialogs.py +2 -1
  86. pygpt_net/tools/html_canvas/ui/dialogs.py +2 -1
  87. pygpt_net/tools/image_viewer/ui/dialogs.py +3 -5
  88. pygpt_net/tools/indexer/ui/dialogs.py +2 -1
  89. pygpt_net/tools/media_player/ui/dialogs.py +2 -1
  90. pygpt_net/tools/translator/ui/dialogs.py +2 -1
  91. pygpt_net/tools/translator/ui/widgets.py +6 -2
  92. pygpt_net/ui/dialog/about.py +2 -2
  93. pygpt_net/ui/dialog/db.py +2 -1
  94. pygpt_net/ui/dialog/debug.py +169 -6
  95. pygpt_net/ui/dialog/logger.py +6 -2
  96. pygpt_net/ui/dialog/models.py +36 -3
  97. pygpt_net/ui/dialog/preset.py +5 -1
  98. pygpt_net/ui/dialog/remote_store.py +2 -1
  99. pygpt_net/ui/main.py +3 -2
  100. pygpt_net/ui/widget/dialog/editor_file.py +2 -1
  101. pygpt_net/ui/widget/lists/debug.py +12 -7
  102. pygpt_net/ui/widget/option/checkbox.py +2 -8
  103. pygpt_net/ui/widget/option/combo.py +10 -2
  104. pygpt_net/ui/widget/textarea/console.py +156 -7
  105. pygpt_net/ui/widget/textarea/highlight.py +66 -0
  106. pygpt_net/ui/widget/textarea/input.py +624 -57
  107. pygpt_net/ui/widget/textarea/notepad.py +294 -27
  108. {pygpt_net-2.7.8.dist-info → pygpt_net-2.7.10.dist-info}/LICENSE +1 -1
  109. {pygpt_net-2.7.8.dist-info → pygpt_net-2.7.10.dist-info}/METADATA +16 -64
  110. {pygpt_net-2.7.8.dist-info → pygpt_net-2.7.10.dist-info}/RECORD +112 -91
  111. {pygpt_net-2.7.8.dist-info → pygpt_net-2.7.10.dist-info}/WHEEL +0 -0
  112. {pygpt_net-2.7.8.dist-info → pygpt_net-2.7.10.dist-info}/entry_points.txt +0 -0
@@ -6,15 +6,15 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2026.01.03 00:00:00 #
9
+ # Updated Date: 2026.01.22 14:40:00 #
10
10
  # ================================================== #
11
11
 
12
- from typing import Optional
12
+ from typing import Optional, Union, Tuple
13
13
  import math
14
14
  import os
15
15
 
16
16
  from PySide6.QtCore import Qt, QSize, QTimer, QEvent
17
- from PySide6.QtGui import QAction, QIcon, QImage
17
+ from PySide6.QtGui import QAction, QIcon, QImage, QTextCursor
18
18
  from PySide6.QtWidgets import (
19
19
  QTextEdit,
20
20
  QApplication,
@@ -49,6 +49,7 @@ class ChatInput(QTextEdit):
49
49
  super().__init__(window)
50
50
  self.window = window
51
51
  self.setAcceptRichText(False)
52
+ self.setPlaceholderText(trans("input.placeholder"))
52
53
  self.setFocus()
53
54
  self.value = self.window.core.config.data['font_size.input']
54
55
  self.max_font_size = 42
@@ -68,12 +69,30 @@ class ChatInput(QTextEdit):
68
69
  self._icon_size = QSize(18, 18) # icon size (matches original)
69
70
  self._btn_size = QSize(24, 24) # button size (w x h), matches QPushButton
70
71
 
72
+ # Independent sizes for the right-bottom icon bar
73
+ self._icon_size_right = QSize(20, 20) # slightly larger by default
74
+ self._btn_size_right = QSize(26, 26) # slightly larger by default
75
+
76
+ # Independent margins/spacing/offset for the right-bottom bar
77
+ self._icons_margin_right = 6
78
+ self._icons_spacing_right = 4
79
+ self._icons_offset_x_right = 0
80
+ self._icons_offset_y_right = 4 # position the right bar 4px lower by default
81
+
71
82
  # Storage for icon buttons and metadata
72
83
  self._icons = {} # key -> QPushButton
73
84
  self._icon_meta = {} # key -> {"icon": QIcon, "alt_icon": Optional[QIcon], "tooltip": str, "alt_tooltip": Optional[str], "active": bool}
74
85
  self._icon_order = [] # rendering order
75
86
 
87
+ # Storage for right-bottom icon buttons and metadata
88
+ self._icons_right = {} # key -> QPushButton
89
+ self._icon_meta_right = {} # key -> meta as above
90
+ self._icon_order_right = [] # rendering order for right bar
91
+
76
92
  self._init_icon_bar()
93
+ # Initialize the bottom-right icon bar (independent from the left one)
94
+ self._init_icon_bar_right()
95
+
77
96
  # Add a "+" button in the top-left corner to add attachments
78
97
  self.add_icon(
79
98
  key="attach",
@@ -83,7 +102,8 @@ class ChatInput(QTextEdit):
83
102
  visible=True,
84
103
  )
85
104
  # Add a microphone button (hidden by default; shown when audio input is enabled)
86
- self.add_icon(
105
+ # Placed on the bottom-right icon bar
106
+ self.add_right_icon(
87
107
  key="mic",
88
108
  icon=self.ICON_MIC_ON,
89
109
  alt_icon=self.ICON_MIC_OFF,
@@ -104,6 +124,7 @@ class ChatInput(QTextEdit):
104
124
  )
105
125
 
106
126
  # Apply initial margins (top padding + left space for icons)
127
+ # Also reserve right space for bottom-right icons; bottom margin stays 0
107
128
  self._apply_margins()
108
129
 
109
130
  # ---- Auto-resize config (input in splitter) ----
@@ -136,9 +157,19 @@ class ChatInput(QTextEdit):
136
157
  # Drag & Drop: add as attachments; do not insert file paths into text
137
158
  self._dnd_handler = AttachmentDropHandler(self.window, self, policy=AttachmentDropHandler.INPUT_MIX)
138
159
 
160
+ # --- History navigation (input prompts) ---
161
+ # Stores sent prompts and allows keyboard navigation through the history.
162
+ self._history = [] # list[str]
163
+ self._history_limit = 30
164
+ self._history_index = -1 # -1 when not navigating; otherwise index of current history item
165
+ self._history_active = False
166
+ self._history_saved_current = "" # snapshot of the current typed text before entering history nav
167
+
139
168
  def _on_text_changed_tokens(self):
140
169
  """Schedule token count update with debounce."""
141
170
  self._tokens_timer.start()
171
+ # Keep auto-height in sync with content growth/shrink on every edit
172
+ self._schedule_auto_resize()
142
173
 
143
174
  def _apply_text_top_padding(self):
144
175
  """Apply extra top padding inside the text area by using viewport margins."""
@@ -428,6 +459,29 @@ class ChatInput(QTextEdit):
428
459
  """
429
460
  handled = False
430
461
  key = event.key()
462
+ mods = event.modifiers()
463
+
464
+ # --- History navigation and recall ---
465
+ # Ctrl/Command + Up/Down navigates history regardless of current text.
466
+ if key in (Qt.Key_Up, Qt.Key_Down) and (mods & (Qt.ControlModifier | Qt.MetaModifier)):
467
+ if key == Qt.Key_Up:
468
+ self._history_navigate(-1)
469
+ else:
470
+ self._history_navigate(+1)
471
+ handled = True
472
+
473
+ # Up with empty input and no modifiers recalls the last sent prompt.
474
+ elif key == Qt.Key_Up and mods == Qt.NoModifier:
475
+ if self._is_effectively_empty() and self._history:
476
+ if not self._history_active:
477
+ self._history_begin()
478
+ # Start from sentinel and move one step up to the latest entry
479
+ self._history_index = len(self._history)
480
+ self._history_navigate(-1)
481
+ handled = True
482
+
483
+ if handled:
484
+ return
431
485
 
432
486
  if key in (Qt.Key_Return, Qt.Key_Enter):
433
487
  mode = self.window.core.config.get('send_mode')
@@ -437,11 +491,15 @@ class ChatInput(QTextEdit):
437
491
 
438
492
  if mode == 2:
439
493
  if has_shift_or_ctrl:
494
+ text_before_send = self.toPlainText()
440
495
  self.window.controller.chat.input.send_input()
496
+ self._on_prompt_sent(text_before_send)
441
497
  handled = True
442
498
  else:
443
499
  if not has_shift_or_ctrl:
500
+ text_before_send = self.toPlainText()
444
501
  self.window.controller.chat.input.send_input()
502
+ self._on_prompt_sent(text_before_send)
445
503
  handled = True
446
504
 
447
505
  self.setFocus()
@@ -540,6 +598,31 @@ class ChatInput(QTextEdit):
540
598
  self._update_icon_bar_geometry()
541
599
  self._apply_margins()
542
600
 
601
+ # -------------------- Right-bottom icon bar --------------------
602
+ # Independent bar anchored at the bottom-right corner of the input widget.
603
+
604
+ def _init_icon_bar_right(self):
605
+ """Create the right-side icon bar pinned in the bottom-right corner."""
606
+ self._icon_bar_right = QWidget(self)
607
+ self._icon_bar_right.setObjectName("chatInputIconBarRight")
608
+ self._icon_bar_right.setAttribute(Qt.WA_StyledBackground, True)
609
+ self._icon_bar_right.setAutoFillBackground(False)
610
+ self._icon_bar_right.setStyleSheet("""
611
+ #chatInputIconBarRight { background-color: transparent; }
612
+ """)
613
+
614
+ layout = QHBoxLayout(self._icon_bar_right)
615
+ layout.setContentsMargins(0, 0, 0, 0)
616
+ layout.setSpacing(self._icons_spacing_right)
617
+ self._icon_bar_right.setLayout(layout)
618
+
619
+ self._icon_bar_right.setFixedHeight(self._btn_size_right.height())
620
+ self._icon_bar_right.show()
621
+
622
+ self._reposition_icon_bar_right()
623
+ self._update_icon_bar_geometry_right()
624
+ self._apply_margins()
625
+
543
626
  # ---- Public API for icons ----
544
627
 
545
628
  def add_icon(
@@ -651,15 +734,120 @@ class ChatInput(QTextEdit):
651
734
  alt_tooltip = it[6] if len(it) > 6 else None
652
735
  self.add_icon(key, icon, tooltip, callback, visible, alt_icon, alt_tooltip)
653
736
 
737
+ # ---- Public API for icons (RIGHT-BOTTOM) ----
738
+
739
+ def add_right_icon(
740
+ self,
741
+ key: str,
742
+ icon: QIcon,
743
+ tooltip: str = "",
744
+ callback=None,
745
+ visible: bool = True,
746
+ alt_icon: Optional[QIcon] = None,
747
+ alt_tooltip: Optional[str] = None,
748
+ ) -> QPushButton:
749
+ """
750
+ Add a new icon button to the right-bottom bar.
751
+
752
+ :param key: unique identifier for the icon
753
+ :param icon: default QIcon
754
+ :param tooltip: default tooltip text
755
+ :param callback: callable executed on click
756
+ :param visible: initial visibility (True=shown, False=hidden)
757
+ :param alt_icon: optional alternate icon
758
+ :param alt_tooltip: optional alternate tooltip text
759
+ :return: the created QPushButton (or existing one if key already present)
760
+ """
761
+ if key in self._icons_right:
762
+ btn = self._icons_right[key]
763
+ meta = self._icon_meta_right.get(key, {})
764
+ meta.update({
765
+ "icon": icon or meta.get("icon"),
766
+ "alt_icon": alt_icon if alt_icon is not None else meta.get("alt_icon"),
767
+ "tooltip": tooltip or meta.get("tooltip", key),
768
+ "alt_tooltip": alt_tooltip if alt_tooltip is not None else meta.get("alt_tooltip"),
769
+ })
770
+ self._icon_meta_right[key] = meta
771
+ btn.setIcon(meta["icon"])
772
+ btn.setToolTip(meta["tooltip"])
773
+ if callback is not None:
774
+ try:
775
+ btn.clicked.disconnect()
776
+ except Exception:
777
+ pass
778
+ btn.clicked.connect(callback)
779
+ btn.setHidden(not visible)
780
+ self._rebuild_icon_layout_right()
781
+ self._update_icon_bar_geometry_right()
782
+ self._apply_margins()
783
+ return btn
784
+
785
+ btn = QPushButton(self._icon_bar_right)
786
+ btn.setObjectName(f"chatInputIconBtnRight_{key}")
787
+ btn.setIcon(icon)
788
+ btn.setIconSize(self._icon_size_right)
789
+ btn.setFixedSize(self._btn_size_right)
790
+ btn.setCursor(Qt.PointingHandCursor)
791
+ btn.setToolTip(tooltip or key)
792
+ btn.setFocusPolicy(Qt.NoFocus)
793
+ btn.setFlat(True)
794
+ btn.setText("")
795
+
796
+ if callback is not None:
797
+ btn.clicked.connect(callback)
798
+
799
+ self._icons_right[key] = btn
800
+ self._icon_order_right.append(key)
801
+ self._icon_meta_right[key] = {
802
+ "icon": icon,
803
+ "alt_icon": alt_icon,
804
+ "tooltip": tooltip or key,
805
+ "alt_tooltip": alt_tooltip,
806
+ "active": False,
807
+ }
808
+
809
+ self._apply_icon_visual(key)
810
+ btn.setHidden(not visible)
811
+
812
+ self._rebuild_icon_layout_right()
813
+ self._update_icon_bar_geometry_right()
814
+ self._apply_margins()
815
+ return btn
816
+
817
+ def add_right_icons(self, items):
818
+ """Add multiple right-bottom icons at once."""
819
+ for it in items:
820
+ if isinstance(it, dict):
821
+ self.add_right_icon(
822
+ key=it["key"],
823
+ icon=it["icon"],
824
+ tooltip=it.get("tooltip", ""),
825
+ callback=it.get("callback"),
826
+ visible=it.get("visible", True),
827
+ alt_icon=it.get("alt_icon"),
828
+ alt_tooltip=it.get("alt_tooltip"),
829
+ )
830
+ else:
831
+ key, icon = it[0], it[1]
832
+ tooltip = it[2] if len(it) > 2 else ""
833
+ callback = it[3] if len(it) > 3 else None
834
+ visible = it[4] if len(it) > 4 else True
835
+ alt_icon = it[5] if len(it) > 5 else None
836
+ alt_tooltip = it[6] if len(it) > 6 else None
837
+ self.add_right_icon(key, icon, tooltip, callback, visible, alt_icon, alt_tooltip)
838
+
839
+ # ---- Cross-bar helpers (operate on both bars where applicable) ----
840
+
654
841
  def remove_icon(self, key: str):
655
842
  """
656
843
  Remove an icon from the bar.
657
844
 
658
845
  :param key: icon key
659
846
  """
847
+ # Left bar
660
848
  btn = self._icons.pop(key, None)
661
- self._icon_meta.pop(key, None)
662
849
  if btn is not None:
850
+ self._icon_meta.pop(key, None)
663
851
  try:
664
852
  self._icon_order.remove(key)
665
853
  except ValueError:
@@ -669,6 +857,21 @@ class ChatInput(QTextEdit):
669
857
  self._rebuild_icon_layout()
670
858
  self._update_icon_bar_geometry()
671
859
  self._apply_margins()
860
+ return
861
+
862
+ # Right-bottom bar
863
+ btn = self._icons_right.pop(key, None)
864
+ if btn is not None:
865
+ self._icon_meta_right.pop(key, None)
866
+ try:
867
+ self._icon_order_right.remove(key)
868
+ except ValueError:
869
+ pass
870
+ btn.setParent(None)
871
+ btn.deleteLater()
872
+ self._rebuild_icon_layout_right()
873
+ self._update_icon_bar_geometry_right()
874
+ self._apply_margins()
672
875
 
673
876
  def set_icon_visible(self, key: str, visible: bool):
674
877
  """
@@ -678,11 +881,16 @@ class ChatInput(QTextEdit):
678
881
  :param visible: True to show, False to hide
679
882
  """
680
883
  btn = self._icons.get(key)
681
- if not btn:
884
+ if btn:
885
+ btn.setHidden(not visible)
886
+ self._update_icon_bar_geometry()
887
+ self._apply_margins()
682
888
  return
683
- btn.setHidden(not visible)
684
- self._update_icon_bar_geometry()
685
- self._apply_margins()
889
+ btn = self._icons_right.get(key)
890
+ if btn:
891
+ btn.setHidden(not visible)
892
+ self._update_icon_bar_geometry_right()
893
+ self._apply_margins()
686
894
 
687
895
  def toggle_icon(self, key: str):
688
896
  """
@@ -691,11 +899,16 @@ class ChatInput(QTextEdit):
691
899
  :param key: icon key
692
900
  """
693
901
  btn = self._icons.get(key)
694
- if not btn:
902
+ if btn:
903
+ btn.setHidden(not btn.isHidden())
904
+ self._update_icon_bar_geometry()
905
+ self._apply_margins()
695
906
  return
696
- btn.setHidden(not btn.isHidden())
697
- self._update_icon_bar_geometry()
698
- self._apply_margins()
907
+ btn = self._icons_right.get(key)
908
+ if btn:
909
+ btn.setHidden(not btn.isHidden())
910
+ self._update_icon_bar_geometry_right()
911
+ self._apply_margins()
699
912
 
700
913
  def is_icon_visible(self, key: str) -> bool:
701
914
  """
@@ -703,7 +916,7 @@ class ChatInput(QTextEdit):
703
916
 
704
917
  :param key: icon key
705
918
  """
706
- btn = self._icons.get(key)
919
+ btn = self._icons.get(key) or self._icons_right.get(key)
707
920
  return bool(btn and not btn.isHidden())
708
921
 
709
922
  def set_icon_order(self, keys):
@@ -727,6 +940,27 @@ class ChatInput(QTextEdit):
727
940
  self._update_icon_bar_geometry()
728
941
  self._apply_margins()
729
942
 
943
+ def set_right_icon_order(self, keys):
944
+ """
945
+ Set rendering order for RIGHT-BOTTOM icons by a list of keys.
946
+ Icons not listed keep their relative order at the end.
947
+
948
+ :param keys: list of icon keys in desired order
949
+ """
950
+ new_order = []
951
+ seen = set()
952
+ for k in keys:
953
+ if k in self._icons_right and k not in seen:
954
+ new_order.append(k)
955
+ seen.add(k)
956
+ for k in self._icon_order_right:
957
+ if k not in seen and k in self._icons_right:
958
+ new_order.append(k)
959
+ self._icon_order_right = new_order
960
+ self._rebuild_icon_layout_right()
961
+ self._update_icon_bar_geometry_right()
962
+ self._apply_margins()
963
+
730
964
  # ---- Runtime icon swap / state API ----
731
965
 
732
966
  def set_icon_state(self, key: str, active: bool):
@@ -738,12 +972,17 @@ class ChatInput(QTextEdit):
738
972
  :param key: icon key
739
973
  :param active: True to show alt icon, False for base icon
740
974
  """
741
- if key not in self._icons:
975
+ if key in self._icons:
976
+ meta = self._icon_meta.get(key, {})
977
+ meta["active"] = bool(active)
978
+ self._icon_meta[key] = meta
979
+ self._apply_icon_visual(key)
742
980
  return
743
- meta = self._icon_meta.get(key, {})
744
- meta["active"] = bool(active)
745
- self._icon_meta[key] = meta
746
- self._apply_icon_visual(key)
981
+ if key in self._icons_right:
982
+ meta = self._icon_meta_right.get(key, {})
983
+ meta["active"] = bool(active)
984
+ self._icon_meta_right[key] = meta
985
+ self._apply_icon_visual(key)
747
986
 
748
987
  def toggle_icon_state(self, key: str) -> bool:
749
988
  """
@@ -752,11 +991,15 @@ class ChatInput(QTextEdit):
752
991
  :param key: icon key
753
992
  :return: new active state (True if alt icon is now shown)
754
993
  """
755
- if key not in self._icons:
756
- return False
757
- current = bool(self._icon_meta.get(key, {}).get("active", False))
758
- self.set_icon_state(key, not current)
759
- return not current
994
+ if key in self._icons:
995
+ current = bool(self._icon_meta.get(key, {}).get("active", False))
996
+ self.set_icon_state(key, not current)
997
+ return not current
998
+ if key in self._icons_right:
999
+ current = bool(self._icon_meta_right.get(key, {}).get("active", False))
1000
+ self.set_icon_state(key, not current)
1001
+ return not current
1002
+ return False
760
1003
 
761
1004
  def set_icon_pixmap(self, key: str, icon: QIcon):
762
1005
  """
@@ -765,12 +1008,17 @@ class ChatInput(QTextEdit):
765
1008
  :param key: icon key
766
1009
  :param icon: new QIcon
767
1010
  """
768
- if key not in self._icons:
1011
+ if key in self._icons:
1012
+ meta = self._icon_meta.get(key, {})
1013
+ meta["icon"] = icon
1014
+ self._icon_meta[key] = meta
1015
+ self._apply_icon_visual(key)
769
1016
  return
770
- meta = self._icon_meta.get(key, {})
771
- meta["icon"] = icon
772
- self._icon_meta[key] = meta
773
- self._apply_icon_visual(key)
1017
+ if key in self._icons_right:
1018
+ meta = self._icon_meta_right.get(key, {})
1019
+ meta["icon"] = icon
1020
+ self._icon_meta_right[key] = meta
1021
+ self._apply_icon_visual(key)
774
1022
 
775
1023
  def set_icon_alt(self, key: str, alt_icon: Optional[QIcon], alt_tooltip: Optional[str] = None):
776
1024
  """
@@ -780,14 +1028,21 @@ class ChatInput(QTextEdit):
780
1028
  :param alt_icon: new alternate QIcon (or None to clear)
781
1029
  :param alt_tooltip: new alternate tooltip (or None to keep existing)
782
1030
  """
783
- if key not in self._icons:
1031
+ if key in self._icons:
1032
+ meta = self._icon_meta.get(key, {})
1033
+ meta["alt_icon"] = alt_icon
1034
+ if alt_tooltip is not None:
1035
+ meta["alt_tooltip"] = alt_tooltip
1036
+ self._icon_meta[key] = meta
1037
+ self._apply_icon_visual(key)
784
1038
  return
785
- meta = self._icon_meta.get(key, {})
786
- meta["alt_icon"] = alt_icon
787
- if alt_tooltip is not None:
788
- meta["alt_tooltip"] = alt_tooltip
789
- self._icon_meta[key] = meta
790
- self._apply_icon_visual(key)
1039
+ if key in self._icons_right:
1040
+ meta = self._icon_meta_right.get(key, {})
1041
+ meta["alt_icon"] = alt_icon
1042
+ if alt_tooltip is not None:
1043
+ meta["alt_tooltip"] = alt_tooltip
1044
+ self._icon_meta_right[key] = meta
1045
+ self._apply_icon_visual(key)
791
1046
 
792
1047
  def set_icon_tooltip(self, key: str, tooltip: str, for_alt: bool = False):
793
1048
  """
@@ -797,15 +1052,23 @@ class ChatInput(QTextEdit):
797
1052
  :param tooltip: new tooltip text
798
1053
  :param for_alt: if True, update alt tooltip instead of base tooltip
799
1054
  """
800
- if key not in self._icons:
1055
+ if key in self._icons:
1056
+ meta = self._icon_meta.get(key, {})
1057
+ if for_alt:
1058
+ meta["alt_tooltip"] = tooltip
1059
+ else:
1060
+ meta["tooltip"] = tooltip
1061
+ self._icon_meta[key] = meta
1062
+ self._apply_icon_visual(key)
801
1063
  return
802
- meta = self._icon_meta.get(key, {})
803
- if for_alt:
804
- meta["alt_tooltip"] = tooltip
805
- else:
806
- meta["tooltip"] = tooltip
807
- self._icon_meta[key] = meta
808
- self._apply_icon_visual(key)
1064
+ if key in self._icons_right:
1065
+ meta = self._icon_meta_right.get(key, {})
1066
+ if for_alt:
1067
+ meta["alt_tooltip"] = tooltip
1068
+ else:
1069
+ meta["tooltip"] = tooltip
1070
+ self._icon_meta_right[key] = meta
1071
+ self._apply_icon_visual(key)
809
1072
 
810
1073
  def set_icon_callback(self, key: str, callback):
811
1074
  """
@@ -815,14 +1078,22 @@ class ChatInput(QTextEdit):
815
1078
  :param callback: new callable (or None to disconnect)
816
1079
  """
817
1080
  btn = self._icons.get(key)
818
- if not btn:
1081
+ if btn:
1082
+ try:
1083
+ btn.clicked.disconnect()
1084
+ except Exception:
1085
+ pass
1086
+ if callback is not None:
1087
+ btn.clicked.connect(callback)
819
1088
  return
820
- try:
821
- btn.clicked.disconnect()
822
- except Exception:
823
- pass
824
- if callback is not None:
825
- btn.clicked.connect(callback)
1089
+ btn = self._icons_right.get(key)
1090
+ if btn:
1091
+ try:
1092
+ btn.clicked.disconnect()
1093
+ except Exception:
1094
+ pass
1095
+ if callback is not None:
1096
+ btn.clicked.connect(callback)
826
1097
 
827
1098
  def get_icon_state(self, key: str) -> bool:
828
1099
  """
@@ -830,7 +1101,11 @@ class ChatInput(QTextEdit):
830
1101
 
831
1102
  :param key: icon key
832
1103
  """
833
- return bool(self._icon_meta.get(key, {}).get("active", False))
1104
+ if key in self._icon_meta:
1105
+ return bool(self._icon_meta.get(key, {}).get("active", False))
1106
+ if key in self._icon_meta_right:
1107
+ return bool(self._icon_meta_right.get(key, {}).get("active", False))
1108
+ return False
834
1109
 
835
1110
  def get_icon_button(self, key: str) -> Optional[QPushButton]:
836
1111
  """
@@ -839,7 +1114,100 @@ class ChatInput(QTextEdit):
839
1114
  :param key: icon key
840
1115
  :return: QPushButton or None if key not found
841
1116
  """
842
- return self._icons.get(key)
1117
+ return self._icons.get(key) or self._icons_right.get(key)
1118
+
1119
+ # ---- Right icons sizing API ----
1120
+
1121
+ def set_right_icon_sizes(
1122
+ self,
1123
+ icon_size: Optional[Union[QSize, Tuple[int, int], int]] = None,
1124
+ btn_size: Optional[Union[QSize, Tuple[int, int], int]] = None,
1125
+ ):
1126
+ """
1127
+ Public API: change sizes for right-bottom icons.
1128
+ - icon_size: QSize | (w, h) | int (square)
1129
+ - btn_size : QSize | (w, h) | int (square)
1130
+ Applies to existing right icons immediately.
1131
+ """
1132
+ def _to_qsize(v, fallback: QSize) -> QSize:
1133
+ if v is None:
1134
+ return fallback
1135
+ if isinstance(v, QSize):
1136
+ return v
1137
+ if isinstance(v, int):
1138
+ return QSize(v, v)
1139
+ if isinstance(v, (tuple, list)) and len(v) >= 2:
1140
+ return QSize(int(v[0]), int(v[1]))
1141
+ return fallback
1142
+
1143
+ new_icon_sz = _to_qsize(icon_size, self._icon_size_right)
1144
+ new_btn_sz = _to_qsize(btn_size, self._btn_size_right)
1145
+
1146
+ self._icon_size_right = new_icon_sz
1147
+ self._btn_size_right = new_btn_sz
1148
+
1149
+ for btn in self._icons_right.values():
1150
+ btn.setIconSize(self._icon_size_right)
1151
+ btn.setFixedSize(self._btn_size_right)
1152
+
1153
+ if hasattr(self, "_icon_bar_right"):
1154
+ self._icon_bar_right.setFixedHeight(self._btn_size_right.height())
1155
+
1156
+ self._update_icon_bar_geometry_right()
1157
+ self._reposition_icon_bar_right()
1158
+ self._apply_margins()
1159
+ self.update()
1160
+
1161
+ def set_right_icon_px(self, icon_px: int, btn_px: Optional[int] = None):
1162
+ """
1163
+ Convenience helper to set square sizes for right-bottom icons.
1164
+ """
1165
+ btn = btn_px if btn_px is not None else self._btn_size_right.height()
1166
+ self.set_right_icon_sizes(icon_px, btn)
1167
+
1168
+ # ---- Right bar margins/spacing/offset API ----
1169
+
1170
+ def set_right_bar_margins(
1171
+ self,
1172
+ margin: Optional[int] = None,
1173
+ spacing: Optional[int] = None,
1174
+ offset_x: Optional[int] = None,
1175
+ offset_y: Optional[int] = None,
1176
+ ):
1177
+ """
1178
+ Public API: change layout params for the right-bottom icon bar.
1179
+ - margin: inner padding from edges (px)
1180
+ - spacing: spacing between right-bar buttons (px)
1181
+ - offset_x: horizontal offset (+ rightwards, - leftwards)
1182
+ - offset_y: vertical offset (+ downwards, - upwards)
1183
+ """
1184
+ if margin is not None:
1185
+ try:
1186
+ self._icons_margin_right = int(margin)
1187
+ except Exception:
1188
+ pass
1189
+ if spacing is not None:
1190
+ try:
1191
+ self._icons_spacing_right = int(spacing)
1192
+ if hasattr(self, "_icon_bar_right") and self._icon_bar_right.layout():
1193
+ self._icon_bar_right.layout().setSpacing(self._icons_spacing_right)
1194
+ except Exception:
1195
+ pass
1196
+ if offset_x is not None:
1197
+ try:
1198
+ self._icons_offset_x_right = int(offset_x)
1199
+ except Exception:
1200
+ pass
1201
+ if offset_y is not None:
1202
+ try:
1203
+ self._icons_offset_y_right = int(offset_y)
1204
+ except Exception:
1205
+ pass
1206
+
1207
+ self._update_icon_bar_geometry_right()
1208
+ self._reposition_icon_bar_right()
1209
+ self._apply_margins()
1210
+ self.update()
843
1211
 
844
1212
  # ---- Internal layout helpers ----
845
1213
 
@@ -849,8 +1217,8 @@ class ChatInput(QTextEdit):
849
1217
 
850
1218
  :param key: icon key
851
1219
  """
852
- btn = self._icons.get(key)
853
- meta = self._icon_meta.get(key, {})
1220
+ btn = self._icons.get(key) or self._icons_right.get(key)
1221
+ meta = self._icon_meta.get(key) if key in self._icon_meta else self._icon_meta_right.get(key, {})
854
1222
  if not btn or not meta:
855
1223
  return
856
1224
  active = meta.get("active", False)
@@ -878,10 +1246,29 @@ class ChatInput(QTextEdit):
878
1246
  if btn:
879
1247
  layout.addWidget(btn)
880
1248
 
1249
+ def _rebuild_icon_layout_right(self):
1250
+ """Rebuild the RIGHT-BOTTOM layout according to current _icon_order_right."""
1251
+ if not hasattr(self, "_icon_bar_right"):
1252
+ return
1253
+ layout = self._icon_bar_right.layout()
1254
+ while layout.count():
1255
+ item = layout.takeAt(0)
1256
+ w = item.widget()
1257
+ if w:
1258
+ layout.removeWidget(w)
1259
+ for k in self._icon_order_right:
1260
+ btn = self._icons_right.get(k)
1261
+ if btn:
1262
+ layout.addWidget(btn)
1263
+
881
1264
  def _visible_buttons(self):
882
1265
  """Helper to list icon buttons that are not hidden."""
883
1266
  return [self._icons[k] for k in self._icon_order if k in self._icons and not self._icons[k].isHidden()]
884
1267
 
1268
+ def _visible_buttons_right(self):
1269
+ """Helper to list icon buttons that are not hidden on right bar."""
1270
+ return [self._icons_right[k] for k in self._icon_order_right if k in self._icons_right and not self._icons_right[k].isHidden()]
1271
+
885
1272
  def _compute_icon_bar_width(self) -> int:
886
1273
  """
887
1274
  Compute width from button count to ensure padding before layout measures.
@@ -895,6 +1282,17 @@ class ChatInput(QTextEdit):
895
1282
  w = count * self._btn_size.width() + (count - 1) * self._icons_spacing
896
1283
  return w
897
1284
 
1285
+ def _compute_icon_bar_right_width(self) -> int:
1286
+ """
1287
+ Compute width for right-bottom bar from button count.
1288
+ """
1289
+ vis = self._visible_buttons_right()
1290
+ if not vis:
1291
+ return 0
1292
+ count = len(vis)
1293
+ w = count * self._btn_size_right.width() + (count - 1) * self._icons_spacing_right
1294
+ return w
1295
+
898
1296
  def _update_icon_bar_geometry(self):
899
1297
  """Update the bar width and keep it raised above the text viewport."""
900
1298
  if not hasattr(self, "_icon_bar"):
@@ -904,6 +1302,15 @@ class ChatInput(QTextEdit):
904
1302
  self._icon_bar.raise_()
905
1303
  self._reposition_icon_bar()
906
1304
 
1305
+ def _update_icon_bar_geometry_right(self):
1306
+ """Update the right-bottom bar width and keep it raised above the text viewport."""
1307
+ if not hasattr(self, "_icon_bar_right"):
1308
+ return
1309
+ width = self._compute_icon_bar_right_width()
1310
+ self._icon_bar_right.setFixedWidth(max(0, width))
1311
+ self._icon_bar_right.raise_()
1312
+ self._reposition_icon_bar_right()
1313
+
907
1314
  def _reposition_icon_bar(self):
908
1315
  """Keep the icon bar pinned to the top-left corner."""
909
1316
  if hasattr(self, "_icon_bar"):
@@ -914,12 +1321,37 @@ class ChatInput(QTextEdit):
914
1321
  y = 0
915
1322
  self._icon_bar.move(x, y)
916
1323
 
1324
+ def _reposition_icon_bar_right(self):
1325
+ """Keep the right-bottom icon bar pinned to the bottom-right corner."""
1326
+ if hasattr(self, "_icon_bar_right"):
1327
+ fw = self.frameWidth()
1328
+ bar_w = self._compute_icon_bar_right_width()
1329
+ bar_h = self._btn_size_right.height()
1330
+ x = self.width() - fw - self._icons_margin_right - bar_w + self._icons_offset_x_right
1331
+ y = self.height() - fw - self._icons_margin_right - bar_h + self._icons_offset_y_right
1332
+ # Clamp inside widget bounds
1333
+ x = max(0, min(self.width() - bar_w, x))
1334
+ y = max(0, min(self.height() - bar_h, y))
1335
+ self._icon_bar_right.move(x, y)
1336
+
917
1337
  def _apply_margins(self):
918
1338
  """Reserve left space for visible icons and apply top text padding."""
1339
+ # Also reserve right space for the bottom-right icon bar; keep bottom margin at 0 to avoid vertical shrink
919
1340
  left_space = self._compute_icon_bar_width()
920
1341
  if left_space > 0:
921
1342
  left_space += self._icons_margin * 2
922
- self.setViewportMargins(left_space, self._text_top_padding, 0, 0)
1343
+
1344
+ right_space = self._compute_icon_bar_right_width()
1345
+ if right_space > 0:
1346
+ right_space += self._icons_margin_right * 2
1347
+
1348
+ self.setViewportMargins(left_space, self._text_top_padding, right_space, 0)
1349
+
1350
+ # Reflow may change number of lines; adjust auto-height on next tick
1351
+ try:
1352
+ QTimer.singleShot(0, self._schedule_auto_resize)
1353
+ except Exception:
1354
+ pass
923
1355
 
924
1356
  def resizeEvent(self, event):
925
1357
  """Resize event keeps the icon bar in place."""
@@ -928,6 +1360,10 @@ class ChatInput(QTextEdit):
928
1360
  self._reposition_icon_bar()
929
1361
  except Exception:
930
1362
  pass
1363
+ try:
1364
+ self._reposition_icon_bar_right()
1365
+ except Exception:
1366
+ pass
931
1367
  # Recompute on width changes (word wrap may change line count)
932
1368
  if not self._splitter_resize_in_progress:
933
1369
  if self.hasFocus():
@@ -1168,4 +1604,135 @@ class ChatInput(QTextEdit):
1168
1604
 
1169
1605
  def collapse_to_min(self):
1170
1606
  """Public helper to collapse input area to minimal height."""
1171
- self._schedule_auto_resize(force=True, enforce_minimize_if_single=True)
1607
+ self._schedule_auto_resize(force=True, enforce_minimize_if_single=True)
1608
+
1609
+ # ================== Prompt history helpers ==================
1610
+
1611
+ def _is_effectively_empty(self) -> bool:
1612
+ """Returns True if input contains only whitespace or is empty."""
1613
+ try:
1614
+ return len(self.toPlainText().strip()) == 0
1615
+ except Exception:
1616
+ return True
1617
+
1618
+ def _set_text_and_move_end(self, text: str):
1619
+ """Set text and move cursor to the end."""
1620
+ self.setPlainText(text or "")
1621
+ cursor = self.textCursor()
1622
+ cursor.movePosition(QTextCursor.End)
1623
+ self.setTextCursor(cursor)
1624
+
1625
+ def _history_begin(self):
1626
+ """Enter history navigation mode and snapshot current typed text."""
1627
+ if self._history_active:
1628
+ return
1629
+ try:
1630
+ self._history_saved_current = self.toPlainText()
1631
+ except Exception:
1632
+ self._history_saved_current = ""
1633
+ self._history_active = True
1634
+ self._history_index = len(self._history)
1635
+
1636
+ def _history_end(self, restore_saved: bool = True):
1637
+ """Exit history navigation mode; optionally restore the saved typed text."""
1638
+ if restore_saved:
1639
+ self._set_text_and_move_end(self._history_saved_current)
1640
+ self._history_active = False
1641
+ self._history_index = -1
1642
+ self._history_saved_current = ""
1643
+
1644
+ def _history_navigate(self, direction: int):
1645
+ """
1646
+ Navigate through history.
1647
+ direction: -1 for older (Up), +1 for newer (Down).
1648
+ """
1649
+ if not self._history:
1650
+ return
1651
+ if not self._history_active:
1652
+ self._history_begin()
1653
+
1654
+ # Move within [0, len(history)-1]; moving past newest restores and exits navigation.
1655
+ if direction < 0:
1656
+ # Older
1657
+ if self._history_index > 0:
1658
+ self._history_index -= 1
1659
+ self._set_text_and_move_end(self._history[self._history_index])
1660
+ elif self._history_index == 0:
1661
+ # Stay at the oldest entry
1662
+ self._set_text_and_move_end(self._history[0])
1663
+ else:
1664
+ # Sentinel -> jump to last
1665
+ self._history_index = max(0, len(self._history) - 1)
1666
+ self._set_text_and_move_end(self._history[self._history_index])
1667
+ else:
1668
+ # Newer
1669
+ if self._history_index < len(self._history) - 1:
1670
+ self._history_index += 1
1671
+ self._set_text_and_move_end(self._history[self._history_index])
1672
+ elif self._history_index == len(self._history) - 1:
1673
+ # Past newest -> restore typed and exit
1674
+ self._history_end(restore_saved=True)
1675
+ else:
1676
+ # Already at sentinel -> ensure restore
1677
+ self._history_end(restore_saved=True)
1678
+
1679
+ def _normalize_history_text(self, text: str) -> str:
1680
+ """Normalize text before storing in history."""
1681
+ if not isinstance(text, str):
1682
+ try:
1683
+ text = str(text)
1684
+ except Exception:
1685
+ return ""
1686
+ return text.strip()
1687
+
1688
+ def history_push(self, text: str):
1689
+ """
1690
+ Public API: push a sent prompt to history.
1691
+ Controllers may call this when sending via buttons to keep history in sync.
1692
+ """
1693
+ s = self._normalize_history_text(text)
1694
+ if not s:
1695
+ return
1696
+ if self._history and self._history[-1] == s:
1697
+ return
1698
+ self._history.append(s)
1699
+ if len(self._history) > self._history_limit:
1700
+ # Keep most recent N entries
1701
+ overflow = len(self._history) - self._history_limit
1702
+ if overflow > 0:
1703
+ del self._history[:overflow]
1704
+
1705
+ def history_clear(self):
1706
+ """Public API: clear stored prompt history."""
1707
+ self._history.clear()
1708
+ self._history_index = -1
1709
+ self._history_active = False
1710
+ self._history_saved_current = ""
1711
+
1712
+ def _on_prompt_sent(self, text_before_send: str):
1713
+ """
1714
+ Internal hook executed after sending the input via Enter.
1715
+ Stores the prompt into history and resets navigation snapshot.
1716
+ """
1717
+ try:
1718
+ # Avoid recording while editing existing messages (best-effort).
1719
+ is_editing = bool(self.window.controller.ctx.extra.is_editing())
1720
+ except Exception:
1721
+ is_editing = False
1722
+
1723
+ if not is_editing:
1724
+ self.history_push(text_before_send)
1725
+
1726
+ # Leave history navigation after send
1727
+ self._history_active = False
1728
+ self._history_index = -1
1729
+ self._history_saved_current = ""
1730
+
1731
+ def on_prompt_sent(self, text: Optional[str] = None):
1732
+ """
1733
+ Public API: controllers can call this when a prompt is sent by other means (e.g., send button).
1734
+ If text is None the current input text is used.
1735
+ """
1736
+ if text is None:
1737
+ text = self.toPlainText()
1738
+ self._on_prompt_sent(text)