"""APIClient - Agent - Response Panel.""" import json from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget, QTextEdit, QTableWidget, QTableWidgetItem, QHeaderView, QPushButton, QLineEdit, QApplication, QTreeWidget, QTreeWidgetItem, QFrame, QFileDialog, QStackedWidget ) from PyQt6.QtGui import QFont, QTextDocument, QTextCursor, QBrush, QColor from PyQt6.QtCore import Qt from app.ui.theme import Colors, status_color from app.ui.highlighter import JsonHighlighter from app.models import HttpResponse, TestResult def _fmt_size(n: int) -> str: if n < 1024: return f"{n} B" if n < 1024 * 1024: return f"{n / 1024:.1f} KB" return f"{n / (1024 * 1024):.2f} MB" class StatusBadge(QLabel): def __init__(self, parent=None): super().__init__("-", parent) self.setFixedHeight(26) self._apply_style(Colors.TEXT_MUTED) self.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) def _apply_style(self, color: str): # Inline style intentional - badge color is dynamic per status code self.setStyleSheet(f""" QLabel {{ color: {color}; background: {color}18; border: 1px solid {color}50; border-radius: 4px; padding: 2px 10px; font-weight: 700; }} """) def set_status(self, code: int, reason: str): color = status_color(code) self.setText(f" {code} {reason} ") self._apply_style(color) def set_error(self): self.setText(" ERROR ") self._apply_style(Colors.ERROR) def clear(self): self.setText("-") self._apply_style(Colors.TEXT_MUTED) class ResponsePanel(QWidget): def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # ── Top bar ────────────────────────────────────────────────────────── top_bar = QWidget() top_bar.setObjectName("responseBar") top_bar.setFixedHeight(42) top_layout = QHBoxLayout(top_bar) top_layout.setContentsMargins(12, 0, 12, 0) top_layout.setSpacing(8) resp_label = QLabel("RESPONSE") resp_label.setObjectName("responseTitle") top_layout.addWidget(resp_label) sep = QFrame() sep.setFrameShape(QFrame.Shape.VLine) top_layout.addWidget(sep) self.status_badge = StatusBadge() self.time_label = QLabel("") self.time_label.setObjectName("metaLabel") self.size_label = QLabel("") self.size_label.setObjectName("metaLabel") top_layout.addWidget(self.status_badge) top_layout.addWidget(self.time_label) top_layout.addWidget(self.size_label) top_layout.addStretch() # Search bar self.search_input = QLineEdit() self.search_input.setObjectName("searchBar") self.search_input.setPlaceholderText("Search response…") self.search_input.setFixedWidth(200) self.search_input.textChanged.connect(self._on_search) prev_btn = QPushButton("↑") prev_btn.setObjectName("ghost") prev_btn.setFixedSize(26, 26) prev_btn.setToolTip("Previous match") prev_btn.clicked.connect(lambda: self._nav(backward=True)) next_btn = QPushButton("↓") next_btn.setObjectName("ghost") next_btn.setFixedSize(26, 26) next_btn.setToolTip("Next match") next_btn.clicked.connect(lambda: self._nav(backward=False)) self.match_label = QLabel("") self.match_label.setObjectName("metaLabel") self.copy_btn = QPushButton("Copy") self.copy_btn.setObjectName("ghost") self.copy_btn.setFixedWidth(55) self.copy_btn.setToolTip("Copy response body") self.copy_btn.clicked.connect(self._copy) self.save_btn = QPushButton("Save") self.save_btn.setObjectName("ghost") self.save_btn.setFixedWidth(55) self.save_btn.setToolTip("Save response body to file") self.save_btn.clicked.connect(self._save) top_layout.addWidget(self.search_input) top_layout.addWidget(prev_btn) top_layout.addWidget(next_btn) top_layout.addWidget(self.match_label) top_layout.addWidget(self.copy_btn) top_layout.addWidget(self.save_btn) layout.addWidget(top_bar) # ── Stacked: content tabs + loading overlay ─────────────────────── self._stack = QStackedWidget() # ── Content tabs ──────────────────────────────────────────────────── self.tabs = QTabWidget() self.tabs.setObjectName("innerTabs") # Body view self.body_view = QTextEdit() self.body_view.setObjectName("codeEditor") self.body_view.setReadOnly(True) self.body_view.setFont( QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11) ) self._hl = JsonHighlighter(self.body_view.document()) self.tabs.addTab(self.body_view, "Body") # Headers table self.headers_table = QTableWidget(0, 2) self.headers_table.setHorizontalHeaderLabels(["Header", "Value"]) self.headers_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.headers_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.headers_table.verticalHeader().setVisible(False) self.tabs.addTab(self.headers_table, "Headers") # Test results tree self.test_tree = QTreeWidget() self.test_tree.setHeaderLabels(["Test", "Result"]) self.test_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.test_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.tabs.addTab(self.test_tree, "Tests") # ── Loading overlay ────────────────────────────────────────────────── loading_widget = QWidget() loading_widget.setObjectName("loadingOverlay") ll = QVBoxLayout(loading_widget) ll.setAlignment(Qt.AlignmentFlag.AlignCenter) self._loading_label = QLabel("Sending request…") self._loading_label.setObjectName("loadingLabel") self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) ll.addWidget(self._loading_label) self._stack.addWidget(self.tabs) # index 0 - normal view self._stack.addWidget(loading_widget) # index 1 - loading layout.addWidget(self._stack, 1) self._match_positions: list = [] # ── Public API ──────────────────────────────────────────────────────────── def set_loading(self, loading: bool): self._stack.setCurrentIndex(1 if loading else 0) def display(self, resp: HttpResponse, test_results: list = None): self.set_loading(False) if resp.error: self.status_badge.set_error() self.time_label.setText("") self.size_label.setText("") self.body_view.setPlainText(resp.error) self.tabs.setCurrentIndex(0) return # Status / timing / size self.status_badge.set_status(resp.status, resp.reason) self.time_label.setText(f"{resp.elapsed_ms:.0f} ms") size = resp.size_bytes or len(resp.body.encode()) self.size_label.setText(_fmt_size(size)) # Body - pretty-print JSON if possible try: parsed = json.loads(resp.body) self.body_view.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False)) except (json.JSONDecodeError, ValueError): self.body_view.setPlainText(resp.body) # Response headers self.headers_table.setRowCount(0) for k, v in sorted(resp.headers.items()): row = self.headers_table.rowCount() self.headers_table.insertRow(row) ki = QTableWidgetItem(k) ki.setForeground(QBrush(QColor(Colors.INFO))) self.headers_table.setItem(row, 0, ki) self.headers_table.setItem(row, 1, QTableWidgetItem(v)) # Test results self.test_tree.clear() if test_results: passed = sum(1 for t in test_results if t.passed) total = len(test_results) color = Colors.SUCCESS if passed == total else Colors.WARNING summary = QTreeWidgetItem([f"Results: {passed}/{total} passed", ""]) summary.setForeground(0, QBrush(QColor(color))) self.test_tree.addTopLevelItem(summary) for tr in test_results: icon = "✓" if tr.passed else "✗" child = QTreeWidgetItem([f" {icon} {tr.name}", tr.message]) child.setForeground(0, QBrush(QColor(Colors.SUCCESS if tr.passed else Colors.ERROR))) summary.addChild(child) summary.setExpanded(True) self.tabs.setTabText(2, f"Tests ({passed}/{total})") self.tabs.setCurrentIndex(2 if test_results else 0) else: self.tabs.setTabText(2, "Tests") self.tabs.setCurrentIndex(0) def clear(self): self.status_badge.clear() self.time_label.setText("") self.size_label.setText("") self.body_view.clear() self.headers_table.setRowCount(0) self.test_tree.clear() self.tabs.setTabText(2, "Tests") self.match_label.setText("") # ── Search ──────────────────────────────────────────────────────────────── def _on_search(self, text: str): if not text: self.match_label.setText("") return self._nav(backward=False) def _nav(self, backward: bool): text = self.search_input.text() if not text: return flag = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0) found = self.body_view.find(text, flag) if not found: cursor = self.body_view.textCursor() cursor.movePosition( QTextCursor.MoveOperation.End if backward else QTextCursor.MoveOperation.Start ) self.body_view.setTextCursor(cursor) self.body_view.find(text, flag) def _copy(self): text = self.body_view.toPlainText() if text: QApplication.clipboard().setText(text) def _save(self): text = self.body_view.toPlainText() if not text: return path, _ = QFileDialog.getSaveFileName( self, "Save Response", "response.json", "JSON (*.json);;Text (*.txt);;All Files (*)" ) if path: with open(path, "w", encoding="utf-8") as f: f.write(text)