abstractassistant 0.3.5__py3-none-any.whl → 0.4.0__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.
@@ -10,10 +10,19 @@ import time
10
10
  import json
11
11
  from datetime import datetime
12
12
  from pathlib import Path
13
- from typing import Optional, Callable, List, Dict
13
+ from typing import Optional, Callable, List, Dict, Any
14
14
 
15
15
  # Import AbstractVoice-compatible TTS manager (required dependency)
16
- from ..core.tts_manager import VoiceManager
16
+ from ..core.agent_host import AgentHost
17
+
18
+ try:
19
+ # Optional dependency (installed via `abstractassistant[full]`).
20
+ from ..core.tts_manager import VoiceManager # type: ignore
21
+
22
+ TTS_AVAILABLE = True
23
+ except Exception:
24
+ VoiceManager = None # type: ignore[assignment]
25
+ TTS_AVAILABLE = False
17
26
 
18
27
  # Import our new manager classes (required dependencies)
19
28
  from .provider_manager import ProviderManager
@@ -21,17 +30,17 @@ from .ui_styles import UIStyles
21
30
  from .tts_state_manager import TTSStateManager, TTSState
22
31
  from .history_dialog import iPhoneMessagesDialog
23
32
 
24
- # Since these are required dependencies, set availability to True
25
- TTS_AVAILABLE = True
33
+ # Provider/model managers are package-local.
26
34
  MANAGERS_AVAILABLE = True
27
35
 
28
36
  try:
29
37
  from PyQt5.QtWidgets import (
30
38
  QApplication, QWidget, QVBoxLayout, QHBoxLayout,
31
39
  QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
32
- QFileDialog, QMessageBox
40
+ QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
41
+ QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
33
42
  )
34
- from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect, QMetaObject
43
+ from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QRect, QMetaObject, QEvent
35
44
  from PyQt5.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
36
45
  from PyQt5.QtCore import QPoint
37
46
  QT_AVAILABLE = "PyQt5"
@@ -40,9 +49,10 @@ except ImportError:
40
49
  from PySide2.QtWidgets import (
41
50
  QApplication, QWidget, QVBoxLayout, QHBoxLayout,
42
51
  QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
43
- QFileDialog, QMessageBox
52
+ QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
53
+ QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
44
54
  )
45
- from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot, QMetaObject
55
+ from PySide2.QtCore import Qt, QTimer, Signal as pyqtSignal, QThread, Slot as pyqtSlot, QMetaObject, QEvent
46
56
  from PySide2.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
47
57
  from PySide2.QtCore import QPoint
48
58
  QT_AVAILABLE = "PySide2"
@@ -51,9 +61,10 @@ except ImportError:
51
61
  from PyQt6.QtWidgets import (
52
62
  QApplication, QWidget, QVBoxLayout, QHBoxLayout,
53
63
  QTextEdit, QPushButton, QComboBox, QLabel, QFrame,
54
- QFileDialog, QMessageBox
64
+ QFileDialog, QMessageBox, QInputDialog, QCheckBox, QDialog, QMenu,
65
+ QLineEdit, QScrollArea, QSizePolicy, QButtonGroup
55
66
  )
56
- from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot
67
+ from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot, QEvent
57
68
  from PyQt6.QtGui import QFont, QPalette, QColor, QPainter, QPen, QBrush
58
69
  from PyQt6.QtCore import QPoint
59
70
  QT_AVAILABLE = "PyQt6"
@@ -125,17 +136,27 @@ class TTSToggle(QPushButton):
125
136
 
126
137
  def _update_appearance(self):
127
138
  """Update button appearance based on user's toggle state ONLY."""
139
+ palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
140
+ is_dark = palette.window().color().lightness() < 128
141
+ accent = palette.highlight().color()
142
+
143
+ def rgba(color: QColor, alpha: float) -> str:
144
+ return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
145
+
146
+ overlay_bg = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.06)"
147
+ overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
148
+ overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
149
+ overlay_fg = "rgba(255, 255, 255, 0.7)" if is_dark else "rgba(0, 0, 0, 0.65)"
150
+
128
151
  # SIMPLE USER CONTROL - only shows enabled/disabled state
129
152
  if self._enabled:
130
- # User has enabled TTS
131
153
  icon = "🔉" # Speaker icon when enabled
132
- bg_color = "rgba(0, 102, 204, 0.8)" # Blue
154
+ bg_color = rgba(accent, 0.85)
133
155
  text_color = "#ffffff"
134
156
  else:
135
- # User has disabled TTS
136
157
  icon = "🔇" # Muted speaker when disabled
137
- bg_color = "rgba(255, 255, 255, 0.06)"
138
- text_color = "rgba(255, 255, 255, 0.7)"
158
+ bg_color = overlay_bg
159
+ text_color = overlay_fg
139
160
 
140
161
  self.setText(icon)
141
162
  self.setStyleSheet(f"""
@@ -149,10 +170,10 @@ class TTSToggle(QPushButton):
149
170
  font-weight: 600;
150
171
  }}
151
172
  QPushButton:hover {{
152
- background: rgba(0, 122, 255, 0.8);
173
+ background: {rgba(accent, 0.9) if self._enabled else overlay_hover};
153
174
  }}
154
175
  QPushButton:pressed {{
155
- background: rgba(0, 122, 255, 0.6);
176
+ background: {rgba(accent, 0.75) if self._enabled else overlay_pressed};
156
177
  }}
157
178
  """)
158
179
 
@@ -192,17 +213,30 @@ class FullVoiceToggle(QPushButton):
192
213
 
193
214
  def _update_appearance(self):
194
215
  """Update button appearance based on user's toggle state ONLY."""
216
+ palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
217
+ is_dark = palette.window().color().lightness() < 128
218
+ accent = palette.highlight().color()
219
+
220
+ def rgba(color: QColor, alpha: float) -> str:
221
+ return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
222
+
223
+ overlay_bg = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.06)"
224
+ overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
225
+ overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
226
+ overlay_fg = "rgba(255, 255, 255, 0.7)" if is_dark else "rgba(0, 0, 0, 0.65)"
227
+
195
228
  # SIMPLE USER CONTROL - only shows enabled/disabled state
196
229
  if self._enabled:
197
- # User has enabled Full Voice Mode
198
230
  icon = "🎙️" # Microphone when enabled
199
- bg_color = "rgba(0, 122, 204, 0.8)" # Blue
231
+ bg_color = rgba(accent, 0.85)
200
232
  text_color = "#ffffff"
201
233
  else:
202
- # User has disabled Full Voice Mode
203
- icon = "🎤" # Muted microphone when disabled
204
- bg_color = "rgba(255, 255, 255, 0.06)"
205
- text_color = "rgba(255, 255, 255, 0.7)"
234
+ # Show an explicit "mic off" glyph by default (struck mic),
235
+ # because the app starts in non-listening mode until the user enables Full Voice Mode.
236
+ # Using a combining overlay is the most portable way to get a clear strike without custom painting.
237
+ icon = "🎙️\u20E0" # "no" overlay (combining enclosing circle backslash)
238
+ bg_color = overlay_bg
239
+ text_color = overlay_fg
206
240
 
207
241
  self.setText(icon)
208
242
  self.setStyleSheet(f"""
@@ -216,29 +250,461 @@ class FullVoiceToggle(QPushButton):
216
250
  font-weight: 600;
217
251
  }}
218
252
  QPushButton:hover {{
219
- background: {bg_color.replace('0.8', '1.0') if '0.8' in bg_color else bg_color};
253
+ background: {rgba(accent, 0.9) if self._enabled else overlay_hover};
220
254
  }}
221
255
  QPushButton:pressed {{
222
- background: {bg_color.replace('0.8', '0.6') if '0.8' in bg_color else bg_color};
256
+ background: {rgba(accent, 0.75) if self._enabled else overlay_pressed};
223
257
  }}
224
258
  """)
225
259
 
226
260
 
227
261
 
228
262
 
263
+ class ToolSelectorDialog(QDialog):
264
+ """Tool allowlist editor (All tools vs Custom allowlist)."""
265
+
266
+ def __init__(
267
+ self,
268
+ *,
269
+ parent: Optional[QWidget] = None,
270
+ tools: List[Dict[str, str]],
271
+ enabled: set[str],
272
+ safe_preset: set[str],
273
+ require_approval: set[str],
274
+ ):
275
+ super().__init__(parent)
276
+ self.setWindowTitle("Tools")
277
+ self.setModal(True)
278
+
279
+ self._tools = [t for t in list(tools) if isinstance(t, dict)]
280
+ self._safe_preset = set(safe_preset)
281
+ self._require_approval = set(require_approval)
282
+
283
+ # Keep the tool order stable.
284
+ self._all_names = [
285
+ str(t.get("name") or "").strip()
286
+ for t in self._tools
287
+ if isinstance(t.get("name"), str) and str(t.get("name") or "").strip()
288
+ ]
289
+ self._all_names_set = set(self._all_names)
290
+
291
+ self._mode: str = "all" if set(enabled) == self._all_names_set else "custom"
292
+ self._custom_selected: set[str] = set(enabled)
293
+ if self._mode == "all":
294
+ self._custom_selected = set(self._all_names_set)
295
+
296
+ palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
297
+ is_dark = palette.window().color().lightness() < 128
298
+ window_bg = palette.window().color().name()
299
+ base_bg = palette.base().color().name()
300
+ mid = palette.mid().color()
301
+ mid_hex = mid.name()
302
+ text = palette.text().color()
303
+ accent = palette.highlight().color()
304
+
305
+ def rgba(color: QColor, alpha: float) -> str:
306
+ return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
307
+
308
+ overlay = "rgba(255, 255, 255, 0.08)" if is_dark else "rgba(0, 0, 0, 0.06)"
309
+ overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
310
+ overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
311
+ text_primary = rgba(text, 0.92)
312
+ text_secondary = rgba(text, 0.70)
313
+ text_muted = rgba(text, 0.55 if is_dark else 0.50)
314
+ accent_hex = accent.name()
315
+ accent_hover = accent.lighter(115).name()
316
+ accent_pressed = accent.darker(115).name()
317
+ accent_border = rgba(accent, 0.28)
318
+ danger = QColor(255, 59, 48)
319
+ danger_border = rgba(danger, 0.45)
320
+ tool_name_color = "#22c55e" if is_dark else "#16a34a"
321
+ indicator_border = rgba(text, 0.38 if is_dark else 0.30)
322
+
323
+ self._suppress_checkbox_updates: bool = False
324
+
325
+ self.setStyleSheet(
326
+ f"""
327
+ QDialog {{
328
+ background: {window_bg};
329
+ color: {text_primary};
330
+ }}
331
+ """
332
+ )
333
+
334
+ layout = QVBoxLayout(self)
335
+ layout.setContentsMargins(18, 18, 18, 18)
336
+ layout.setSpacing(12)
337
+
338
+ header = QLabel("TOOLS")
339
+ header.setStyleSheet(
340
+ f"""
341
+ QLabel {{
342
+ color: {accent_hex};
343
+ font-size: 11px;
344
+ font-weight: 700;
345
+ }}
346
+ """
347
+ )
348
+ layout.addWidget(header)
349
+
350
+ subtitle = QLabel("Default is all tools. Switch to a custom allowlist only when needed.")
351
+ subtitle.setWordWrap(True)
352
+ subtitle.setStyleSheet(f"QLabel {{ font-size: 12px; color: {text_secondary}; }}")
353
+ layout.addWidget(subtitle)
354
+
355
+ controls_row = QHBoxLayout()
356
+ controls_row.setSpacing(10)
357
+
358
+ seg_frame = QFrame()
359
+ seg_frame.setStyleSheet(
360
+ f"""
361
+ QFrame {{
362
+ background: {overlay_pressed};
363
+ border: 1px solid {mid_hex};
364
+ border-radius: 14px;
365
+ }}
366
+ """
367
+ )
368
+ seg_layout = QHBoxLayout(seg_frame)
369
+ seg_layout.setContentsMargins(2, 2, 2, 2)
370
+ seg_layout.setSpacing(2)
371
+
372
+ self.all_mode_btn = QPushButton("All tools")
373
+ self.custom_mode_btn = QPushButton("Custom allowlist")
374
+ for b in (self.all_mode_btn, self.custom_mode_btn):
375
+ b.setCheckable(True)
376
+ try:
377
+ b.setAutoExclusive(True)
378
+ except Exception:
379
+ pass
380
+ b.setFixedHeight(28)
381
+ b.setStyleSheet(
382
+ f"""
383
+ QPushButton {{
384
+ background: transparent;
385
+ border: none;
386
+ border-radius: 12px;
387
+ padding: 0 12px;
388
+ font-size: 12px;
389
+ font-weight: 600;
390
+ color: {text_secondary};
391
+ }}
392
+ QPushButton:hover {{ background: {overlay_hover}; color: {text_primary}; }}
393
+ QPushButton:checked {{ background: {base_bg}; color: {text_primary}; }}
394
+ """
395
+ )
396
+ self.all_mode_btn.setChecked(self._mode == "all")
397
+ self.custom_mode_btn.setChecked(self._mode == "custom")
398
+
399
+ seg_layout.addWidget(self.all_mode_btn)
400
+ seg_layout.addWidget(self.custom_mode_btn)
401
+ controls_row.addWidget(seg_frame)
402
+
403
+ self.count_pill = QLabel("")
404
+ self.count_pill.setAlignment(Qt.AlignmentFlag.AlignCenter)
405
+ self.count_pill.setFixedHeight(28)
406
+ self.count_pill.setMinimumWidth(160)
407
+ self.count_pill.setStyleSheet(
408
+ f"""
409
+ QLabel {{
410
+ background: {overlay};
411
+ border: 1px solid {mid_hex};
412
+ border-radius: 14px;
413
+ font-size: 11px;
414
+ font-weight: 600;
415
+ color: {text_secondary};
416
+ padding: 0 12px;
417
+ }}
418
+ """
419
+ )
420
+ controls_row.addWidget(self.count_pill)
421
+ controls_row.addStretch()
422
+ layout.addLayout(controls_row)
423
+
424
+ self.filter_input = QLineEdit()
425
+ self.filter_input.setPlaceholderText("Filter tools…")
426
+ self.filter_input.setClearButtonEnabled(True)
427
+ self.filter_input.setFixedHeight(34)
428
+ self.filter_input.setStyleSheet(
429
+ f"""
430
+ QLineEdit {{
431
+ background: {overlay_pressed};
432
+ border: 1px solid {mid_hex};
433
+ border-radius: 10px;
434
+ padding: 0 12px;
435
+ font-size: 12px;
436
+ color: {text_primary};
437
+ }}
438
+ QLineEdit:focus {{
439
+ border: 1px solid {accent_hex};
440
+ background: {overlay_hover};
441
+ }}
442
+ """
443
+ )
444
+ layout.addWidget(self.filter_input)
445
+
446
+ scroll = QScrollArea()
447
+ scroll.setWidgetResizable(True)
448
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
449
+ scroll.setStyleSheet("QScrollArea { background: transparent; }")
450
+ layout.addWidget(scroll, 1)
451
+
452
+ list_root = QWidget()
453
+ list_layout = QVBoxLayout(list_root)
454
+ list_layout.setContentsMargins(0, 0, 0, 0)
455
+ list_layout.setSpacing(10)
456
+ scroll.setWidget(list_root)
457
+
458
+ self._rows: Dict[str, Dict[str, Any]] = {}
459
+
460
+ def _badge(text_value: str, *, fg: str, bg: str, border: str) -> QLabel:
461
+ lab = QLabel(text_value)
462
+ lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
463
+ lab.setFixedHeight(18)
464
+ lab.setStyleSheet(
465
+ f"""
466
+ QLabel {{
467
+ color: {fg};
468
+ background: {bg};
469
+ border: 1px solid {border};
470
+ border-radius: 9px;
471
+ padding: 0 8px;
472
+ font-size: 10px;
473
+ font-weight: 700;
474
+ }}
475
+ """
476
+ )
477
+ return lab
478
+
479
+ def _current_selected() -> set[str]:
480
+ return {n for n, info in self._rows.items() if info["checkbox"].isChecked()}
481
+
482
+ def _on_checkbox_changed(_: str) -> None:
483
+ if getattr(self, "_suppress_checkbox_updates", False):
484
+ return
485
+
486
+ selected = _current_selected()
487
+
488
+ # In "All tools" mode, any manual uncheck means the user is starting
489
+ # a custom allowlist. Flip to custom and keep current selection.
490
+ if self._mode == "all" and selected != self._all_names_set:
491
+ self._custom_selected = set(selected)
492
+ _set_mode("custom")
493
+ return
494
+
495
+ if self._mode == "custom":
496
+ self._custom_selected = set(selected)
497
+
498
+ self._update_counts()
499
+
500
+ for info in self._tools:
501
+ name = str(info.get("name") or "").strip()
502
+ if not name:
503
+ continue
504
+ desc = str(info.get("description") or "").strip()
505
+
506
+ row = QFrame()
507
+ row.setObjectName("toolRow")
508
+ row_layout = QHBoxLayout(row)
509
+ row_layout.setContentsMargins(12, 10, 12, 10)
510
+ row_layout.setSpacing(10)
511
+
512
+ cb = QCheckBox()
513
+ cb.setChecked(name in self._custom_selected)
514
+ cb.setStyleSheet(
515
+ f"""
516
+ QCheckBox {{ color: {text_primary}; }}
517
+ QCheckBox::indicator {{
518
+ width: 18px;
519
+ height: 18px;
520
+ border-radius: 4px;
521
+ border: 1px solid {indicator_border};
522
+ background: {overlay};
523
+ }}
524
+ QCheckBox::indicator:hover {{
525
+ background: {overlay_hover};
526
+ border: 1px solid {accent_hex};
527
+ }}
528
+ QCheckBox::indicator:checked {{
529
+ background: {accent_hex};
530
+ border: 1px solid {accent_hover};
531
+ }}
532
+ QCheckBox::indicator:checked:hover {{
533
+ background: {accent_hover};
534
+ border: 1px solid {accent_hover};
535
+ }}
536
+ """
537
+ )
538
+ cb.stateChanged.connect(lambda _=0, n=name: _on_checkbox_changed(n))
539
+ row_layout.addWidget(cb, 0, Qt.AlignmentFlag.AlignTop)
540
+
541
+ text_col = QWidget()
542
+ text_col_layout = QVBoxLayout(text_col)
543
+ text_col_layout.setContentsMargins(0, 0, 0, 0)
544
+ text_col_layout.setSpacing(4)
545
+
546
+ meta_row = QHBoxLayout()
547
+ meta_row.setContentsMargins(0, 0, 0, 0)
548
+ meta_row.setSpacing(8)
549
+
550
+ name_label = QLabel(name.upper())
551
+ name_label.setStyleSheet(
552
+ f"""
553
+ QLabel {{
554
+ color: {tool_name_color};
555
+ font-size: 12px;
556
+ font-weight: 800;
557
+ font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
558
+ }}
559
+ """
560
+ )
561
+ meta_row.addWidget(name_label)
562
+
563
+ if name in self._require_approval:
564
+ meta_row.addWidget(
565
+ _badge("APPROVAL", fg="#ffffff", bg=danger_border, border=danger_border)
566
+ )
567
+
568
+ meta_row.addStretch()
569
+ text_col_layout.addLayout(meta_row)
570
+
571
+ if desc:
572
+ desc_label = QLabel(desc)
573
+ desc_label.setWordWrap(True)
574
+ desc_label.setStyleSheet(f"QLabel {{ color: {text_muted}; font-size: 11px; }}")
575
+ text_col_layout.addWidget(desc_label)
576
+
577
+ row_layout.addWidget(text_col, 1)
578
+
579
+ border_color = danger_border if name in self._require_approval else accent_border
580
+ row.setStyleSheet(
581
+ f"""
582
+ QFrame#toolRow {{
583
+ background: {overlay_pressed};
584
+ border: 1px solid {border_color};
585
+ border-radius: 12px;
586
+ }}
587
+ """
588
+ )
589
+
590
+ list_layout.addWidget(row)
591
+ self._rows[name] = {"row": row, "checkbox": cb, "desc": desc}
592
+
593
+ list_layout.addStretch(1)
594
+
595
+ footer = QHBoxLayout()
596
+ footer.setSpacing(10)
597
+ footer.addStretch()
598
+
599
+ cancel_btn = QPushButton("Cancel")
600
+ save_btn = QPushButton("Save")
601
+ for b in (cancel_btn, save_btn):
602
+ b.setFixedHeight(34)
603
+ b.setStyleSheet(
604
+ f"""
605
+ QPushButton {{
606
+ background: {overlay};
607
+ border: 1px solid {mid_hex};
608
+ border-radius: 12px;
609
+ padding: 0 16px;
610
+ font-size: 12px;
611
+ font-weight: 700;
612
+ color: {text_primary};
613
+ }}
614
+ QPushButton:hover {{ background: {overlay_hover}; border: 1px solid {accent_hex}; }}
615
+ QPushButton:pressed {{ background: {overlay_pressed}; }}
616
+ """
617
+ )
618
+ save_btn.setStyleSheet(
619
+ f"""
620
+ QPushButton {{
621
+ background: {accent_hex};
622
+ border: 1px solid {accent_hover};
623
+ border-radius: 12px;
624
+ padding: 0 16px;
625
+ font-size: 12px;
626
+ font-weight: 800;
627
+ color: #ffffff;
628
+ }}
629
+ QPushButton:hover {{ background: {accent_hover}; border: 1px solid {accent_hover}; }}
630
+ QPushButton:pressed {{ background: {accent_pressed}; }}
631
+ """
632
+ )
633
+
634
+ cancel_btn.clicked.connect(self.reject)
635
+ save_btn.clicked.connect(self.accept)
636
+ footer.addWidget(cancel_btn)
637
+ footer.addWidget(save_btn)
638
+ layout.addLayout(footer)
639
+
640
+ def _set_mode(mode: str) -> None:
641
+ mode = "all" if mode == "all" else "custom"
642
+ self._mode = mode
643
+ self.all_mode_btn.setChecked(self._mode == "all")
644
+ self.custom_mode_btn.setChecked(self._mode == "custom")
645
+ self._suppress_checkbox_updates = True
646
+ try:
647
+ if self._mode == "all":
648
+ self._custom_selected = set(self._all_names_set)
649
+ for _, info in self._rows.items():
650
+ info["checkbox"].setEnabled(True)
651
+ info["checkbox"].setChecked(True)
652
+ else:
653
+ for n, info in self._rows.items():
654
+ info["checkbox"].setEnabled(True)
655
+ info["checkbox"].setChecked(n in self._custom_selected)
656
+ finally:
657
+ self._suppress_checkbox_updates = False
658
+
659
+ self._update_counts()
660
+
661
+ self.all_mode_btn.clicked.connect(lambda _=False: _set_mode("all"))
662
+ self.custom_mode_btn.clicked.connect(lambda _=False: _set_mode("custom"))
663
+
664
+ def _apply_filter() -> None:
665
+ q = (self.filter_input.text() or "").strip().lower()
666
+ for n, info in self._rows.items():
667
+ if not q:
668
+ info["row"].setVisible(True)
669
+ continue
670
+ hay = f"{n}\n{info.get('desc','')}".lower()
671
+ info["row"].setVisible(q in hay)
672
+
673
+ self.filter_input.textChanged.connect(lambda _=None: _apply_filter())
674
+
675
+ self.resize(720, 560)
676
+ _set_mode(self._mode)
677
+ _apply_filter()
678
+ self._update_counts()
679
+
680
+ def _update_counts(self) -> None:
681
+ total = len(self._all_names)
682
+ if self._mode == "all":
683
+ selected = total
684
+ else:
685
+ selected = len({n for n, info in self._rows.items() if info["checkbox"].isChecked()})
686
+ self.count_pill.setText(f"✓ {selected} of {total} selected")
687
+
688
+ def selected_tools(self) -> List[str]:
689
+ if self._mode == "all":
690
+ return list(self._all_names)
691
+ return sorted([n for n, info in self._rows.items() if info["checkbox"].isChecked()])
692
+
693
+
229
694
  class LLMWorker(QThread):
