"""Off/Auto/Manual/Fallback routing toggle + Auto-run toggle + confirm dialog. Dropped into every chat surface's composer (Cowork / Co4E / AI-Edit). By default a :class:`RoutingToggle` reads/writes the **per-workspace** mode via ``AppContext.project_routing_mode`` / ``set_project_routing_mode`` (so each workspace keeps its own mode), but the storage is fully injectable through ``get_mode``/``set_mode`` callables — all the real decision logic lives in ``application/model_routing`` (which the surfaces call through ``RoutingApplicationService``). Call :meth:`refresh` when the active workspace changes so the control shows that workspace's mode. """ from __future__ import annotations from typing import Any, Callable, Optional from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import ( QCheckBox, QComboBox, QHBoxLayout, QLabel, QMessageBox, QWidget, ) from ..i18n import on_language_changed, tr class RoutingToggle(QWidget): """A small ``Routing: [Off ▾]`` control bound to one chat surface. Storage is injectable so the same widget can back a per-workspace mode, a global mode, or anything else: * ``get_mode()`` returns the current mode string to display. * ``set_mode(mode)`` persists a newly-chosen mode. When omitted, both default to the ACTIVE workspace's per-surface mode (``AppContext.project_routing_mode`` / ``set_project_routing_mode``). Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches. """ mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback" def __init__( self, ctx: Any, surface: str, parent: Optional[QWidget] = None, *, get_mode: Optional[Callable[[], str]] = None, set_mode: Optional[Callable[[str], None]] = None, ) -> None: """Công tắc chế độ định tuyến cho một bề mặt chat. ``get_mode``/``set_mode`` tiêm được để dùng lại công tắc này ở chỗ đọc/ghi chế độ theo cách khác, mà không phải chép lại cả widget. """ super().__init__(parent) self.ctx = ctx self.surface = surface # Default to per-workspace storage (each workspace keeps its own mode). self._get_mode = get_mode or (lambda: ctx.project_routing_mode(surface)) self._set_mode = set_mode or (lambda m: ctx.set_project_routing_mode(surface, m)) lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(4) self._label = QLabel(tr("routing.toggle_label")) self._label.setObjectName("hint") self._combo = QComboBox() self._combo.setToolTip(tr("routing.toggle_tooltip")) # Mode names differ in length per language ("Thủ công" is wider than # "手動"), and a combo only re-measures itself under this policy — without # it the width stays frozen at the language the widget was built in and # the longer translation is cut off. self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) # (data value, i18n key) — data is the persisted mode string. Order is # least-to-most autonomous, with Fallback (R03-T03) last because it is # the "only when something breaks" mode rather than a stronger Auto. self._modes = [ ("off", "routing.mode_off"), ("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"), ("fallback", "routing.mode_fallback"), ] for value, key in self._modes: self._combo.addItem(tr(key), value) self.refresh() # reflect the current (per-workspace) mode self._combo.currentIndexChanged.connect(self._on_changed) lay.addWidget(self._label) lay.addWidget(self._combo) # Registered HERE, not by the four surfaces that embed this widget # (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the # control stayed frozen in the language it was built in. Owning it here # means no future embedder can forget either. on_language_changed(self.retranslate) def current_mode(self) -> str: """Chế độ định tuyến đang chọn; 'off' nếu chưa đặt.""" return self._combo.currentData() or "off" def refresh(self) -> None: """Re-read the backing mode (e.g. after switching workspace) and show it without emitting a spurious change.""" try: mode = self._get_mode() or "off" except Exception: # noqa: BLE001 mode = "off" idx = self._combo.findData(mode) if idx < 0: idx = 0 self._combo.blockSignals(True) self._combo.setCurrentIndex(idx) self._combo.blockSignals(False) def retranslate(self) -> None: """Re-apply labels after a language change.""" self._label.setText(tr("routing.toggle_label")) self._combo.setToolTip(tr("routing.toggle_tooltip")) for i, (value, key) in enumerate(self._modes): self._combo.setItemText(i, tr(key)) def _on_changed(self, _idx: int) -> None: """Lưu chế độ vừa chọn. Nuốt lỗi có chủ ý: một cú đổi công tắc không được phép làm vỡ giao diện. """ mode = self.current_mode() try: self._set_mode(mode) except Exception: # noqa: BLE001 — never let a toggle change crash the UI pass self.mode_changed.emit(mode) class AutoRunToggle(QWidget): """A checkbox that auto-approves commands for the ACTIVE workspace. Checked → commands run without a confirm dialog (auto-approve) in this workspace; unchecked → the Approve/Reject dialog is shown. Backed by the per-workspace ``auto_run`` override (``AppContext.set_project_auto_run``), falling back to the global ``cowork_confirm_commands`` when unset. """ toggled_auto = Signal(bool) def __init__(self, ctx: Any, parent: Optional[QWidget] = None) -> None: """Ô tick tự chạy: đổi model xong có tự tiếp tục lượt hay dừng lại hỏi.""" super().__init__(parent) self.ctx = ctx lay = QHBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(4) self._chk = QCheckBox(tr("routing.autorun_label")) self._chk.setToolTip(tr("routing.autorun_tooltip")) self.refresh() self._chk.toggled.connect(self._on_toggled) lay.addWidget(self._chk) on_language_changed(self.retranslate) # same reason as RoutingToggle def refresh(self) -> None: """Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu.""" try: auto = bool(self.ctx.project_auto_run()) except Exception: # noqa: BLE001 auto = False self._chk.blockSignals(True) self._chk.setChecked(auto) self._chk.blockSignals(False) def retranslate(self) -> None: """Áp lại chữ theo ngôn ngữ đang chọn.""" self._chk.setText(tr("routing.autorun_label")) self._chk.setToolTip(tr("routing.autorun_tooltip")) def _on_toggled(self, checked: bool) -> None: """Ghi trạng thái tự chạy vào project đang mở.""" try: self.ctx.set_project_auto_run(bool(checked)) except Exception: # noqa: BLE001 pass self.toggled_auto.emit(bool(checked)) def confirm_switch(parent: QWidget, decision: Any, timeout_sec: float) -> bool: """Modal Manual-mode confirm: ask before switching, auto-keep on timeout. Returns True if the user approved the switch; False if they declined or the ``timeout_sec`` window elapsed (→ keep the current model, per spec). The "Keep current" button shows a live countdown so the timeout is visible. """ from ..core.routing.models import split_key from_id = split_key(decision.from_model)[1] if decision.from_model else "—" to_id = split_key(decision.to_model)[1] if decision.to_model else "—" box = QMessageBox(parent) box.setIcon(QMessageBox.Question) box.setWindowTitle(tr("routing.confirm_title")) box.setText(tr( "routing.confirm_body", task=decision.task_type or "?", from_model=from_id, to_model=to_id, gain=f"{decision.score_gain:.2f}", reason=decision.reason, )) yes_btn = box.addButton(tr("routing.confirm_yes"), QMessageBox.AcceptRole) no_btn = box.addButton(tr("routing.confirm_no"), QMessageBox.RejectRole) box.setDefaultButton(no_btn) # Countdown that auto-declines (keep current) when the window elapses. remaining = {"secs": int(max(1, round(timeout_sec)))} timer = QTimer(box) timer.setInterval(1000) def _tick() -> None: """Đếm lùi mỗi giây; hết giờ thì tự đóng hộp thoại theo hướng GIỮ model hiện tại. Hết giờ mà tự đổi model là quyết định thay người dùng — mặc định an toàn phải là không đổi gì. """ remaining["secs"] -= 1 if remaining["secs"] <= 0: timer.stop() box.done(QMessageBox.RejectRole) # timeout → keep current else: no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"])) no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"])) timer.timeout.connect(_tick) if timeout_sec > 0: timer.start() box.exec() timer.stop() return box.clickedButton() is yes_btn __all__ = ["RoutingToggle", "AutoRunToggle", "confirm_switch"]