230
695
  """Worker thread for LLM processing."""
231
696
 
232
697
  response_ready = pyqtSignal(str)
233
698
  error_occurred = pyqtSignal(str)
234
699
 
235
- def __init__(self, llm_manager, message, provider, model, media=None):
700
+ def __init__(self, llm_manager, message, provider, model, media=None, debug: bool = False):
236
701
  super().__init__()
237
702
  self.llm_manager = llm_manager
238
703
  self.message = message
239
704
  self.provider = provider
240
705
  self.model = model
241
706
  self.media = media or []
707
+ self.debug = bool(debug)
242
708
 
243
709
  def run(self):
244
710
  """Run LLM processing in background."""
@@ -264,6 +730,221 @@ class LLMWorker(QThread):
264
730
  self.error_occurred.emit(str(e))
265
731
 
266
732
 
733
+ class AgentWorker(QThread):
734
+ """Worker thread that drives an AgentHost turn (tick/resume loop)."""
735
+
736
+ event_emitted = pyqtSignal(object) # dict payloads
737
+ error_occurred = pyqtSignal(str)
738
+
739
+ def __init__(
740
+ self,
741
+ *,
742
+ agent_host: AgentHost,
743
+ user_text: str,
744
+ provider: str,
745
+ model: str,
746
+ attachments: Optional[List[str]] = None,
747
+ system_prompt_extra: Optional[str] = None,
748
+ allowed_tools: Optional[List[str]] = None,
749
+ debug: bool = False,
750
+ ):
751
+ super().__init__()
752
+ self._agent_host = agent_host
753
+ self._user_text = str(user_text or "")
754
+ self._provider = str(provider or "")
755
+ self._model = str(model or "")
756
+ self._attachments = list(attachments or [])
757
+ self._system_prompt_extra = str(system_prompt_extra) if system_prompt_extra else None
758
+ self._allowed_tools = list(allowed_tools) if allowed_tools is not None else None
759
+ self._debug = bool(debug)
760
+
761
+ self._tool_approval_event = threading.Event()
762
+ self._tool_approval_decision: Optional[bool] = None
763
+ self._ask_user_event = threading.Event()
764
+ self._ask_user_response: Optional[str] = None
765
+
766
+ def provide_tool_approval(self, approved: bool) -> None:
767
+ self._tool_approval_decision = bool(approved)
768
+ self._tool_approval_event.set()
769
+
770
+ def provide_user_response(self, response: str) -> None:
771
+ self._ask_user_response = str(response or "")
772
+ self._ask_user_event.set()
773
+
774
+ def run(self) -> None:
775
+ try:
776
+ def _approve(_tool_calls):
777
+ return bool(self._tool_approval_decision)
778
+
779
+ def _ask_user(_wait):
780
+ return str(self._ask_user_response or "")
781
+
782
+ gen = self._agent_host.run_turn(
783
+ user_text=self._user_text,
784
+ attachments=self._attachments if self._attachments else None,
785
+ provider=self._provider,
786
+ model=self._model,
787
+ system_prompt_extra=self._system_prompt_extra,
788
+ allowed_tools=self._allowed_tools,
789
+ approve_tools=_approve,
790
+ ask_user=_ask_user,
791
+ )
792
+
793
+ while True:
794
+ try:
795
+ ev = next(gen)
796
+ except StopIteration:
797
+ return
798
+
799
+ self.event_emitted.emit(ev)
800
+
801
+ typ = ev.get("type") if isinstance(ev, dict) else None
802
+ if typ == "tool_request":
803
+ tool_calls = ev.get("tool_calls")
804
+ if isinstance(tool_calls, list) and not self._agent_host.tool_policy.requires_approval(tool_calls):
805
+ # Safe/read-only tool batch: auto-approve (no UI prompt required).
806
+ self._tool_approval_decision = True
807
+ continue
808
+ self._tool_approval_decision = None
809
+ self._tool_approval_event.clear()
810
+ self._tool_approval_event.wait()
811
+ if self._tool_approval_decision is None:
812
+ self._tool_approval_decision = False
813
+ continue
814
+
815
+ if typ == "ask_user":
816
+ self._ask_user_response = None
817
+ self._ask_user_event.clear()
818
+ self._ask_user_event.wait()
819
+ if self._ask_user_response is None:
820
+ self._ask_user_response = ""
821
+ continue
822
+
823
+ except Exception as e:
824
+ if self._debug:
825
+ import traceback
826
+
827
+ traceback.print_exc()
828
+ self.error_occurred.emit(str(e))
829
+
830
+
831
+ class _MessageInputRow(QWidget):
832
+ """
833
+ Two-column message input row with a strict-square (1:1) 3-button action column.
834
+
835
+ Design goals (per UX requirements):
836
+ - The action buttons fill the *full* vertical space of the input row (up to the card border)
837
+ - Buttons remain perfectly square while scaling with available height
838
+ - Exactly 1px vertical spacing between the 3 buttons
839
+ - The text input ends exactly where the action column begins (two columns)
840
+ """
841
+
842
+ def __init__(
843
+ self,
844
+ *,
845
+ parent: Optional[QWidget] = None,
846
+ h_spacing_px: int = 2,
847
+ v_spacing_px: int = 1,
848
+ min_button_px: int = 22,
849
+ min_text_width_px: int = 140,
850
+ ) -> None:
851
+ super().__init__(parent)
852
+ self._h_spacing_px = max(0, int(h_spacing_px))
853
+ self._v_spacing_px = max(0, int(v_spacing_px))
854
+ self._min_button_px = max(12, int(min_button_px))
855
+ self._min_text_width_px = max(40, int(min_text_width_px))
856
+
857
+ self.input_text = QTextEdit(self)
858
+ self.attach_button = QPushButton("📎", self)
859
+ self.tools_button = QPushButton("🛠", self)
860
+ self.send_button = QPushButton("→", self)
861
+ self._voice_mode: bool = False
862
+
863
+ # Keep focus/navigation sane.
864
+ for b in (self.attach_button, self.tools_button, self.send_button):
865
+ try:
866
+ b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
867
+ except Exception:
868
+ pass
869
+
870
+ def set_voice_mode(self, enabled: bool) -> None:
871
+ """
872
+ In voice mode we keep the action column, but hide the Send button because
873
+ end-of-sentence detection effectively acts as "send".
874
+ """
875
+ enabled = bool(enabled)
876
+ if self._voice_mode == enabled:
877
+ return
878
+ self._voice_mode = enabled
879
+ try:
880
+ self.send_button.setVisible(not enabled)
881
+ self.send_button.setEnabled(not enabled)
882
+ except Exception:
883
+ pass
884
+ self.updateGeometry()
885
+ self.update()
886
+
887
+ def _set_button_font_px(self, px: int) -> None:
888
+ px = max(10, int(px))
889
+ try:
890
+ for b in (self.attach_button, self.tools_button, self.send_button):
891
+ f = b.font() if hasattr(b, "font") else QFont()
892
+ try:
893
+ f.setPixelSize(px)
894
+ except Exception:
895
+ # Fallback: point size if pixel sizing isn't available.
896
+ f.setPointSize(max(9, px // 2))
897
+ b.setFont(f)
898
+ except Exception:
899
+ pass
900
+
901
+ def resizeEvent(self, event):
902
+ try:
903
+ super().resizeEvent(event)
904
+ except Exception:
905
+ pass
906
+
907
+ w = int(self.width())
908
+ h = int(self.height())
909
+ if w <= 0 or h <= 0:
910
+ return
911
+
912
+ # Compute a square button size that uses as much vertical space as possible.
913
+ # With N buttons and (N-1) gaps:
914
+ # used_h = N*btn + (N-1)*v_spacing <= h
915
+ action_buttons = (self.attach_button, self.tools_button) if self._voice_mode else (self.attach_button, self.tools_button, self.send_button)
916
+ n = max(1, len(action_buttons))
917
+ btn_from_h = (h - ((n - 1) * self._v_spacing_px)) // n
918
+ btn = max(self._min_button_px, btn_from_h)
919
+
920
+ # Ensure the column also fits horizontally (keep a minimum text width).
921
+ max_btn_from_w = w - self._min_text_width_px - self._h_spacing_px
922
+ if max_btn_from_w <= 0:
923
+ max_btn_from_w = self._min_button_px
924
+ btn = max(self._min_button_px, min(btn, max_btn_from_w))
925
+
926
+ input_w = max(10, w - btn - self._h_spacing_px)
927
+ col_x = input_w + self._h_spacing_px
928
+
929
+ # Place the text input to fill the available height.
930
+ self.input_text.setGeometry(0, 0, input_w, h)
931
+
932
+ # Place the square buttons stacked on the right with strict spacing.
933
+ # We anchor to the top to keep the tiny leftover (0-2px) at the bottom.
934
+ used_h = (n * btn) + ((n - 1) * self._v_spacing_px)
935
+ top = 0
936
+ if used_h < h:
937
+ # If there's slack (usually 0-2px), split it so borders look even.
938
+ top = (h - used_h) // 2
939
+
940
+ for i, b in enumerate(action_buttons):
941
+ y = top + i * (btn + self._v_spacing_px)
942
+ b.setGeometry(col_x, y, btn, btn)
943
+
944
+ # Scale icon glyphs with the square size.
945
+ self._set_button_font_px(int(btn * 0.55))
946
+
947
+
267
948
  class QtChatBubble(QWidget):
268
949
  """Modern Qt-based chat bubble."""
269
950
 
@@ -273,6 +954,7 @@ class QtChatBubble(QWidget):
273
954
  self.config = config
274
955
  self.debug = debug
275
956
  self.listening_mode = listening_mode
957
+ self._theme: Dict[str, Any] = {}
276
958
 
277
959
  # State - default to LMStudio with qwen/qwen3-next-80b
278
960
  self.current_provider = 'lmstudio' # Default to LMStudio
@@ -282,6 +964,11 @@ class QtChatBubble(QWidget):
282
964
 
283
965
  # Message history for session management
284
966
  self.message_history: List[Dict] = []
967
+ self._session_auto_approve_tools: set[str] = set()
968
+ self._voice_busy: bool = False
969
+ # Full Voice Mode lifecycle: treat "running" as separate from the toggle state so
970
+ # late callbacks cannot keep the UI in LISTENING after a user-initiated stop.
971
+ self._full_voice_running: bool = False
285
972
 
286
973
  # History dialog instance for toggle behavior
287
974
  self.history_dialog = None
@@ -291,6 +978,13 @@ class QtChatBubble(QWidget):
291
978
 
292
979
  # Track file attachments per message for history display
293
980
  self.message_file_attachments: Dict[int, List[str]] = {}
981
+
982
+ # Tool selection (external tools): controls the per-run allowlist passed to AbstractAgent.
983
+ self._available_external_tools: List[Dict[str, str]] = []
984
+ self._enabled_external_tools: set[str] = set()
985
+ self._safe_external_tools: set[str] = set()
986
+ self._require_approval_tools: set[str] = set()
987
+ self._refresh_tool_inventory()
294
988
 
295
989
  # Initialize new manager classes
296
990
  self.provider_manager = None
@@ -331,6 +1025,19 @@ class QtChatBubble(QWidget):
331
1025
  self.setup_ui()
332
1026
  self.setup_styling()
333
1027
  self.load_providers()
1028
+
1029
+ # Bootstrap UI state from the durable active session (tokens/history).
1030
+ try:
1031
+ if self.llm_manager and hasattr(self.llm_manager, "refresh"):
1032
+ self.llm_manager.refresh()
1033
+ except Exception:
1034
+ pass
1035
+ try:
1036
+ self._update_message_history_from_session()
1037
+ self._update_token_count_from_session()
1038
+ self._reload_session_combo()
1039
+ except Exception:
1040
+ pass
334
1041
 
335
1042
  if self.debug:
336
1043
  print("✅ QtChatBubble initialized")
@@ -356,15 +1063,29 @@ class QtChatBubble(QWidget):
356
1063
  def setup_ui(self):
357
1064
  """Set up the modern user interface with SOTA UX practices."""
358
1065
  self.setWindowTitle("AbstractAssistant")
1066
+ self.setObjectName("AbstractAssistantBubble")
1067
+ try:
1068
+ attr = getattr(Qt, "WA_StyledBackground", None)
1069
+ if attr is None and hasattr(Qt, "WidgetAttribute"):
1070
+ attr = Qt.WidgetAttribute.WA_StyledBackground
1071
+ if attr is not None:
1072
+ self.setAttribute(attr, True)
1073
+ except Exception:
1074
+ pass
359
1075
  self.setWindowFlags(
360
1076
  Qt.WindowType.FramelessWindowHint |
361
1077
  Qt.WindowType.WindowStaysOnTopHint |
362
1078
  Qt.WindowType.Tool
363
1079
  )
1080
+ try:
1081
+ self.setWindowOpacity(0.97)
1082
+ except Exception:
1083
+ pass
364
1084
 
365
- # Set optimal size for modern chat interface - much wider to nearly touch screen edge
1085
+ # Set optimal size for modern chat interface.
1086
+ # Keep the default lightweight and compact: ~15% narrower than the previous 630px.
366
1087
  # Initial size - will be adjusted dynamically based on file attachments
367
- self.base_width = 630
1088
+ self.base_width = 536
368
1089
  self.base_height = 196
369
1090
  self.setFixedSize(self.base_width, self.base_height)
370
1091
  self.position_near_tray()
@@ -399,40 +1120,102 @@ class QtChatBubble(QWidget):
399
1120
  }
400
1121
  """)
401
1122
  header_layout.addWidget(self.close_button)
402
-
403
- # Session buttons (minimal, rounded)
404
- session_buttons = [
405
- ("Clear", self.clear_session),
406
- ("Load", self.load_session),
407
- ("Save", self.save_session),
408
- # ("Compact", self.compact_session), # Hidden for now - functionality preserved
409
- ("History", self.show_history)
410
- ]
411
-
412
- for text, handler in session_buttons:
413
- btn = QPushButton(text)
414
- btn.setFixedHeight(22)
415
- btn.clicked.connect(handler)
416
-
417
- # Store reference to history button for toggle appearance
418
- if text == "History":
419
- self.history_button = btn
420
- btn.setStyleSheet("""
421
- QPushButton {
422
- background: rgba(255, 255, 255, 0.06);
423
- border: none;
424
- border-radius: 11px;
425
- font-size: 10px;
426
- color: rgba(255, 255, 255, 0.7);
427
- font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
428
- padding: 0 10px;
429
- }
430
- QPushButton:hover {
431
- background: rgba(255, 255, 255, 0.12);
432
- color: rgba(255, 255, 255, 0.9);
433
- }
434
- """)
435
- header_layout.addWidget(btn)
1123
+
1124
+ # Session selector + New session (replaces legacy "Clear" in the header).
1125
+ self.session_combo = QComboBox()
1126
+ self.session_combo.setFixedHeight(22)
1127
+ self.session_combo.setMinimumWidth(160)
1128
+ self.session_combo.setToolTip("Select a session")
1129
+ self.session_combo.setStyleSheet("""
1130
+ QComboBox {
1131
+ background: rgba(255, 255, 255, 0.06);
1132
+ border: none;
1133
+ border-radius: 6px;
1134
+ font-size: 10px;
1135
+ color: rgba(255, 255, 255, 0.8);
1136
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1137
+ padding: 0 10px;
1138
+ }
1139
+ QComboBox:hover {
1140
+ background: rgba(255, 255, 255, 0.12);
1141
+ color: rgba(255, 255, 255, 0.9);
1142
+ }
1143
+ QComboBox::drop-down {
1144
+ border: none;
1145
+ }
1146
+ """)
1147
+ try:
1148
+ self.session_combo.view().setMinimumWidth(420)
1149
+ except Exception:
1150
+ pass
1151
+ self.session_combo.currentIndexChanged.connect(self._on_session_combo_changed)
1152
+ header_layout.addWidget(self.session_combo)
1153
+
1154
+ self.new_session_button = QPushButton("New")
1155
+ self.new_session_button.setFixedHeight(22)
1156
+ self.new_session_button.setToolTip("Start a new session")
1157
+ self.new_session_button.clicked.connect(self._start_new_session)
1158
+ self.new_session_button.setStyleSheet("""
1159
+ QPushButton {
1160
+ background: rgba(255, 255, 255, 0.06);
1161
+ border: none;
1162
+ border-radius: 6px;
1163
+ font-size: 10px;
1164
+ color: rgba(255, 255, 255, 0.7);
1165
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1166
+ padding: 0 10px;
1167
+ }
1168
+ QPushButton:hover {
1169
+ background: rgba(255, 255, 255, 0.12);
1170
+ color: rgba(255, 255, 255, 0.9);
1171
+ }
1172
+ """)
1173
+ header_layout.addWidget(self.new_session_button)
1174
+
1175
+ # Overflow menu to reduce header clutter (Load/Save/Debug actions).
1176
+ self.more_button = QPushButton("⋯")
1177
+ self.more_button.setFixedSize(28, 22)
1178
+ self.more_button.setToolTip("More")
1179
+ self.more_button.clicked.connect(self._show_more_menu)
1180
+ self.more_button.setStyleSheet("""
1181
+ QPushButton {
1182
+ background: rgba(255, 255, 255, 0.06);
1183
+ border: none;
1184
+ border-radius: 6px;
1185
+ font-size: 14px;
1186
+ color: rgba(255, 255, 255, 0.7);
1187
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1188
+ padding: 0px;
1189
+ }
1190
+ QPushButton:hover {
1191
+ background: rgba(255, 255, 255, 0.12);
1192
+ color: rgba(255, 255, 255, 0.9);
1193
+ }
1194
+ """)
1195
+ header_layout.addWidget(self.more_button)
1196
+
1197
+ # Messages/history button (user-facing transcript).
1198
+ self.history_button = QPushButton("💬")
1199
+ self.history_button.setFixedHeight(22)
1200
+ self.history_button.setFixedWidth(28)
1201
+ self.history_button.setToolTip("Messages")
1202
+ self.history_button.clicked.connect(self.show_history)
1203
+ self.history_button.setStyleSheet("""
1204
+ QPushButton {
1205
+ background: rgba(255, 255, 255, 0.06);
1206
+ border: none;
1207
+ border-radius: 11px;
1208
+ font-size: 12px;
1209
+ color: rgba(255, 255, 255, 0.7);
1210
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1211
+ padding: 0px;
1212
+ }
1213
+ QPushButton:hover {
1214
+ background: rgba(255, 255, 255, 0.12);
1215
+ color: rgba(255, 255, 255, 0.9);
1216
+ }
1217
+ """)
1218
+ header_layout.addWidget(self.history_button)
436
1219
 
437
1220
  # TTS toggle (if available)
438
1221
  if self.voice_manager and self.voice_manager.is_available():
@@ -453,7 +1236,9 @@ class QtChatBubble(QWidget):
453
1236
 
454
1237
  # Status (Cursor-style, enlarged to show full text including "Processing")
455
1238
  self.status_label = QLabel("READY")
456
- self.status_label.setFixedSize(120, 24) # Increased from 80x24 to 120x24 for "Processing" text
1239
+ self.status_label.setFixedHeight(24)
1240
+ self.status_label.setMinimumWidth(92)
1241
+ self.status_label.setMaximumWidth(120)
457
1242
  self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
458
1243
  self.status_label.setStyleSheet("""
459
1244
  QLabel {
@@ -472,94 +1257,106 @@ class QtChatBubble(QWidget):
472
1257
 
473
1258
  # Input section with modern card design
474
1259
  self.input_container = QFrame()
1260
+ self.input_container.setObjectName("inputContainer")
475
1261
  self.input_container.setStyleSheet("""
476
1262
  QFrame {
477
1263
  background: #2a2a2a;
478
1264
  border: 1px solid #404040;
479
- border-radius: 8px;
1265
+ border-radius: 6px;
480
1266
  padding: 4px;
481
1267
  }
482
1268
  """)
483
1269
  input_layout = QVBoxLayout(self.input_container)
484
- input_layout.setContentsMargins(4, 4, 4, 4)
485
- input_layout.setSpacing(4)
486
-
487
- # Input field with inline send button
488
- input_row = QHBoxLayout()
489
- input_row.setSpacing(4)
1270
+ # Keep this tight so the right-side action column visually reaches the card border.
1271
+ input_layout.setContentsMargins(2, 2, 2, 2)
1272
+ input_layout.setSpacing(2)
1273
+
1274
+ # Deterministic manual-geometry input row (no layout race on first show).
1275
+ self._input_row = _MessageInputRow(parent=self.input_container, h_spacing_px=2, v_spacing_px=1)
1276
+ self._input_row.setMinimumHeight(86)
1277
+ input_layout.addWidget(self._input_row, 1)
1278
+
1279
+ # Expose the child widgets on the bubble for the rest of the codebase.
1280
+ self.input_text = self._input_row.input_text
1281
+ self.attach_button = self._input_row.attach_button
1282
+ self.tools_button = self._input_row.tools_button
1283
+ self.send_button = self._input_row.send_button
490
1284
 
491
- # File attachment button - modern paperclip icon
492
- self.attach_button = QPushButton("📎")
1285
+ self.input_text.setPlaceholderText("Ask me anything... (Shift+Enter to send)")
1286
+ try:
1287
+ self.input_text.installEventFilter(self)
1288
+ except Exception:
1289
+ pass
1290
+
1291
+ # Wiring
493
1292
  self.attach_button.clicked.connect(self.attach_files)
494
- self.attach_button.setFixedSize(36, 36)
495
1293
  self.attach_button.setToolTip("Attach files (images, PDFs, Office docs, etc.)")
496
- self.attach_button.setStyleSheet("""
1294
+ self.tools_button.clicked.connect(self.open_tool_selector)
1295
+ self.tools_button.setToolTip("Tools")
1296
+ self.send_button.clicked.connect(self.send_message)
1297
+
1298
+ # Styling (theme methods will override these too; these are safe defaults for first paint)
1299
+ self.attach_button.setStyleSheet(
1300
+ """
497
1301
  QPushButton {
498
1302
  background: rgba(255, 255, 255, 0.08);
499
1303
  border: 1px solid #404040;
500
- border-radius: 18px;
501
- font-size: 14px;
502
- color: rgba(255, 255, 255, 0.7);
503
- text-align: center;
1304
+ border-radius: 4px;
1305
+ color: rgba(255, 255, 255, 0.75);
504
1306
  padding: 0px;
1307
+ margin: 0px;
505
1308
  }
506
-
507
1309
  QPushButton:hover {
508
1310
  background: rgba(255, 255, 255, 0.12);
509
1311
  border: 1px solid #0066cc;
510
- color: rgba(255, 255, 255, 0.9);
1312
+ color: rgba(255, 255, 255, 0.95);
511
1313
  }
512
-
513
- QPushButton:pressed {
514
- background: rgba(255, 255, 255, 0.06);
515
- }
516
- """)
517
- input_row.addWidget(self.attach_button)
518
-
519
- # Text input - larger, primary focus
520
- self.input_text = QTextEdit()
521
- self.input_text.setPlaceholderText("Ask me anything... (Shift+Enter to send)")
522
- self.input_text.setMaximumHeight(100) # Increased to better use available space
523
- self.input_text.setMinimumHeight(70) # Increased to better use available space
524
- input_row.addWidget(self.input_text)
525
-
526
- # Send button - primary action with special styling
527
- self.send_button = QPushButton("→")
528
- self.send_button.clicked.connect(self.send_message)
529
- self.send_button.setFixedSize(40, 40)
530
- self.send_button.setStyleSheet("""
1314
+ QPushButton:pressed { background: rgba(255, 255, 255, 0.06); }
1315
+ """
1316
+ )
1317
+ self.tools_button.setStyleSheet(
1318
+ """
531
1319
  QPushButton {
532
- background: #0066cc;
533
- border: 1px solid #0080ff;
534
- border-radius: 20px;
535
- font-size: 16px;
536
- font-weight: bold;
537
- color: white;
538
- text-align: center;
539
- padding: 0px;
540
- }
541
-
1320
+ background: rgba(255, 255, 255, 0.08);
1321
+ border: 1px solid #404040;
1322
+ border-radius: 4px;
1323
+ color: rgba(255, 255, 255, 0.75);
1324
+ padding: 0px;
1325
+ margin: 0px;
1326
+ }
542
1327
  QPushButton:hover {
543
- background: #0080ff;
544
- border: 1px solid #0099ff;
1328
+ background: rgba(255, 255, 255, 0.12);
1329
+ border: 1px solid #7c3aed;
1330
+ color: rgba(255, 255, 255, 0.95);
545
1331
  }
546
-
547
- QPushButton:pressed {
548
- background: #0052a3;
1332
+ QPushButton:pressed { background: rgba(255, 255, 255, 0.06); }
1333
+ """
1334
+ )
1335
+ self.send_button.setStyleSheet(
1336
+ """
1337
+ QPushButton {
1338
+ background: #0066cc;
1339
+ border: 1px solid #0080ff;
1340
+ border-radius: 4px;
1341
+ font-weight: 700;
1342
+ color: #ffffff;
1343
+ padding: 0px;
1344
+ margin: 0px;
549
1345
  }
550
-
1346
+ QPushButton:hover { background: #0080ff; border: 1px solid #0099ff; }
1347
+ QPushButton:pressed { background: #0052a3; }
551
1348
  QPushButton:disabled {
552
1349
  background: #404040;
553
1350
  color: #666666;
554
1351
  border: 1px solid #333333;
555
1352
  }
556
- """)
557
- input_row.addWidget(self.send_button)
558
-
559
- input_layout.addLayout(input_row)
1353
+ """
1354
+ )
1355
+ self._update_tools_button_state()
560
1356
 
561
1357
  # Attached files display area (initially hidden)
562
1358
  self.attached_files_container = QFrame()
1359
+ self.attached_files_container.setObjectName("attachedFilesContainer")
563
1360
  self.attached_files_container.setStyleSheet("""
564
1361
  QFrame {
565
1362
  background: rgba(255, 255, 255, 0.04);
@@ -675,209 +1472,364 @@ class QtChatBubble(QWidget):
675
1472
 
676
1473
  # Enter key handling
677
1474
  self.input_text.keyPressEvent = self.handle_key_press
678
-
679
- def setup_styling(self):
680
- """Set up Cursor-style clean theme."""
681
- self.setStyleSheet("""
682
- /* Main Window - Cursor Style */
683
- QWidget {
684
- background: #1e1e1e;
685
- border: none;
1475
+
1476
+ # Populate session selector (durable multi-session).
1477
+ try:
1478
+ self._reload_session_combo(select_session_id=getattr(self.llm_manager, "active_session_id", None))
1479
+ except Exception:
1480
+ pass
1481
+
1482
+ def _compute_theme(self) -> Dict[str, Any]:
1483
+ palette = QApplication.instance().palette() if QApplication.instance() else self.palette()
1484
+ window = palette.window().color()
1485
+ base = palette.base().color()
1486
+ mid = palette.mid().color()
1487
+ text = palette.text().color()
1488
+ accent = palette.highlight().color()
1489
+
1490
+ is_dark = window.lightness() < 128
1491
+
1492
+ def rgba(color: QColor, alpha: float) -> str:
1493
+ return f"rgba({color.red()}, {color.green()}, {color.blue()}, {alpha})"
1494
+
1495
+ overlay = "rgba(255, 255, 255, 0.08)" if is_dark else "rgba(0, 0, 0, 0.06)"
1496
+ overlay_hover = "rgba(255, 255, 255, 0.12)" if is_dark else "rgba(0, 0, 0, 0.10)"
1497
+ overlay_pressed = "rgba(255, 255, 255, 0.06)" if is_dark else "rgba(0, 0, 0, 0.04)"
1498
+
1499
+ focus_bg = base.lighter(112) if is_dark else base.darker(102)
1500
+ accent_hover = accent.lighter(115)
1501
+ accent_pressed = accent.darker(115)
1502
+
1503
+ return {
1504
+ "is_dark": is_dark,
1505
+ "window_bg": window.name(),
1506
+ "surface_bg": base.name(),
1507
+ "surface_focus_bg": focus_bg.name(),
1508
+ "border": mid.name(),
1509
+ "text_primary": rgba(text, 0.9),
1510
+ "text_secondary": rgba(text, 0.72),
1511
+ "text_muted": rgba(text, 0.55 if is_dark else 0.5),
1512
+ "accent": accent.name(),
1513
+ "accent_hover": accent_hover.name(),
1514
+ "accent_pressed": accent_pressed.name(),
1515
+ "accent_rgba_12": rgba(accent, 0.12),
1516
+ "accent_rgba_20": rgba(accent, 0.20),
1517
+ "accent_rgba_35": rgba(accent, 0.35),
1518
+ "overlay": overlay,
1519
+ "overlay_hover": overlay_hover,
1520
+ "overlay_pressed": overlay_pressed,
1521
+ }
1522
+
1523
+ def _apply_theme(self) -> None:
1524
+ t = self._theme or self._compute_theme()
1525
+
1526
+ input_focused = False
1527
+ try:
1528
+ input_focused = bool(getattr(self, "input_text", None) and self.input_text.hasFocus())
1529
+ except Exception:
1530
+ input_focused = False
1531
+ input_border = t["accent"] if input_focused else t["border"]
1532
+
1533
+ # Window
1534
+ self.setStyleSheet(
1535
+ f"""
1536
+ QWidget#AbstractAssistantBubble {{
1537
+ background: {t['window_bg']};
1538
+ border: 1px solid {t['border']};
686
1539
  border-radius: 12px;
687
- color: #ffffff;
688
- }
689
-
690
- /* Input Field - Modern Grey Design */
691
- QTextEdit {
692
- background: #2a2a2a;
693
- border: 1px solid #404040;
694
- border-radius: 8px;
695
- padding: 12px 16px;
696
- font-size: 14px;
697
- font-weight: 400;
698
- color: #ffffff;
699
- font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
700
- selection-background-color: #0066cc;
701
- line-height: 1.4;
702
- }
703
-
704
- QTextEdit:focus {
705
- border: 1px solid #0066cc;
706
- background: #333333;
707
- }
708
-
709
- QTextEdit::placeholder {
710
- color: rgba(255, 255, 255, 0.6);
711
- }
712
-
713
- /* Buttons - Grey Theme */
714
- QPushButton {
715
- background: #404040;
716
- border: 1px solid #555555;
717
- border-radius: 4px;
718
- padding: 4px 8px;
719
- font-size: 11px;
720
- font-weight: 500;
721
- color: #ffffff;
722
- font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
723
- }
724
-
725
- QPushButton:hover {
726
- background: #505050;
727
- border: 1px solid #0066cc;
728
- }
729
-
730
- QPushButton:pressed {
731
- background: #353535;
732
- }
733
-
734
- QPushButton:disabled {
735
- background: #2a2a2a;
736
- color: #666666;
737
- border: 1px solid #333333;
738
- }
739
-
740
- /* Dropdown Menus - Grey Theme */
741
- QComboBox {
742
- background: #1e1e1e;
743
- border: 1px solid #404040;
1540
+ color: {t['text_primary']};
1541
+ }}
1542
+ """
1543
+ )
1544
+
1545
+ # Input container + text input
1546
+ if hasattr(self, "input_container"):
1547
+ self.input_container.setStyleSheet(
1548
+ f"""
1549
+ QFrame#inputContainer {{
1550
+ background: {t['surface_bg']};
1551
+ border: 1px solid {input_border};
744
1552
  border-radius: 6px;
745
- padding: 6px 10px;
746
- min-width: 80px;
747
- font-size: 12px;
1553
+ padding: 4px;
1554
+ }}
1555
+ """
1556
+ )
1557
+ if hasattr(self, "input_text"):
1558
+ self.input_text.setStyleSheet(
1559
+ f"""
1560
+ QTextEdit {{
1561
+ background: transparent;
1562
+ border: none;
1563
+ padding: 4px 8px;
1564
+ font-size: 14px;
748
1565
  font-weight: 400;
749
- color: #ffffff;
1566
+ color: {t['text_primary']};
750
1567
  font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
751
- letter-spacing: 0.01em;
752
- }
753
-
754
- QComboBox:hover {
755
- border: 1px solid #0066cc;
756
- background: #252525;
757
- }
758
-
759
- QComboBox::drop-down {
760
- border: none;
761
- width: 20px;
762
- }
763
-
764
- QComboBox::down-arrow {
765
- image: none;
766
- border-left: 5px solid transparent;
767
- border-right: 5px solid transparent;
768
- border-top: 5px solid rgba(255, 255, 255, 0.7);
769
- width: 0px;
770
- height: 0px;
771
- }
772
-
773
- QComboBox QAbstractItemView {
774
- background: #252525;
775
- border: 1px solid #404040;
776
- border-radius: 8px;
777
- selection-background-color: #404040;
1568
+ selection-background-color: {t['accent']};
1569
+ line-height: 1.4;
1570
+ }}
1571
+
1572
+ QTextEdit:focus {{
1573
+ background: transparent;
1574
+ }}
1575
+
1576
+ QTextEdit::placeholder {{
1577
+ color: {t['text_muted']};
1578
+ }}
1579
+ """
1580
+ )
1581
+
1582
+ # Header controls
1583
+ pill_qss = f"""
1584
+ background: {t['overlay_pressed']};
1585
+ border: none;
1586
+ border-radius: 11px;
1587
+ font-size: 10px;
1588
+ color: {t['text_secondary']};
1589
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1590
+ padding: 0 10px;
1591
+ """
1592
+ pill_hover = f"background: {t['overlay_hover']}; color: {t['text_primary']};"
1593
+
1594
+ if hasattr(self, "session_combo"):
1595
+ self.session_combo.setStyleSheet(
1596
+ f"""
1597
+ QComboBox {{ {pill_qss} }}
1598
+ QComboBox:hover {{ {pill_hover} }}
1599
+ QComboBox::drop-down {{ border: none; }}
1600
+ """
1601
+ )
1602
+
1603
+ if hasattr(self, "new_session_button"):
1604
+ self.new_session_button.setStyleSheet(
1605
+ f"""
1606
+ QPushButton {{ {pill_qss} }}
1607
+ QPushButton:hover {{ {pill_hover} }}
1608
+ """
1609
+ )
1610
+
1611
+ if hasattr(self, "more_button"):
1612
+ self.more_button.setStyleSheet(
1613
+ f"""
1614
+ QPushButton {{
1615
+ background: {t['overlay_pressed']};
1616
+ border: none;
1617
+ border-radius: 11px;
1618
+ font-size: 16px;
1619
+ color: {t['text_secondary']};
1620
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1621
+ padding: 0px;
1622
+ }}
1623
+ QPushButton:hover {{
1624
+ background: {t['overlay_hover']};
1625
+ color: {t['text_primary']};
1626
+ }}
1627
+ """
1628
+ )
1629
+
1630
+ if hasattr(self, "history_button"):
1631
+ self.history_button.setStyleSheet(
1632
+ f"""
1633
+ QPushButton {{
1634
+ background: {t['overlay_pressed']};
1635
+ border: none;
1636
+ border-radius: 11px;
1637
+ font-size: 12px;
1638
+ color: {t['text_secondary']};
1639
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1640
+ padding: 0px;
1641
+ }}
1642
+ QPushButton:hover {{
1643
+ background: {t['overlay_hover']};
1644
+ color: {t['text_primary']};
1645
+ }}
1646
+ """
1647
+ )
1648
+
1649
+ if hasattr(self, "close_button"):
1650
+ self.close_button.setStyleSheet(
1651
+ f"""
1652
+ QPushButton {{
1653
+ background: {t['overlay_hover']};
1654
+ border: none;
1655
+ border-radius: 12px;
1656
+ font-size: 14px;
1657
+ font-weight: 600;
1658
+ color: {t['text_primary']};
1659
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1660
+ }}
1661
+ QPushButton:hover {{
1662
+ background: rgba(255, 60, 60, 0.85);
778
1663
  color: #ffffff;
779
- padding: 6px;
780
- font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
781
- }
1664
+ }}
1665
+ QPushButton:pressed {{
1666
+ background: rgba(255, 60, 60, 0.65);
1667
+ }}
1668
+ """
1669
+ )
782
1670
 
783
- QComboBox QAbstractItemView::item {
784
- height: 44px;
785
- padding: 10px 16px;
1671
+ # Input action buttons (stretch to fill vertical space)
1672
+ icon_btn_qss = f"""
1673
+ QPushButton {{
1674
+ background: {t['overlay']};
1675
+ border: 1px solid {t['border']};
1676
+ border-radius: 4px;
1677
+ color: {t['text_secondary']};
1678
+ text-align: center;
1679
+ padding: 0px;
1680
+ margin: 0px;
1681
+ }}
1682
+ QPushButton:hover {{
1683
+ background: {t['overlay_hover']};
1684
+ border: 1px solid {t['accent']};
1685
+ color: {t['text_primary']};
1686
+ }}
1687
+ QPushButton:pressed {{
1688
+ background: {t['overlay_pressed']};
1689
+ }}
1690
+ """
1691
+ if hasattr(self, "attach_button"):
1692
+ self.attach_button.setStyleSheet(icon_btn_qss)
1693
+
1694
+ # tools_button style is handled via _update_tools_button_state()
1695
+
1696
+ if hasattr(self, "send_button"):
1697
+ self.send_button.setStyleSheet(
1698
+ f"""
1699
+ QPushButton {{
1700
+ background: {t['accent']};
1701
+ border: 1px solid {t['accent_hover']};
1702
+ border-radius: 4px;
1703
+ font-weight: bold;
1704
+ color: #ffffff;
1705
+ text-align: center;
1706
+ padding: 0px;
1707
+ margin: 0px;
1708
+ }}
1709
+ QPushButton:hover {{
1710
+ background: {t['accent_hover']};
1711
+ border: 1px solid {t['accent_hover']};
1712
+ }}
1713
+ QPushButton:pressed {{
1714
+ background: {t['accent_pressed']};
1715
+ }}
1716
+ QPushButton:disabled {{
1717
+ background: {t['overlay_pressed']};
1718
+ color: {t['text_muted']};
1719
+ border: 1px solid {t['border']};
1720
+ }}
1721
+ """
1722
+ )
1723
+
1724
+ # Bottom controls
1725
+ if hasattr(self, "provider_combo"):
1726
+ self.provider_combo.setStyleSheet(
1727
+ f"""
1728
+ QComboBox {{
1729
+ background: {t['overlay']};
786
1730
  border: none;
787
- font-size: 13px;
788
- font-weight: 400;
789
- color: #e8e8e8;
1731
+ border-radius: 14px;
1732
+ padding: 0 8px;
1733
+ font-size: 11px;
1734
+ color: {t['text_primary']};
1735
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1736
+ }}
1737
+ QComboBox:hover {{ background: {t['overlay_hover']}; }}
1738
+ QComboBox::drop-down {{ border: none; width: 20px; }}
1739
+ QComboBox::down-arrow {{ image: none; border: none; width: 0px; }}
1740
+ """
1741
+ )
1742
+
1743
+ if hasattr(self, "model_combo"):
1744
+ self.model_combo.setStyleSheet(
1745
+ f"""
1746
+ QComboBox {{
1747
+ background: {t['overlay']};
1748
+ border: none;
1749
+ border-radius: 14px;
1750
+ padding: 0 8px;
1751
+ font-size: 11px;
1752
+ color: {t['text_primary']};
1753
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1754
+ }}
1755
+ QComboBox:hover {{ background: {t['overlay_hover']}; }}
1756
+ QComboBox::drop-down {{ border: none; width: 20px; }}
1757
+ QComboBox::down-arrow {{ image: none; border: none; width: 0px; }}
1758
+ """
1759
+ )
1760
+
1761
+ if hasattr(self, "token_label"):
1762
+ self.token_label.setStyleSheet(
1763
+ f"""
1764
+ QLabel {{
1765
+ background: {t['overlay_pressed']};
1766
+ border: none;
1767
+ border-radius: 14px;
1768
+ font-size: 12px;
1769
+ color: {t['text_muted']};
1770
+ font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
1771
+ }}
1772
+ """
1773
+ )
1774
+
1775
+ # Attached files container
1776
+ if hasattr(self, "attached_files_container"):
1777
+ self.attached_files_container.setStyleSheet(
1778
+ f"""
1779
+ QFrame#attachedFilesContainer {{
1780
+ background: {t['overlay_pressed']};
1781
+ border: 1px solid {t['border']};
790
1782
  border-radius: 6px;
791
- margin: 3px;
792
- letter-spacing: 0.02em;
793
- }
1783
+ padding: 4px;
1784
+ }}
1785
+ """
1786
+ )
794
1787
 
795
- QComboBox QAbstractItemView::item:selected {
796
- background: #404040;
797
- color: #ffffff;
798
- font-weight: 500;
799
- border: 1px solid #0066cc;
800
- }
1788
+ def eventFilter(self, obj, event):
1789
+ try:
1790
+ if obj is getattr(self, "input_text", None):
1791
+ etype = event.type()
1792
+ focus_in = getattr(QEvent, "FocusIn", None)
1793
+ focus_out = getattr(QEvent, "FocusOut", None)
1794
+ if focus_in is None and hasattr(QEvent, "Type"):
1795
+ focus_in = QEvent.Type.FocusIn
1796
+ focus_out = QEvent.Type.FocusOut
1797
+ if etype in {focus_in, focus_out}:
1798
+ try:
1799
+ self._apply_theme()
1800
+ except Exception:
1801
+ pass
1802
+ except Exception:
1803
+ pass
1804
+ return super().eventFilter(obj, event)
801
1805
 
802
- QComboBox QAbstractItemView::item:hover {
803
- background: #333333;
804
- }
805
-
806
- /* Labels - Clean Typography */
807
- QLabel {
808
- color: rgba(255, 255, 255, 0.8);
809
- font-size: 12px;
810
- font-weight: 500;
811
- font-family: "Helvetica Neue", "Helvetica", 'Segoe UI', Arial, sans-serif;
812
- letter-spacing: 0.3px;
813
- }
814
-
815
- /* Status and Token Labels - Accent Colors */
816
- QLabel#status_ready {
817
- background: rgba(166, 227, 161, 0.15);
818
- border: 1px solid rgba(166, 227, 161, 0.3);
819
- border-radius: 12px;
820
- padding: 6px 12px;
821
- font-size: 10px;
822
- font-weight: 600;
823
- text-transform: uppercase;
824
- letter-spacing: 0.5px;
825
- color: #a6e3a1;
826
- font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
827
- }
828
-
829
- QLabel#status_generating {
830
- background: rgba(250, 179, 135, 0.15);
831
- border: 1px solid rgba(250, 179, 135, 0.3);
832
- border-radius: 12px;
833
- padding: 6px 12px;
834
- font-size: 10px;
835
- font-weight: 600;
836
- text-transform: uppercase;
837
- letter-spacing: 0.5px;
838
- color: #fab387;
839
- font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
840
- }
841
-
842
- QLabel#status_error {
843
- background: rgba(243, 139, 168, 0.15);
844
- border: 1px solid rgba(243, 139, 168, 0.3);
845
- border-radius: 12px;
846
- padding: 6px 12px;
847
- font-size: 10px;
848
- font-weight: 600;
849
- text-transform: uppercase;
850
- letter-spacing: 0.5px;
851
- color: #f38ba8;
852
- font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
853
- }
854
-
855
- QLabel#token_label {
856
- background: #2d3748;
857
- border: 1px solid #4a5568;
858
- border-radius: 8px;
859
- padding: 10px 12px;
860
- font-family: "Helvetica Neue", "Helvetica", "Segoe UI", Arial, sans-serif;
861
- font-size: 11px;
862
- font-weight: 500;
863
- color: #cbd5e0;
864
- text-align: center;
865
- letter-spacing: 0.025em;
866
- }
867
-
868
- /* Frames - Invisible Containers */
869
- QFrame {
870
- border: none;
871
- background: transparent;
872
- }
873
-
874
- /* Separator Lines */
875
- QLabel#separator {
876
- color: rgba(255, 255, 255, 0.3);
877
- font-size: 14px;
878
- font-weight: 300;
879
- }
880
- """)
1806
+ def changeEvent(self, event):
1807
+ try:
1808
+ etype = event.type()
1809
+ palette_change = getattr(QEvent, "PaletteChange", None)
1810
+ app_palette_change = getattr(QEvent, "ApplicationPaletteChange", None)
1811
+ if palette_change is None and hasattr(QEvent, "Type"):
1812
+ palette_change = QEvent.Type.PaletteChange
1813
+ app_palette_change = QEvent.Type.ApplicationPaletteChange
1814
+ if etype in {palette_change, app_palette_change}:
1815
+ self._theme = self._compute_theme()
1816
+ self._apply_theme()
1817
+ try:
1818
+ self._update_tools_button_state()
1819
+ except Exception:
1820
+ pass
1821
+ except Exception:
1822
+ pass
1823
+ super().changeEvent(event)
1824
+
1825
+ def setup_styling(self):
1826
+ """Apply a system-aware theme (follows OS light/dark)."""
1827
+ self._theme = self._compute_theme()
1828
+ self._apply_theme()
1829
+ try:
1830
+ self._update_tools_button_state()
1831
+ except Exception:
1832
+ pass
881
1833
 
882
1834
  def position_near_tray(self):
883
1835
  """Position the bubble near the system tray."""
@@ -1048,15 +2000,46 @@ class QtChatBubble(QWidget):
1048
2000
 
1049
2001
  def update_token_limits(self):
1050
2002
  """Update token limits using AbstractCore's built-in detection."""
1051
- # Get token limits from LLMManager (which uses AbstractCore's detection)
1052
- if self.llm_manager and self.llm_manager.llm:
1053
- self.max_tokens = self.llm_manager.llm.max_tokens
1054
-
1055
- if self.debug:
1056
- print(f"📊 Token limits from AbstractCore: {self.max_tokens}")
1057
- else:
1058
- # Fallback if LLM not initialized
1059
- self.max_tokens = 128000
2003
+ max_tokens = None
2004
+ source = None
2005
+
2006
+ # Preferred: AbstractCore model capabilities (model_capabilities.json).
2007
+ try:
2008
+ from abstractcore.architectures.detection import get_model_capabilities
2009
+
2010
+ caps = get_model_capabilities(str(self.current_model or ""))
2011
+ mt = caps.get("max_tokens") if isinstance(caps, dict) else None
2012
+ if isinstance(mt, int) and mt > 0:
2013
+ max_tokens = int(mt)
2014
+ source = "abstractcore:model_capabilities"
2015
+ except Exception:
2016
+ max_tokens = None
2017
+
2018
+ # Fallback: provider instance (best-effort; may be lazy/unavailable).
2019
+ if max_tokens is None:
2020
+ try:
2021
+ llm = getattr(self.llm_manager, "llm", None)
2022
+ mt = getattr(llm, "max_tokens", None)
2023
+ if isinstance(mt, int) and mt > 0:
2024
+ max_tokens = int(mt)
2025
+ source = "provider"
2026
+ except Exception:
2027
+ max_tokens = None
2028
+
2029
+ # Final fallback: keep UI stable even for unknown models.
2030
+ if max_tokens is None:
2031
+ max_tokens = 128000
2032
+ source = "fallback"
2033
+
2034
+ self.max_tokens = int(max_tokens)
2035
+
2036
+ try:
2037
+ self.token_label.setToolTip(f"Max context: {self.max_tokens} tokens ({source})")
2038
+ except Exception:
2039
+ pass
2040
+
2041
+ if self.debug:
2042
+ print(f"📊 Token limit: {self.max_tokens} ({source})")
1060
2043
 
1061
2044
  self.update_token_display()
1062
2045
 
@@ -1113,6 +2096,142 @@ class QtChatBubble(QWidget):
1113
2096
  print(f"Model changed to: {self.current_model}")
1114
2097
 
1115
2098
 
2099
+ def _refresh_tool_inventory(self) -> None:
2100
+ """Refresh the list of available tools and keep the enabled set consistent."""
2101
+ host = getattr(self.llm_manager, "agent_host", None)
2102
+ tool_infos: List[Dict[str, str]] = []
2103
+ safe: set[str] = set()
2104
+ require: set[str] = set()
2105
+
2106
+ if host is not None:
2107
+ try:
2108
+ policy = getattr(host, "tool_policy", None)
2109
+ safe = set(getattr(policy, "auto_approve_tools", set()) or set())
2110
+ require = set(getattr(policy, "require_approval_tools", set()) or set())
2111
+ except Exception:
2112
+ safe = set()
2113
+ require = set()
2114
+
2115
+ try:
2116
+ for t in getattr(host, "tools", []) or []:
2117
+ td = getattr(t, "_tool_definition", None)
2118
+ name = getattr(td, "name", None) or getattr(t, "__name__", None)
2119
+ if not isinstance(name, str) or not name.strip():
2120
+ continue
2121
+ desc = ""
2122
+ try:
2123
+ desc = str(getattr(td, "description", "") or "") if td is not None else ""
2124
+ except Exception:
2125
+ desc = ""
2126
+ tool_infos.append({"name": name.strip(), "description": desc.strip()})
2127
+ except Exception:
2128
+ tool_infos = []
2129
+
2130
+ available_names = {info.get("name", "") for info in tool_infos if isinstance(info, dict) and info.get("name")}
2131
+ available_names = {n for n in available_names if isinstance(n, str) and n.strip()}
2132
+
2133
+ safe = set(safe) & set(available_names)
2134
+ require = set(require) & set(available_names)
2135
+
2136
+ def _sort_key(info: Dict[str, str]) -> tuple[int, str]:
2137
+ name = str(info.get("name") or "")
2138
+ if name in safe:
2139
+ return (0, name)
2140
+ if name in require:
2141
+ return (1, name)
2142
+ return (2, name)
2143
+
2144
+ self._available_external_tools = sorted(tool_infos, key=_sort_key)
2145
+ self._safe_external_tools = set(safe)
2146
+ self._require_approval_tools = set(require)
2147
+
2148
+ if not self._enabled_external_tools:
2149
+ self._enabled_external_tools = set(available_names)
2150
+ else:
2151
+ self._enabled_external_tools &= set(available_names)
2152
+
2153
+ self._update_tools_button_state()
2154
+
2155
+ def _update_tools_button_state(self) -> None:
2156
+ """Update the tools button tooltip/style based on current selection."""
2157
+ btn = getattr(self, "tools_button", None)
2158
+ if btn is None:
2159
+ return
2160
+ t = self._theme or self._compute_theme()
2161
+
2162
+ total = len(self._available_external_tools or [])
2163
+ enabled = len(self._enabled_external_tools or set())
2164
+ safe_enabled = len((self._enabled_external_tools or set()) & (self._safe_external_tools or set()))
2165
+ approval_enabled = len((self._enabled_external_tools or set()) & (self._require_approval_tools or set()))
2166
+
2167
+ if total <= 0:
2168
+ btn.setEnabled(False)
2169
+ btn.setToolTip("Tools: none available")
2170
+ return
2171
+
2172
+ btn.setEnabled(True)
2173
+ btn.setToolTip(f"Tools enabled: {enabled}/{total} (safe: {safe_enabled}, approval: {approval_enabled})")
2174
+
2175
+ if enabled == 0:
2176
+ border = t["border"]
2177
+ bg = t["overlay_pressed"]
2178
+ fg = t["text_muted"]
2179
+ elif enabled < total:
2180
+ border = t["accent"]
2181
+ bg = t["accent_rgba_12"]
2182
+ fg = t["text_primary"]
2183
+ else:
2184
+ border = t["border"]
2185
+ bg = t["overlay"]
2186
+ fg = t["text_secondary"]
2187
+
2188
+ btn.setStyleSheet(f"""
2189
+ QPushButton {{
2190
+ background: {bg};
2191
+ border: 1px solid {border};
2192
+ border-radius: 4px;
2193
+ color: {fg};
2194
+ text-align: center;
2195
+ padding: 0px;
2196
+ margin: 0px;
2197
+ }}
2198
+ QPushButton:hover {{
2199
+ background: {t['overlay_hover']};
2200
+ border: 1px solid {t['accent']};
2201
+ color: {t['text_primary']};
2202
+ }}
2203
+ QPushButton:pressed {{
2204
+ background: {t['overlay_pressed']};
2205
+ }}
2206
+ """)
2207
+
2208
+ def open_tool_selector(self) -> None:
2209
+ """Open the tool selector dialog (controls per-run tool allowlist)."""
2210
+ self._refresh_tool_inventory()
2211
+ if not self._available_external_tools:
2212
+ QMessageBox.information(self, "Tools", "No tools are available in this configuration.")
2213
+ return
2214
+
2215
+ dlg = ToolSelectorDialog(
2216
+ parent=self,
2217
+ tools=list(self._available_external_tools),
2218
+ enabled=set(self._enabled_external_tools),
2219
+ safe_preset=set(self._safe_external_tools),
2220
+ require_approval=set(self._require_approval_tools),
2221
+ )
2222
+ result = dlg.exec()
2223
+ accepted_code = getattr(QDialog, "Accepted", 1)
2224
+ if result != accepted_code:
2225
+ return
2226
+
2227
+ self._enabled_external_tools = set(dlg.selected_tools())
2228
+ # Keep session auto-approve set consistent with enabled tool selection.
2229
+ try:
2230
+ self._session_auto_approve_tools &= set(self._enabled_external_tools)
2231
+ except Exception:
2232
+ pass
2233
+ self._update_tools_button_state()
2234
+
1116
2235
  def attach_files(self):
1117
2236
  """Open file dialog to attach files (AbstractCore 2.4.5+ media handling)."""
1118
2237
  file_dialog = QFileDialog(self)
@@ -1139,6 +2258,7 @@ class QtChatBubble(QWidget):
1139
2258
 
1140
2259
  def update_attached_files_display(self):
1141
2260
  """Update the visual display of attached files."""
2261
+ t = self._theme or self._compute_theme()
1142
2262
  # Clear existing file chips
1143
2263
  while self.attached_files_layout.count():
1144
2264
  child = self.attached_files_layout.takeAt(0)
@@ -1159,14 +2279,16 @@ class QtChatBubble(QWidget):
1159
2279
 
1160
2280
  # Create file chip
1161
2281
  file_chip = QFrame()
1162
- file_chip.setStyleSheet("""
1163
- QFrame {
1164
- background: rgba(0, 102, 204, 0.2);
1165
- border: 1px solid rgba(0, 102, 204, 0.4);
2282
+ file_chip.setStyleSheet(
2283
+ f"""
2284
+ QFrame {{
2285
+ background: {t['accent_rgba_20']};
2286
+ border: 1px solid {t['accent_rgba_35']};
1166
2287
  border-radius: 6px;
1167
2288
  padding: 1px 4px;
1168
- }
1169
- """)
2289
+ }}
2290
+ """
2291
+ )
1170
2292
 
1171
2293
  chip_layout = QHBoxLayout(file_chip)
1172
2294
  chip_layout.setContentsMargins(2, 1, 2, 1)
@@ -1190,24 +2312,28 @@ class QtChatBubble(QWidget):
1190
2312
  icon = "📎"
1191
2313
 
1192
2314
  file_label = QLabel(f"{icon} {file_name[:20]}{'...' if len(file_name) > 20 else ''}")
1193
- file_label.setStyleSheet("background: transparent; border: none; color: rgba(255, 255, 255, 0.9); font-size: 8px;")
2315
+ file_label.setStyleSheet(
2316
+ f"background: transparent; border: none; color: {t['text_primary']}; font-size: 8px;"
2317
+ )
1194
2318
  chip_layout.addWidget(file_label)
1195
2319
 
1196
2320
  # Remove button
1197
2321
  remove_btn = QPushButton("✕")
1198
2322
  remove_btn.setFixedSize(12, 12)
1199
- remove_btn.setStyleSheet("""
1200
- QPushButton {
2323
+ remove_btn.setStyleSheet(
2324
+ f"""
2325
+ QPushButton {{
1201
2326
  background: transparent;
1202
2327
  border: none;
1203
- color: rgba(255, 255, 255, 0.6);
2328
+ color: {t['text_muted']};
1204
2329
  font-size: 8px;
1205
2330
  padding: 0px;
1206
- }
1207
- QPushButton:hover {
2331
+ }}
2332
+ QPushButton:hover {{
1208
2333
  color: rgba(255, 60, 60, 0.9);
1209
- }
1210
- """)
2334
+ }}
2335
+ """
2336
+ )
1211
2337
  remove_btn.clicked.connect(lambda checked, fp=file_path: self.remove_attached_file(fp))
1212
2338
  chip_layout.addWidget(remove_btn)
1213
2339
 
@@ -1278,6 +2404,7 @@ class QtChatBubble(QWidget):
1278
2404
  # 4. Update UI for sending state
1279
2405
  self.send_button.setEnabled(False)
1280
2406
  self.send_button.setText("⏳")
2407
+ self._set_session_controls_enabled(False)
1281
2408
  self.status_label.setText("generating")
1282
2409
  self.status_label.setObjectName("status_generating")
1283
2410
  self.status_label.setStyleSheet("""
@@ -1294,32 +2421,222 @@ class QtChatBubble(QWidget):
1294
2421
  }
1295
2422
  """)
1296
2423
 
1297
- # Notify main app about status change (for icon animation)
1298
- if self.status_callback:
1299
- self.status_callback("generating")
2424
+ # Notify main app about status change (for icon animation)
2425
+ if self.status_callback:
2426
+ self.status_callback("generating")
2427
+
2428
+ if self.debug:
2429
+ print("🔄 QtChatBubble: UI updated, creating worker thread...")
2430
+
2431
+ # 5. Start worker thread to send request with optional media files
2432
+ system_prompt_extra = None
2433
+ if self._is_voice_mode_active():
2434
+ system_prompt_extra = (
2435
+ "You are in voice mode.\n"
2436
+ "- Keep responses concise and conversational.\n"
2437
+ "- Avoid markdown and heavy formatting.\n"
2438
+ )
2439
+
2440
+ host = getattr(self.llm_manager, "agent_host", None)
2441
+ if host is not None:
2442
+ self.worker = AgentWorker(
2443
+ agent_host=host,
2444
+ user_text=message,
2445
+ provider=self.current_provider,
2446
+ model=self.current_model,
2447
+ attachments=media_files if media_files else None,
2448
+ system_prompt_extra=system_prompt_extra,
2449
+ allowed_tools=sorted(self._enabled_external_tools),
2450
+ debug=bool(self.debug),
2451
+ )
2452
+ self.worker.event_emitted.connect(self.on_agent_event)
2453
+ self.worker.error_occurred.connect(self.on_error_occurred)
2454
+ else:
2455
+ self.worker = LLMWorker(
2456
+ self.llm_manager,
2457
+ message,
2458
+ self.current_provider,
2459
+ self.current_model,
2460
+ media=media_files if media_files else None,
2461
+ debug=bool(self.debug),
2462
+ )
2463
+ self.worker.response_ready.connect(self.on_response_ready)
2464
+ self.worker.error_occurred.connect(self.on_error_occurred)
2465
+
2466
+ if self.debug:
2467
+ print("🔄 QtChatBubble: Starting worker thread...")
2468
+ self.worker.start()
2469
+
2470
+ if self.debug:
2471
+ print("🔄 QtChatBubble: Worker thread started, hiding bubble...")
2472
+ # Hide bubble after sending (like the original design)
2473
+ QTimer.singleShot(500, self.hide)
2474
+
2475
+ @pyqtSlot(object)
2476
+ def on_agent_event(self, event):
2477
+ """Handle AgentHost events emitted by AgentWorker."""
2478
+ if not isinstance(event, dict):
2479
+ return
2480
+
2481
+ typ = event.get("type")
2482
+ if typ == "status":
2483
+ status = str(event.get("status") or "")
2484
+ self._set_agent_status(status)
2485
+ return
2486
+
2487
+ if typ == "tool_request":
2488
+ self._handle_tool_request(event)
2489
+ return
2490
+
2491
+ if typ == "ask_user":
2492
+ self._handle_ask_user(event)
2493
+ return
2494
+
2495
+ if typ == "assistant":
2496
+ try:
2497
+ if hasattr(self.llm_manager, "refresh"):
2498
+ self.llm_manager.refresh()
2499
+ except Exception:
2500
+ pass
2501
+ self.on_response_ready(str(event.get("content") or ""))
2502
+ return
2503
+
2504
+ if typ == "error":
2505
+ self.on_error_occurred(str(event.get("error") or "error"))
2506
+ return
2507
+
2508
+ def _set_agent_status(self, status: str) -> None:
2509
+ st = str(status or "").strip().lower()
2510
+ if st in {"thinking", "running"}:
2511
+ self.status_label.setText("thinking")
2512
+ if self.status_callback:
2513
+ self.status_callback("thinking")
2514
+ return
2515
+ if st in {"executing_tools", "executing"}:
2516
+ self.status_label.setText("executing")
2517
+ if self.status_callback:
2518
+ self.status_callback("executing")
2519
+ return
2520
+ if st in {"ready", "completed"}:
2521
+ self.status_label.setText("ready")
2522
+ if self.status_callback:
2523
+ self.status_callback("ready")
2524
+ return
2525
+
2526
+ def _handle_tool_request(self, event: Dict) -> None:
2527
+ """Prompt user for tool approval when required."""
2528
+ tool_calls = event.get("tool_calls")
2529
+ if not isinstance(tool_calls, list):
2530
+ tool_calls = []
2531
+
2532
+ host = getattr(self.llm_manager, "agent_host", None)
2533
+ requires = True
2534
+ try:
2535
+ if host is not None:
2536
+ requires = bool(host.tool_policy.requires_approval(tool_calls))
2537
+ except Exception:
2538
+ requires = True
2539
+
2540
+ # Session-level allowlist can bypass approval prompts (still bounded by policy).
2541
+ try:
2542
+ if all(
2543
+ str(tc.get("name") or "") in self._session_auto_approve_tools
2544
+ for tc in tool_calls
2545
+ if isinstance(tc, dict)
2546
+ ):
2547
+ requires = False
2548
+ except Exception:
2549
+ pass
2550
+
2551
+ if not requires:
2552
+ if self.debug:
2553
+ print("✅ Auto-approving safe tool batch (no prompt).")
2554
+ if self.status_callback:
2555
+ self.status_callback("executing")
2556
+ if isinstance(self.worker, AgentWorker):
2557
+ self.worker.provide_tool_approval(True)
2558
+ return
2559
+
2560
+ # Bring UI forward for interactive approvals.
2561
+ try:
2562
+ self.show()
2563
+ self.raise_()
2564
+ self.activateWindow()
2565
+ except Exception:
2566
+ pass
1300
2567
 
1301
- if self.debug:
1302
- print("🔄 QtChatBubble: UI updated, creating worker thread...")
2568
+ self.status_label.setText("approve")
2569
+ if self.status_callback:
2570
+ self.status_callback("thinking")
2571
+
2572
+ # Format tool calls for display.
2573
+ lines: List[str] = []
2574
+ for i, tc in enumerate(tool_calls):
2575
+ if not isinstance(tc, dict):
2576
+ continue
2577
+ name = str(tc.get("name") or f"tool_{i}")
2578
+ args = tc.get("arguments")
2579
+ try:
2580
+ args_txt = json.dumps(args, ensure_ascii=False, indent=2)
2581
+ except Exception:
2582
+ args_txt = str(args)
2583
+ lines.append(f"{name}({args_txt})")
2584
+ details = "\n\n".join(lines).strip()
2585
+ if len(details) > 8000:
2586
+ details = details[:8000] + "\n…(truncated)…"
2587
+
2588
+ box = QMessageBox(self)
2589
+ box.setWindowTitle("Tool approval required")
2590
+ box.setIcon(QMessageBox.Icon.Warning)
2591
+ box.setText("The assistant wants to run tools that may affect your system or workspace.")
2592
+ box.setInformativeText("Review the tool calls and approve or deny this batch.")
2593
+ box.setDetailedText(details)
2594
+
2595
+ allow_box = QCheckBox("Always allow these tools for this session (non-destructive tools only)")
2596
+ box.setCheckBox(allow_box)
2597
+
2598
+ approve_btn = box.addButton("Approve", QMessageBox.ButtonRole.AcceptRole)
2599
+ deny_btn = box.addButton("Deny", QMessageBox.ButtonRole.RejectRole)
2600
+ box.setDefaultButton(deny_btn)
2601
+
2602
+ box.exec()
2603
+ clicked = box.clickedButton()
2604
+ approved = clicked == approve_btn
2605
+
2606
+ if approved and allow_box.isChecked() and host is not None:
2607
+ try:
2608
+ policy = host.tool_policy
2609
+ for tc in tool_calls:
2610
+ if not isinstance(tc, dict):
2611
+ continue
2612
+ name = str(tc.get("name") or "").strip()
2613
+ if not name:
2614
+ continue
2615
+ # Only allow session auto-approvals for tools that are not in the explicit "requires approval" set.
2616
+ if name in getattr(policy, "require_approval_tools", set()):
2617
+ continue
2618
+ self._session_auto_approve_tools.add(name)
2619
+ except Exception:
2620
+ pass
1303
2621
 
1304
- # 5. Start worker thread to send request with optional media files
1305
- self.worker = LLMWorker(
1306
- self.llm_manager,
1307
- message,
1308
- self.current_provider,
1309
- self.current_model,
1310
- media=media_files if media_files else None
1311
- )
1312
- self.worker.response_ready.connect(self.on_response_ready)
1313
- self.worker.error_occurred.connect(self.on_error_occurred)
2622
+ if isinstance(self.worker, AgentWorker):
2623
+ self.worker.provide_tool_approval(bool(approved))
1314
2624
 
1315
- if self.debug:
1316
- print("🔄 QtChatBubble: Starting worker thread...")
1317
- self.worker.start()
2625
+ def _handle_ask_user(self, event: Dict) -> None:
2626
+ """Prompt user for input required by the run."""
2627
+ prompt = str(event.get("prompt") or "Input required:")
1318
2628
 
1319
- if self.debug:
1320
- print("🔄 QtChatBubble: Worker thread started, hiding bubble...")
1321
- # Hide bubble after sending (like the original design)
1322
- QTimer.singleShot(500, self.hide)
2629
+ try:
2630
+ self.show()
2631
+ self.raise_()
2632
+ self.activateWindow()
2633
+ except Exception:
2634
+ pass
2635
+
2636
+ text, ok = QInputDialog.getText(self, "Assistant needs input", prompt)
2637
+ response = str(text) if ok else ""
2638
+ if isinstance(self.worker, AgentWorker):
2639
+ self.worker.provide_user_response(response)
1323
2640
 
1324
2641
  @pyqtSlot(str)
1325
2642
  def on_response_ready(self, response):
@@ -1329,6 +2646,7 @@ class QtChatBubble(QWidget):
1329
2646
 
1330
2647
  self.send_button.setEnabled(True)
1331
2648
  self.send_button.setText("→")
2649
+ self._set_session_controls_enabled(True)
1332
2650
  self.status_label.setText("ready")
1333
2651
  self.status_label.setObjectName("status_ready")
1334
2652
  self.status_label.setStyleSheet("""
@@ -1354,6 +2672,25 @@ class QtChatBubble(QWidget):
1354
2672
 
1355
2673
  # Update token count from AbstractCore
1356
2674
  self._update_token_count_from_session()
2675
+
2676
+ # Refresh sessions list (recency ordering).
2677
+ try:
2678
+ self._reload_session_combo()
2679
+ except Exception:
2680
+ pass
2681
+
2682
+ # Best-effort: auto-title the active session for the dropdown.
2683
+ try:
2684
+ if self.llm_manager and hasattr(self.llm_manager, "update_active_session_title_async"):
2685
+ self.llm_manager.update_active_session_title_async(
2686
+ provider=self.current_provider,
2687
+ model=self.current_model,
2688
+ on_done=lambda _sid, _title: QMetaObject.invokeMethod(
2689
+ self, "_refresh_session_combo_ui", Qt.QueuedConnection
2690
+ ),
2691
+ )
2692
+ except Exception:
2693
+ pass
1357
2694
 
1358
2695
  # Handle TTS if enabled (AbstractVoice integration)
1359
2696
  if self.tts_enabled and self.voice_manager and self.voice_manager.is_available():
@@ -1386,7 +2723,9 @@ class QtChatBubble(QWidget):
1386
2723
 
1387
2724
  # Speak the cleaned response using AbstractVoice-compatible interface
1388
2725
  # Note: We don't set "speaking" status here anymore - we wait for the callback
1389
- self.voice_manager.speak(clean_response)
2726
+ started = bool(self.voice_manager.speak(clean_response))
2727
+ if not started:
2728
+ raise RuntimeError("TTS speak() returned False")
1390
2729
 
1391
2730
  # Update toggle state to 'speaking'
1392
2731
  self._update_tts_toggle_state()
@@ -1403,6 +2742,12 @@ class QtChatBubble(QWidget):
1403
2742
  print(f"❌ TTS error: {e}")
1404
2743
  # Show chat history as fallback - only if voice mode is OFF
1405
2744
  QTimer.singleShot(100, self._show_history_if_voice_mode_off)
2745
+ if self._is_full_voice_running():
2746
+ self._voice_busy = False
2747
+ try:
2748
+ self.update_status("LISTENING")
2749
+ except Exception:
2750
+ pass
1406
2751
  else:
1407
2752
  # Show chat history instead of toast when TTS is disabled - only if voice mode is OFF
1408
2753
  self._show_history_if_voice_mode_off()
@@ -1421,6 +2766,12 @@ class QtChatBubble(QWidget):
1421
2766
  self.response_callback(response)
1422
2767
  if self.status_callback:
1423
2768
  self.status_callback("ready")
2769
+ if self._is_full_voice_running():
2770
+ self._voice_busy = False
2771
+ try:
2772
+ self.update_status("LISTENING")
2773
+ except Exception:
2774
+ pass
1424
2775
  else:
1425
2776
  # TTS path: Stay in thinking mode until audio actually starts
1426
2777
  if self.debug:
@@ -1584,7 +2935,13 @@ class QtChatBubble(QWidget):
1584
2935
  pass
1585
2936
 
1586
2937
  def on_full_voice_toggled(self, enabled: bool):
1587
- """Handle Full Voice Mode toggle state change."""
2938
+ """Handle Full Voice Mode toggle state change (always apply on Qt main thread)."""
2939
+ try:
2940
+ QTimer.singleShot(0, lambda e=bool(enabled): self._apply_full_voice_toggled(e))
2941
+ except Exception:
2942
+ self._apply_full_voice_toggled(bool(enabled))
2943
+
2944
+ def _apply_full_voice_toggled(self, enabled: bool) -> None:
1588
2945
  if self.debug:
1589
2946
  print(f"🎙️ Full Voice Mode {'enabled' if enabled else 'disabled'}")
1590
2947
 
@@ -1607,7 +2964,8 @@ class QtChatBubble(QWidget):
1607
2964
  if self.debug:
1608
2965
  print("🚀 Starting Full Voice Mode...")
1609
2966
 
1610
- # Hide text input UI
2967
+ # Keep the normal interface visible, but switch input actions to voice mode
2968
+ # (attach + tools remain usable; send is hidden).
1611
2969
  self.hide_text_ui()
1612
2970
 
1613
2971
  # Enable TTS automatically
@@ -1621,6 +2979,9 @@ class QtChatBubble(QWidget):
1621
2979
  if self.llm_manager:
1622
2980
  self.llm_manager.update_session_mode(tts_mode=True)
1623
2981
 
2982
+ # Mark running before starting the underlying loop so late UI updates can be gated.
2983
+ self._full_voice_running = True
2984
+
1624
2985
  # Start listening
1625
2986
  self.voice_manager.listen(
1626
2987
  on_transcription=self.handle_voice_input,
@@ -1645,77 +3006,132 @@ class QtChatBubble(QWidget):
1645
3006
  traceback.print_exc()
1646
3007
 
1647
3008
  # Reset toggle state on error
3009
+ self._full_voice_running = False
1648
3010
  self.full_voice_toggle.set_enabled(False)
1649
3011
  self.show_text_ui()
1650
3012
 
3013
+ def _is_full_voice_running(self) -> bool:
3014
+ """Centralized guard for any 'LISTENING' UI updates from async callbacks."""
3015
+ try:
3016
+ return bool(self._full_voice_running) and bool(self.full_voice_toggle.is_enabled())
3017
+ except Exception:
3018
+ return bool(getattr(self, "_full_voice_running", False))
3019
+
1651
3020
  def stop_full_voice_mode(self):
1652
3021
  """Stop Full Voice Mode and return to normal text mode."""
1653
- try:
3022
+ # IMPORTANT: make this robust. Even if voice backend stop throws, the UI must restore.
3023
+ if self.debug:
1654
3024
  if self.debug:
1655
- if self.debug:
1656
- print("🛑 Stopping Full Voice Mode...")
3025
+ print("🛑 Stopping Full Voice Mode...")
3026
+
3027
+ # Gate all future async 'LISTENING' updates immediately.
3028
+ self._full_voice_running = False
3029
+ self._voice_busy = False
1657
3030
 
1658
- # Stop listening
1659
- if self.voice_manager:
3031
+ # 1) Stop listening/speaking (best-effort, never block UI restore)
3032
+ if self.voice_manager:
3033
+ try:
3034
+ # Detach callbacks to avoid late status flips after shutdown.
3035
+ try:
3036
+ self.voice_manager.on_speech_start = None
3037
+ self.voice_manager.on_speech_end = None
3038
+ except Exception:
3039
+ pass
1660
3040
  self.voice_manager.stop_listening()
3041
+ except Exception as e:
3042
+ if self.debug:
3043
+ print(f"❌ Error stopping listening: {e}")
3044
+ try:
1661
3045
  self.voice_manager.stop_speaking()
3046
+ except Exception as e:
3047
+ if self.debug:
3048
+ print(f"❌ Error stopping speaking: {e}")
3049
+
3050
+ # 2) Turn off the speaker toggle (TTS) when leaving voice mode (as requested)
3051
+ try:
3052
+ if hasattr(self, "tts_toggle") and self.tts_toggle:
3053
+ self.tts_toggle.set_enabled(False)
3054
+ self.tts_enabled = False
3055
+ except Exception:
3056
+ pass
1662
3057
 
1663
- # Show text input UI
3058
+ # 3) Restore normal UI (Send visible again)
3059
+ try:
1664
3060
  self.show_text_ui()
3061
+ except Exception:
3062
+ pass
1665
3063
 
1666
- # No longer updating voice toggle appearance - it's a simple user control
3064
+ # If no run is currently in progress, restore the send affordance immediately.
3065
+ try:
3066
+ if not self._is_run_in_progress():
3067
+ if getattr(self, "send_button", None) is not None:
3068
+ self.send_button.setEnabled(True)
3069
+ self.send_button.setText("→")
3070
+ self._set_session_controls_enabled(True)
3071
+ except Exception:
3072
+ pass
3073
+
3074
+ # 4) Status back to Ready (green) + tray icon ready
3075
+ try:
1667
3076
  self.update_status("READY")
3077
+ except Exception:
3078
+ pass
3079
+ try:
3080
+ if self.status_callback:
3081
+ self.status_callback("ready")
3082
+ except Exception:
3083
+ pass
1668
3084
 
3085
+ if self.debug:
1669
3086
  if self.debug:
1670
- if self.debug:
1671
- print("✅ Full Voice Mode stopped")
1672
-
1673
- except Exception as e:
1674
- if self.debug:
1675
- if self.debug:
1676
- print(f"❌ Error stopping Full Voice Mode: {e}")
1677
- import traceback
1678
- traceback.print_exc()
3087
+ print("✅ Full Voice Mode stopped")
1679
3088
 
1680
3089
  def handle_voice_input(self, transcribed_text: str):
1681
- """Handle speech-to-text input from the user."""
1682
- try:
1683
- if self.debug:
1684
- if self.debug:
1685
- print(f"👤 Voice input: {transcribed_text}")
3090
+ """Handle speech-to-text input (thread-safe, routes through agentic pipeline)."""
3091
+ # Ignore any late STT callbacks after the user stopped voice mode.
3092
+ if not self._is_full_voice_running():
3093
+ return
1686
3094
 
1687
- # No longer updating voice toggle appearance - it's a simple user control
1688
- self.update_status("PROCESSING")
3095
+ text = str(transcribed_text or "").strip()
3096
+ if not text:
3097
+ return
1689
3098
 
1690
- # Generate AI response (AbstractCore will handle message logging automatically)
1691
- response = self.llm_manager.generate_response(
1692
- transcribed_text,
1693
- self.current_provider,
1694
- self.current_model
1695
- )
3099
+ if self.debug:
3100
+ print(f"👤 Voice input: {text}")
1696
3101
 
1697
- # Update message history from AbstractCore session
1698
- self._update_message_history_from_session()
3102
+ # Ensure we run UI + agent turn creation on the Qt main thread.
3103
+ QTimer.singleShot(0, lambda t=text: self._handle_voice_input_main_thread(t))
1699
3104
 
1700
- if self.debug:
1701
- if self.debug:
1702
- print(f"🤖 AI response: {response[:100]}...")
3105
+ def _handle_voice_input_main_thread(self, transcribed_text: str) -> None:
3106
+ """Execute a voice input turn on the Qt main thread."""
3107
+ if not self._is_full_voice_running():
3108
+ self._voice_busy = False
3109
+ return
1703
3110
 
1704
- # Speak the response
1705
- self.voice_manager.speak(response)
3111
+ if self._voice_busy:
3112
+ if self.debug:
3113
+ print("🎙️ Ignoring transcription while busy")
3114
+ return
1706
3115
 
1707
- # No longer updating voice toggle appearance - it's a simple user control
1708
- self.update_status("LISTENING")
3116
+ self._voice_busy = True
3117
+ try:
3118
+ self.update_status("PROCESSING")
1709
3119
 
3120
+ # Route through the same agentic sending path as typed input.
3121
+ try:
3122
+ self.input_text.setPlainText(str(transcribed_text or ""))
3123
+ except Exception:
3124
+ pass
3125
+ self.send_message()
1710
3126
  except Exception as e:
3127
+ self._voice_busy = False
1711
3128
  if self.debug:
1712
- if self.debug:
1713
- print(f"❌ Error handling voice input: {e}")
1714
- import traceback
1715
- traceback.print_exc()
1716
-
1717
- # No longer updating voice toggle appearance - it's a simple user control
1718
- self.update_status("LISTENING")
3129
+ print(f"❌ Error handling voice input: {e}")
3130
+ try:
3131
+ if self._is_full_voice_running():
3132
+ self.update_status("LISTENING")
3133
+ except Exception:
3134
+ pass
1719
3135
 
1720
3136
  def handle_voice_stop(self):
1721
3137
  """Handle when user says 'stop' to exit Full Voice Mode."""
@@ -1724,28 +3140,64 @@ class QtChatBubble(QWidget):
1724
3140
  print("🛑 User said 'stop' - exiting Full Voice Mode")
1725
3141
 
1726
3142
  # Disable Full Voice Mode
3143
+ self._full_voice_running = False
1727
3144
  self.full_voice_toggle.set_enabled(False)
1728
3145
 
1729
3146
  def hide_text_ui(self):
1730
- """Hide the text input interface during Full Voice Mode."""
1731
- # Hide the input container and other text-related UI elements
1732
- if hasattr(self, 'input_container'):
1733
- self.input_container.hide()
1734
-
1735
- # Update window size to be smaller but maintain wider width
1736
- voice_base_height = 120
1737
- attachment_height = 28 if (self.attached_files and self.attached_files_container.isVisible()) else 0
1738
- voice_height = voice_base_height + attachment_height
1739
- self.setFixedSize(self.base_width, voice_height) # Dynamic height for voice mode
3147
+ """Enter Full Voice Mode UI (keep input visible; hide Send; keep attach/tools)."""
3148
+ self._set_voice_ui_mode(True)
1740
3149
 
1741
3150
  def show_text_ui(self):
1742
- """Show the text input interface when exiting Full Voice Mode."""
1743
- # Show the input container and other text-related UI elements
1744
- if hasattr(self, 'input_container'):
1745
- self.input_container.show()
3151
+ """Exit Full Voice Mode UI (restore Send and normal text interaction)."""
3152
+ self._set_voice_ui_mode(False)
1746
3153
 
1747
- # Restore normal window size with wider width - use dynamic sizing
1748
- self._adjust_window_size_for_attachments()
3154
+ def _set_voice_ui_mode(self, enabled: bool) -> None:
3155
+ """
3156
+ Centralized UI state switch for voice mode.
3157
+
3158
+ Requirements:
3159
+ - Even in voice mode, user can still change file attachments and tools.
3160
+ - Send is hidden/disabled in voice mode (end-of-sentence acts as "send").
3161
+ """
3162
+ enabled = bool(enabled)
3163
+ try:
3164
+ if hasattr(self, "input_container") and self.input_container:
3165
+ self.input_container.show()
3166
+ except Exception:
3167
+ pass
3168
+
3169
+ # Toggle the action column behavior (2 buttons in voice mode, 3 otherwise).
3170
+ try:
3171
+ if hasattr(self, "_input_row") and self._input_row:
3172
+ self._input_row.set_voice_mode(enabled)
3173
+ except Exception:
3174
+ pass
3175
+
3176
+ # Ensure attach/tools remain available in both modes.
3177
+ for btn_attr in ("attach_button", "tools_button"):
3178
+ b = getattr(self, btn_attr, None)
3179
+ if b is None:
3180
+ continue
3181
+ try:
3182
+ b.setEnabled(True)
3183
+ b.setVisible(True)
3184
+ except Exception:
3185
+ pass
3186
+
3187
+ # Send button is only relevant in text mode.
3188
+ sb = getattr(self, "send_button", None)
3189
+ if sb is not None:
3190
+ try:
3191
+ sb.setVisible(not enabled)
3192
+ sb.setEnabled(not enabled)
3193
+ except Exception:
3194
+ pass
3195
+
3196
+ # Keep the window sizing consistent with attachments.
3197
+ try:
3198
+ self._adjust_window_size_for_attachments()
3199
+ except Exception:
3200
+ pass
1749
3201
 
1750
3202
  def update_status(self, status_text: str):
1751
3203
  """Update the status label with the given text."""
@@ -1894,6 +3346,7 @@ class QtChatBubble(QWidget):
1894
3346
  """Handle LLM error."""
1895
3347
  self.send_button.setEnabled(True)
1896
3348
  self.send_button.setText("→")
3349
+ self._set_session_controls_enabled(True)
1897
3350
  self.status_label.setText("error")
1898
3351
  self.status_label.setObjectName("status_error")
1899
3352
  self.status_label.setStyleSheet("""
@@ -1921,6 +3374,14 @@ class QtChatBubble(QWidget):
1921
3374
 
1922
3375
  # Show history so user can see the error context - only if voice mode is OFF
1923
3376
  QTimer.singleShot(100, self._show_history_if_voice_mode_off)
3377
+
3378
+ # If we're in full voice mode, unblock the STT loop.
3379
+ if self._is_full_voice_running():
3380
+ self._voice_busy = False
3381
+ try:
3382
+ self.update_status("LISTENING")
3383
+ except Exception:
3384
+ pass
1924
3385
 
1925
3386
  # Call error callback
1926
3387
  if self.error_callback:
@@ -1937,41 +3398,257 @@ class QtChatBubble(QWidget):
1937
3398
  def set_status_callback(self, callback):
1938
3399
  """Set status callback function."""
1939
3400
  self.status_callback = callback
3401
+
3402
+ def _set_session_controls_enabled(self, enabled: bool) -> None:
3403
+ for attr in ("session_combo", "new_session_button"):
3404
+ w = getattr(self, attr, None)
3405
+ if w is None:
3406
+ continue
3407
+ try:
3408
+ w.setEnabled(bool(enabled))
3409
+ except Exception:
3410
+ pass
3411
+
3412
+ def _selected_session_id(self) -> Optional[str]:
3413
+ combo = getattr(self, "session_combo", None)
3414
+ if combo is None:
3415
+ return None
3416
+ try:
3417
+ sid = combo.currentData()
3418
+ except Exception:
3419
+ sid = None
3420
+ sid = str(sid or "").strip()
3421
+ return sid or None
3422
+
3423
+ def _reload_session_combo(self, *, select_session_id: Optional[str] = None) -> None:
3424
+ combo = getattr(self, "session_combo", None)
3425
+ if combo is None:
3426
+ return
3427
+ if not self.llm_manager or not hasattr(self.llm_manager, "list_sessions"):
3428
+ try:
3429
+ combo.clear()
3430
+ combo.addItem("Session")
3431
+ combo.setEnabled(False)
3432
+ except Exception:
3433
+ pass
3434
+ return
3435
+
3436
+ try:
3437
+ sessions = list(self.llm_manager.list_sessions() or [])
3438
+ except Exception:
3439
+ sessions = []
3440
+
3441
+ active = select_session_id or self._selected_session_id()
3442
+ if not active:
3443
+ try:
3444
+ active = str(getattr(self.llm_manager, "active_session_id", "") or "").strip()
3445
+ except Exception:
3446
+ active = None
3447
+
3448
+ try:
3449
+ combo.blockSignals(True)
3450
+ combo.clear()
3451
+
3452
+ select_index = 0
3453
+ items = 0
3454
+ for rec in sessions:
3455
+ if not isinstance(rec, dict):
3456
+ continue
3457
+ sid = str(rec.get("session_id") or "").strip()
3458
+ if not sid:
3459
+ continue
3460
+ title = str(rec.get("title") or "New session").strip() or "New session"
3461
+ label = title
3462
+ if title.strip().lower() == "new session":
3463
+ stamp = str(rec.get("updated_at") or rec.get("created_at") or "").strip()
3464
+ human = None
3465
+ if stamp:
3466
+ try:
3467
+ dt = datetime.fromisoformat(stamp)
3468
+ human = dt.astimezone().strftime("%b %d %H:%M")
3469
+ except Exception:
3470
+ human = None
3471
+ label = f"{title} • {human}" if human else f"{title} • {sid[-6:]}"
3472
+ combo.addItem(label, sid)
3473
+ try:
3474
+ idx = combo.count() - 1
3475
+ role = getattr(Qt, "ToolTipRole", None) or getattr(getattr(Qt, "ItemDataRole", object), "ToolTipRole", None)
3476
+ if role is not None:
3477
+ tip_lines = [title, sid]
3478
+ updated = str(rec.get("updated_at") or "").strip()
3479
+ if updated:
3480
+ tip_lines.append(f"Updated: {updated}")
3481
+ combo.setItemData(idx, "\n".join(tip_lines), role)
3482
+ except Exception:
3483
+ pass
3484
+ if active and sid == active:
3485
+ select_index = items
3486
+ items += 1
3487
+
3488
+ if combo.count() <= 0:
3489
+ combo.addItem("New session")
3490
+ combo.setEnabled(False)
3491
+ else:
3492
+ combo.setEnabled(True)
3493
+ combo.setCurrentIndex(select_index)
3494
+ finally:
3495
+ try:
3496
+ combo.blockSignals(False)
3497
+ except Exception:
3498
+ pass
3499
+
3500
+ def _is_run_in_progress(self) -> bool:
3501
+ try:
3502
+ if self.worker is not None and hasattr(self.worker, "isRunning") and self.worker.isRunning():
3503
+ return True
3504
+ except Exception:
3505
+ pass
3506
+ try:
3507
+ return not bool(self.send_button.isEnabled())
3508
+ except Exception:
3509
+ return False
3510
+
3511
+ def _on_session_combo_changed(self, index: int) -> None:
3512
+ if not self.llm_manager or not hasattr(self.llm_manager, "switch_session"):
3513
+ return
3514
+
3515
+ combo = getattr(self, "session_combo", None)
3516
+ if combo is None:
3517
+ return
3518
+
3519
+ try:
3520
+ sid = combo.itemData(int(index))
3521
+ except Exception:
3522
+ sid = None
3523
+ sid = str(sid or "").strip()
3524
+ if not sid:
3525
+ return
3526
+
3527
+ try:
3528
+ current = str(getattr(self.llm_manager, "active_session_id", "") or "").strip()
3529
+ except Exception:
3530
+ current = ""
3531
+ if sid == current:
3532
+ return
3533
+
3534
+ if self._is_run_in_progress():
3535
+ QMessageBox.information(self, "Session switch", "Please wait for the current response to finish.")
3536
+ self._reload_session_combo(select_session_id=current or None)
3537
+ return
3538
+
3539
+ try:
3540
+ self.llm_manager.switch_session(sid)
3541
+ except Exception as e:
3542
+ QMessageBox.warning(self, "Session switch", f"Failed to switch session:\n{e}")
3543
+ self._reload_session_combo(select_session_id=current or None)
3544
+ return
3545
+
3546
+ try:
3547
+ if hasattr(self.llm_manager, "refresh"):
3548
+ self.llm_manager.refresh()
3549
+ except Exception:
3550
+ pass
3551
+
3552
+ self._reload_session_combo(select_session_id=sid)
3553
+
3554
+ # Reset per-session UI caches.
3555
+ self.attached_files.clear()
3556
+ self.message_file_attachments.clear()
3557
+ self.update_attached_files_display()
3558
+
3559
+ self._update_message_history_from_session()
3560
+ self._update_token_count_from_session()
3561
+ self._rebuild_chat_display()
3562
+
3563
+ # If the history window is open but the new session is empty, hide it.
3564
+ if self.history_dialog and self.history_dialog.isVisible() and not self.message_history:
3565
+ try:
3566
+ self.history_dialog.hide()
3567
+ self._update_history_button_appearance(False)
3568
+ except Exception:
3569
+ pass
3570
+
3571
+ def _start_new_session(self) -> None:
3572
+ if not self.llm_manager or not hasattr(self.llm_manager, "create_new_session"):
3573
+ return
3574
+
3575
+ if self._is_run_in_progress():
3576
+ QMessageBox.information(self, "New session", "Please wait for the current response to finish.")
3577
+ return
3578
+
3579
+ try:
3580
+ new_id = str(self.llm_manager.create_new_session() or "").strip()
3581
+ except Exception as e:
3582
+ QMessageBox.warning(self, "New session", f"Failed to create a new session:\n{e}")
3583
+ return
3584
+
3585
+ # Reset per-session UI caches.
3586
+ self.attached_files.clear()
3587
+ self.message_file_attachments.clear()
3588
+ self.update_attached_files_display()
3589
+
3590
+ self._update_message_history_from_session()
3591
+ self._update_token_count_from_session()
3592
+ self._rebuild_chat_display()
3593
+
3594
+ if self.history_dialog and self.history_dialog.isVisible():
3595
+ try:
3596
+ self.history_dialog.hide()
3597
+ self._update_history_button_appearance(False)
3598
+ except Exception:
3599
+ pass
3600
+
3601
+ self._reload_session_combo(select_session_id=new_id or None)
3602
+
3603
+ @pyqtSlot()
3604
+ def _refresh_session_combo_ui(self) -> None:
3605
+ try:
3606
+ self._reload_session_combo()
3607
+ except Exception:
3608
+ pass
3609
+
3610
+ def _show_more_menu(self) -> None:
3611
+ btn = getattr(self, "more_button", None)
3612
+ if btn is None:
3613
+ return
3614
+ menu = QMenu(self)
3615
+ menu.addAction("Load…", self.load_session)
3616
+ menu.addAction("Save…", self.save_session)
3617
+ if bool(getattr(self, "debug", False)):
3618
+ menu.addSeparator()
3619
+ menu.addAction("Run trace (debug)…", self.show_trace)
3620
+
3621
+ try:
3622
+ pos = btn.mapToGlobal(QPoint(0, btn.height()))
3623
+ except Exception:
3624
+ pos = None
3625
+
3626
+ try:
3627
+ if pos is None:
3628
+ if hasattr(menu, "exec_"):
3629
+ menu.exec_()
3630
+ else:
3631
+ menu.exec()
3632
+ else:
3633
+ if hasattr(menu, "exec_"):
3634
+ menu.exec_(pos)
3635
+ else:
3636
+ menu.exec(pos)
3637
+ except Exception:
3638
+ return
1940
3639
 
1941
3640
  def clear_session(self):
1942
- """Clear the current session."""
3641
+ """Start a new session (prior sessions remain available)."""
1943
3642
  reply = QMessageBox.question(
1944
- self,
1945
- "Clear Session",
1946
- "Are you sure you want to clear the current session?\nThis will remove all messages, attached files, and reset the token count.",
3643
+ self,
3644
+ "New Session",
3645
+ "Start a new session?\nYour previous sessions will remain available in the session dropdown.",
1947
3646
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1948
- QMessageBox.StandardButton.No
3647
+ QMessageBox.StandardButton.No,
1949
3648
  )
1950
-
1951
- if reply == QMessageBox.StandardButton.Yes:
1952
- if hasattr(self, 'chat_display'):
1953
- self.chat_display.clear()
1954
- self.chat_display.hide()
1955
-
1956
- # Clear AbstractCore session and create a new one
1957
- if self.llm_manager:
1958
- self.llm_manager.create_new_session()
1959
- if self.debug:
1960
- if self.debug:
1961
- print("🧹 AbstractCore session cleared and recreated")
1962
3649
 
1963
- self.message_history.clear()
1964
- self.token_count = 0
1965
- self.update_token_display()
1966
-
1967
- # Clear attached files as part of session clearing
1968
- self.attached_files.clear()
1969
- self.message_file_attachments.clear()
1970
- self.update_attached_files_display()
1971
-
1972
- if self.debug:
1973
- if self.debug:
1974
- print("🧹 Session cleared (including attached files and file tracking)")
3650
+ if reply == QMessageBox.StandardButton.Yes:
3651
+ self._start_new_session()
1975
3652
 
1976
3653
  def compact_session(self):
1977
3654
  """Compact the current session using AbstractCore's summarizer functionality."""
@@ -2261,6 +3938,8 @@ Continue the conversation naturally, referring to the context above when relevan
2261
3938
 
2262
3939
  # Update our local message history from AbstractCore
2263
3940
  self._update_message_history_from_session()
3941
+ self._update_token_count_from_session()
3942
+ self._reload_session_combo()
2264
3943
  self._rebuild_chat_display()
2265
3944
 
2266
3945
  QMessageBox.information(
@@ -2358,37 +4037,78 @@ Continue the conversation naturally, referring to the context above when relevan
2358
4037
  return not self._is_voice_mode_active()
2359
4038
 
2360
4039
  def _update_message_history_from_session(self):
2361
- """Update local message history from AbstractCore session."""
2362
- if self.llm_manager and self.llm_manager.current_session:
4040
+ """Update local message history from the durable agent snapshot (preferred).
4041
+
4042
+ Notes:
4043
+ - Runtime-backed agents include role="tool" observations in the transcript so the
4044
+ model can continue tool loops. Those are hidden from the user-visible history
4045
+ and replaced with a compact per-answer tool summary + resource links.
4046
+ """
4047
+ if not self.llm_manager:
4048
+ return
4049
+
4050
+ from ..core.transcript_summary import build_display_messages
4051
+
4052
+ raw_messages = None
4053
+ try:
4054
+ host = getattr(self.llm_manager, "agent_host", None)
4055
+ snap = getattr(host, "snapshot", None) if host is not None else None
4056
+ raw_messages = getattr(snap, "messages", None) if snap is not None else None
4057
+ except Exception:
4058
+ raw_messages = None
4059
+
4060
+ messages: List[Dict] = []
4061
+ if isinstance(raw_messages, list):
4062
+ messages = [dict(m) for m in raw_messages if isinstance(m, dict)]
4063
+ elif getattr(self.llm_manager, "current_session", None) is not None:
2363
4064
  try:
2364
- # Get messages from AbstractCore session
2365
- session_messages = getattr(self.llm_manager.current_session, 'messages', [])
2366
-
2367
- # Convert AbstractCore messages to our format
2368
- self.message_history = []
2369
- for i, msg in enumerate(session_messages):
2370
- # Skip system messages
2371
- if hasattr(msg, 'role') and msg.role == 'system':
2372
- continue
4065
+ session_messages = getattr(self.llm_manager.current_session, "messages", [])
4066
+ for msg in session_messages:
4067
+ role = getattr(msg, "role", "unknown")
4068
+ content = getattr(msg, "content", str(msg))
4069
+ messages.append({"role": str(role), "content": str(content)})
4070
+ except Exception:
4071
+ messages = []
2373
4072
 
2374
- message = {
2375
- 'timestamp': datetime.now().isoformat(), # AbstractCore doesn't store timestamps
2376
- 'type': getattr(msg, 'role', 'unknown'),
2377
- 'content': getattr(msg, 'content', str(msg)),
2378
- 'provider': self.current_provider,
2379
- 'model': self.current_model,
2380
- 'attached_files': self.message_file_attachments.get(len(self.message_history), [])
2381
- }
2382
- self.message_history.append(message)
4073
+ try:
4074
+ rendered = build_display_messages(messages)
4075
+
4076
+ self.message_history = []
4077
+ for m in rendered:
4078
+ role = str(m.get("role") or "unknown")
4079
+ content = str(m.get("content") or "")
4080
+ timestamp = m.get("timestamp")
4081
+ if not isinstance(timestamp, (str, int, float)) or (isinstance(timestamp, str) and not timestamp.strip()):
4082
+ timestamp = datetime.now().isoformat()
4083
+
4084
+ entry: Dict[str, Any] = {
4085
+ "timestamp": timestamp,
4086
+ "type": role,
4087
+ "content": content,
4088
+ "provider": self.current_provider,
4089
+ "model": self.current_model,
4090
+ "attached_files": self.message_file_attachments.get(len(self.message_history), []),
4091
+ }
2383
4092
 
2384
- if self.debug:
2385
- if self.debug:
2386
- print(f"📚 Updated message history from AbstractCore: {len(self.message_history)} messages")
4093
+ tool_summary = m.get("tool_summary")
4094
+ if isinstance(tool_summary, str) and tool_summary.strip():
4095
+ entry["tool_summary"] = tool_summary.strip()
4096
+ tool_links = m.get("tool_links")
4097
+ if isinstance(tool_links, list) and tool_links:
4098
+ entry["tool_links"] = [dict(x) for x in tool_links if isinstance(x, dict)]
2387
4099
 
2388
- except Exception as e:
2389
- if self.debug:
2390
- if self.debug:
2391
- print(f"❌ Error updating message history from session: {e}")
4100
+ image_thumbnails = m.get("image_thumbnails")
4101
+ if isinstance(image_thumbnails, list) and image_thumbnails:
4102
+ entry["image_thumbnails"] = [dict(x) for x in image_thumbnails if isinstance(x, dict)]
4103
+
4104
+ self.message_history.append(entry)
4105
+
4106
+ if self.debug:
4107
+ print(f"📚 Updated message history: {len(self.message_history)} messages")
4108
+
4109
+ except Exception as e:
4110
+ if self.debug:
4111
+ print(f"❌ Error updating message history from snapshot/session: {e}")
2392
4112
 
2393
4113
  def _rebuild_chat_display(self):
2394
4114
  """Rebuild chat display after session loading.
@@ -2427,6 +4147,75 @@ Continue the conversation naturally, referring to the context above when relevan
2427
4147
  if self.debug:
2428
4148
  print(f"❌ Error updating token count from session: {e}")
2429
4149
 
4150
+ def show_trace(self):
4151
+ """Show a lightweight debug trace for the last run."""
4152
+ host = getattr(self.llm_manager, "agent_host", None)
4153
+ run_id = None
4154
+ try:
4155
+ snap = getattr(host, "snapshot", None)
4156
+ run_id = getattr(snap, "last_run_id", None) if snap else None
4157
+ except Exception:
4158
+ run_id = None
4159
+
4160
+ rid = str(run_id or "").strip()
4161
+ if not host or not rid:
4162
+ QMessageBox.information(self, "Run trace", "No run is available yet.")
4163
+ return
4164
+
4165
+ try:
4166
+ ensure = getattr(host, "_ensure_ready", None)
4167
+ if callable(ensure):
4168
+ ensure()
4169
+ except Exception:
4170
+ pass
4171
+
4172
+ rt = getattr(host, "_runtime", None)
4173
+ if rt is None:
4174
+ QMessageBox.information(self, "Run trace", "Runtime is not initialized yet.")
4175
+ return
4176
+
4177
+ try:
4178
+ state = rt.get_state(rid)
4179
+ payload = {
4180
+ "run_id": getattr(state, "run_id", rid),
4181
+ "status": str(getattr(state, "status", "")),
4182
+ "workflow_id": getattr(state, "workflow_id", None),
4183
+ "actor_id": getattr(state, "actor_id", None),
4184
+ "session_id": getattr(state, "session_id", None),
4185
+ "waiting": str(getattr(state, "waiting", None) or ""),
4186
+ "error": str(getattr(state, "error", None) or ""),
4187
+ "vars_keys": sorted(list(getattr(state, "vars", {}).keys())) if isinstance(getattr(state, "vars", None), dict) else [],
4188
+ "output_keys": sorted(list(getattr(state, "output", {}).keys())) if isinstance(getattr(state, "output", None), dict) else [],
4189
+ }
4190
+
4191
+ details_obj = {
4192
+ "summary": payload,
4193
+ "vars": getattr(state, "vars", None),
4194
+ "output": getattr(state, "output", None),
4195
+ }
4196
+ details_txt = json.dumps(details_obj, ensure_ascii=False, indent=2, default=str)
4197
+ if len(details_txt) > 20000:
4198
+ details_txt = details_txt[:20000] + "\n…(truncated)…"
4199
+
4200
+ box = QMessageBox(self)
4201
+ box.setWindowTitle("Run trace")
4202
+ box.setIcon(QMessageBox.Icon.Information)
4203
+ box.setText("Last run trace (summary).")
4204
+ box.setInformativeText(
4205
+ "\n".join(
4206
+ [
4207
+ f"Run ID: {payload['run_id']}",
4208
+ f"Status: {payload['status']}",
4209
+ f"Workflow: {payload['workflow_id']}",
4210
+ f"Actor: {payload['actor_id']}",
4211
+ ]
4212
+ )
4213
+ )
4214
+ box.setDetailedText(details_txt)
4215
+ box.exec()
4216
+ except Exception as e:
4217
+ QMessageBox.warning(self, "Run trace", f"Failed to load trace:\n{e}")
4218
+
2430
4219
  def _show_history_if_voice_mode_off(self):
2431
4220
  """Show chat history only if voice mode is OFF."""
2432
4221
  if not self._should_show_chat_history():
@@ -2768,6 +4557,14 @@ Continue the conversation naturally, referring to the context above when relevan
2768
4557
  if self.debug:
2769
4558
  print("🔊 QtChatBubble: Speech ended, setting ready status")
2770
4559
  self.status_callback("ready")
4560
+
4561
+ # Voice loop: allow next transcription after speaking ends.
4562
+ if self._is_full_voice_running():
4563
+ self._voice_busy = False
4564
+ try:
4565
+ self.update_status("LISTENING")
4566
+ except Exception:
4567
+ pass
2771
4568
 
2772
4569
  @pyqtSlot()
2773
4570
  def _execute_tts_completion_callbacks(self):
@@ -2787,6 +4584,8 @@ Continue the conversation naturally, referring to the context above when relevan
2787
4584
  self._tts_completion_callback = None
2788
4585
 
2789
4586
 
4587
+ # NOTE: The message input row now owns sizing of the square action buttons via `_MessageInputRow`.
4588
+
2790
4589
  def closeEvent(self, event):
2791
4590
  """Handle close event."""
2792
4591
  if self.worker and self.worker.isRunning():