Initial release — APIClient - Agent v2.0.0

AI-first API testing desktop client built with Python + PyQt6.

Features:
- Multi-tab HTTP request editor with params/headers/body/auth/tests
- KeyValueTable with per-row enable/disable checkboxes and 36px rows
- Format JSON button, syntax highlighting, pre-request & test scripts
- Collections, environments, history, import/export (Postman v2.1, cURL)
- OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens)
- EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL,
  Custom REST JSON with all auth types (instant, no AI tokens)
- Persistent AI chat sidebar (Claude-powered co-pilot) with streaming,
  context-aware suggestions, and one-click Apply to request editor
- AI collection generator from any docs URL or pasted spec
- WebSocket client, Mock server, Collection runner, Code generator
- Dark/light theme engine (global QSS, object-name selectors)
- SSL error detection with actionable hints
- MIT License

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:34:18 +05:30
parent 1dbbb4320b
commit 01662f7e0e
37 changed files with 7822 additions and 1 deletions

0
app/ui/__init__.py Normal file
View File

394
app/ui/ai_chat_panel.py Normal file
View File

@@ -0,0 +1,394 @@
"""APIClient - Agent — AI chat sidebar panel (persistent, context-aware)."""
import re
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
QPushButton, QScrollArea, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QEvent
from PyQt6.QtGui import QFont
from app.core import ai_chat
# ── Background worker ─────────────────────────────────────────────────────────
class ChatWorker(QThread):
chunk_received = pyqtSignal(str)
finished = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(self, messages: list, context: str):
super().__init__()
self.messages = messages
self.context = context
def run(self):
try:
text = ai_chat.stream_chat(
self.messages,
self.context,
chunk_cb=lambda c: self.chunk_received.emit(c),
)
self.finished.emit(text)
except ai_chat.AIError as e:
self.error.emit(str(e))
except Exception as e:
self.error.emit(f"Unexpected error: {e}")
# ── Apply block widget ────────────────────────────────────────────────────────
class ApplyBlock(QFrame):
apply_clicked = pyqtSignal(str, str) # type, content
_LABELS = {
"body": "Apply Body",
"params": "Apply Params",
"headers": "Apply Headers",
"test": "Apply Test Script",
}
def __init__(self, atype: str, content: str, parent=None):
super().__init__(parent)
self.setObjectName("applyBlock")
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4)
# Code preview
code = QTextEdit()
code.setObjectName("applyCode")
code.setReadOnly(True)
code.setPlainText(content)
code.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 9))
code.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
code.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
lines = content.count("\n") + 1
code.setFixedHeight(min(lines * 18 + 16, 180))
layout.addWidget(code)
# Apply button
btn = QPushButton(self._LABELS.get(atype, f"Apply {atype}"))
btn.setObjectName("accent")
btn.setFixedHeight(28)
btn.clicked.connect(lambda: self.apply_clicked.emit(atype, content))
layout.addWidget(btn)
# ── Message bubble ────────────────────────────────────────────────────────────
class MessageBubble(QFrame):
apply_requested = pyqtSignal(str, str)
def __init__(self, role: str, text: str = "", parent=None):
super().__init__(parent)
self.role = role
self._full_text = text
self._finalized = False
self.setObjectName("userBubble" if role == "user" else "aiBubble")
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(12, 8, 12, 8)
self._layout.setSpacing(6)
# Role label
role_lbl = QLabel("You" if role == "user" else "✦ APIClient - Agent")
role_lbl.setObjectName("chatRoleLabel")
self._layout.addWidget(role_lbl)
# Message text label
self._text_lbl = QLabel()
self._text_lbl.setObjectName("chatMessageText")
self._text_lbl.setWordWrap(True)
self._text_lbl.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse |
Qt.TextInteractionFlag.TextSelectableByKeyboard
)
self._text_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self._layout.addWidget(self._text_lbl)
# Apply blocks area
self._apply_area = QVBoxLayout()
self._apply_area.setSpacing(6)
self._layout.addLayout(self._apply_area)
if text:
self._render(text)
def append_chunk(self, chunk: str):
"""Called during streaming to add text incrementally."""
self._full_text += chunk
# Show raw text while streaming (apply blocks appear after finalize)
self._text_lbl.setText(self._full_text)
def finalize(self):
"""Called when streaming ends — strip apply blocks and render them."""
if self._finalized:
return
self._finalized = True
self._render(self._full_text)
def _render(self, text: str):
# Strip apply blocks from display text
display = ai_chat.strip_apply_blocks(text)
self._text_lbl.setText(display if display else text)
# Clear old apply blocks
while self._apply_area.count():
item = self._apply_area.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Add new apply blocks
for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL):
atype = m.group(1)
content = m.group(2).strip()
block = ApplyBlock(atype, content)
block.apply_clicked.connect(self.apply_requested)
self._apply_area.addWidget(block)
# ── Quick action definitions ──────────────────────────────────────────────────
QUICK_ACTIONS = [
("Analyze", "Analyze this request and response. What does the response mean? Any issues?"),
("Fix Error", "This request has an error. What went wrong and how do I fix it?"),
("Gen Body", "Generate a complete, correct request body for this endpoint with realistic example data."),
("Write Tests", "Write comprehensive test assertions for this response using pm.test()."),
("Auth Help", "Explain the authentication setup needed and show me the correct headers."),
("Explain Resp", "Explain this API response in detail. What does each field mean?"),
]
# ── Main chat panel ───────────────────────────────────────────────────────────
class AIChatPanel(QWidget):
"""Persistent right-sidebar AI chat panel. Context-aware, multi-turn."""
# Emitted when AI suggests applying something to the current request
apply_body = pyqtSignal(str)
apply_params = pyqtSignal(str)
apply_headers = pyqtSignal(str)
apply_test = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("aiChatPanel")
self.setMinimumWidth(300)
self.setMaximumWidth(500)
self._messages: list[dict] = []
self._context: str = ""
self._worker: ChatWorker|None = None
self._streaming_bubble: MessageBubble|None = None
self._build_ui()
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# Header
header = QWidget()
header.setObjectName("aiChatHeader")
header.setFixedHeight(44)
hl = QHBoxLayout(header)
hl.setContentsMargins(12, 0, 8, 0)
hl.setSpacing(8)
title = QLabel("✦ APIClient - Agent")
title.setObjectName("aiChatTitle")
hl.addWidget(title)
hl.addStretch()
clear_btn = QPushButton("Clear")
clear_btn.setObjectName("ghost")
clear_btn.setFixedHeight(26)
clear_btn.setToolTip("Clear conversation history")
clear_btn.clicked.connect(self._clear_chat)
hl.addWidget(clear_btn)
root.addWidget(header)
# Chat history scroll area
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QFrame.Shape.NoFrame)
self._scroll.setObjectName("chatScroll")
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._chat_container = QWidget()
self._chat_container.setObjectName("chatArea")
self._chat_layout = QVBoxLayout(self._chat_container)
self._chat_layout.setContentsMargins(8, 8, 8, 8)
self._chat_layout.setSpacing(8)
self._chat_layout.addStretch()
self._scroll.setWidget(self._chat_container)
root.addWidget(self._scroll, 1)
# Quick actions bar
qa_bar = QWidget()
qa_bar.setObjectName("quickActions")
qa_outer = QVBoxLayout(qa_bar)
qa_outer.setContentsMargins(8, 5, 8, 4)
qa_outer.setSpacing(4)
qa_lbl = QLabel("Quick Actions")
qa_lbl.setObjectName("hintText")
qa_outer.addWidget(qa_lbl)
for row_start in (0, 3):
row_layout = QHBoxLayout()
row_layout.setSpacing(4)
for label, prompt in QUICK_ACTIONS[row_start:row_start+3]:
btn = QPushButton(label)
btn.setObjectName("qaBtn")
btn.setFixedHeight(24)
btn.clicked.connect(lambda _, p=prompt: self._send(p))
row_layout.addWidget(btn)
qa_outer.addLayout(row_layout)
root.addWidget(qa_bar)
# Input area
input_area = QWidget()
input_area.setObjectName("chatInputArea")
il = QVBoxLayout(input_area)
il.setContentsMargins(8, 6, 8, 8)
il.setSpacing(5)
self._input = QTextEdit()
self._input.setObjectName("chatInput")
self._input.setPlaceholderText("Ask anything about this request… (Ctrl+Enter to send)")
self._input.setFont(QFont("Segoe UI, SF Pro, sans-serif", 10))
self._input.setFixedHeight(68)
self._input.installEventFilter(self)
il.addWidget(self._input)
btn_row = QHBoxLayout()
self._ctx_label = QLabel("No context")
self._ctx_label.setObjectName("hintText")
self._ctx_label.setWordWrap(False)
btn_row.addWidget(self._ctx_label, 1)
self._send_btn = QPushButton("Send")
self._send_btn.setObjectName("accent")
self._send_btn.setFixedSize(70, 28)
self._send_btn.clicked.connect(lambda: self._send(self._input.toPlainText().strip()))
btn_row.addWidget(self._send_btn)
il.addLayout(btn_row)
root.addWidget(input_area)
# ── Public API ────────────────────────────────────────────────────────────
def set_context(self, req=None, resp=None, env_vars: dict = None):
"""Update context from the main window (called on request sent / response received)."""
self._context = ai_chat.build_context(req, resp, env_vars)
if req and req.url:
name = req.name or req.url
short = name[:35] + "" if len(name) > 35 else name
self._ctx_label.setText(f"{req.method} {short}")
elif req:
self._ctx_label.setText("Request loaded")
else:
self._ctx_label.setText("No context")
# ── Event filter: Ctrl+Enter sends ───────────────────────────────────────
def eventFilter(self, obj, event):
if obj is self._input and event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
text = self._input.toPlainText().strip()
if text:
self._send(text)
return True
return super().eventFilter(obj, event)
# ── Sending ───────────────────────────────────────────────────────────────
def _send(self, text: str):
if not text:
return
if self._worker and self._worker.isRunning():
return # already streaming
self._input.clear()
self._messages.append({"role": "user", "content": text})
# User bubble
user_bubble = MessageBubble("user", text)
self._add_bubble(user_bubble)
# AI streaming bubble (starts empty)
ai_bubble = MessageBubble("assistant")
ai_bubble.apply_requested.connect(self._on_apply)
self._streaming_bubble = ai_bubble
self._add_bubble(ai_bubble)
self._send_btn.setEnabled(False)
self._send_btn.setText("")
self._worker = ChatWorker(list(self._messages), self._context)
self._worker.chunk_received.connect(self._on_chunk)
self._worker.finished.connect(self._on_done)
self._worker.error.connect(self._on_error)
self._worker.start()
def _on_chunk(self, chunk: str):
if self._streaming_bubble:
self._streaming_bubble.append_chunk(chunk)
QTimer.singleShot(0, self._scroll_to_bottom)
def _on_done(self, full_text: str):
self._messages.append({"role": "assistant", "content": full_text})
if self._streaming_bubble:
self._streaming_bubble.finalize()
self._streaming_bubble = None
self._send_btn.setEnabled(True)
self._send_btn.setText("Send")
QTimer.singleShot(50, self._scroll_to_bottom)
def _on_error(self, msg: str):
if self._streaming_bubble:
self._streaming_bubble.finalize()
self._streaming_bubble = None
err_bubble = MessageBubble("assistant", f"Error: {msg}")
self._add_bubble(err_bubble)
self._send_btn.setEnabled(True)
self._send_btn.setText("Send")
def _on_apply(self, atype: str, content: str):
signal_map = {
"body": self.apply_body,
"params": self.apply_params,
"headers": self.apply_headers,
"test": self.apply_test,
}
if atype in signal_map:
signal_map[atype].emit(content)
# ── Helpers ───────────────────────────────────────────────────────────────
def _add_bubble(self, bubble: MessageBubble):
idx = self._chat_layout.count() - 1 # insert before the stretch
self._chat_layout.insertWidget(idx, bubble)
QTimer.singleShot(50, self._scroll_to_bottom)
def _scroll_to_bottom(self):
vsb = self._scroll.verticalScrollBar()
vsb.setValue(vsb.maximum())
def _clear_chat(self):
self._messages.clear()
while self._chat_layout.count() > 1:
item = self._chat_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()

699
app/ui/ai_panel.py Normal file
View File

@@ -0,0 +1,699 @@
"""APIClient - Agent — AI Assistant Dialog."""
import json
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QTextEdit, QWidget, QTabWidget, QMessageBox,
QProgressBar, QCheckBox, QFormLayout, QComboBox, QScrollArea,
QGroupBox, QGridLayout, QSizePolicy
)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QFont
from app.core import storage, ai_client, openapi_parser
from app.core.ekika_odoo_generator import (
generate_collection, API_KINDS, AUTH_TYPES, OPERATIONS
)
from app.models import HttpRequest, Environment
# ── Generic analysis worker ───────────────────────────────────────────────────
class AnalysisWorker(QThread):
progress = pyqtSignal(str)
finished = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, url: str = "", raw_text: str = "",
base_url: str = "", models: list = None):
super().__init__()
self.url = url
self.raw_text = raw_text
self.base_url = base_url
self.models = models or []
def run(self):
try:
content = self.raw_text
if self.url and not content:
self.progress.emit("Fetching documentation…")
content = ai_client.fetch_url_content(self.url)
self.progress.emit("Checking for OpenAPI/Swagger spec…")
spec = openapi_parser.detect_spec(content)
if spec:
self.progress.emit("OpenAPI spec detected — parsing directly…")
result = openapi_parser.parse_spec(spec)
if self.base_url:
result["base_url"] = self.base_url
result.setdefault("environment_variables", {})["base_url"] = self.base_url
result["_source"] = "openapi"
self.finished.emit(result)
return
prompt = self._build_prompt(content)
result = ai_client.analyze_docs(prompt, progress_cb=self.progress.emit)
result["_source"] = "ai"
if self.base_url and not result.get("base_url"):
result["base_url"] = self.base_url
if self.base_url:
result.setdefault("environment_variables", {})["base_url"] = self.base_url
self.finished.emit(result)
except ai_client.AIError as e:
self.error.emit(str(e))
except Exception as e:
self.error.emit(f"Unexpected error: {e}")
def _build_prompt(self, content: str) -> str:
parts = ["Analyze the following API documentation and generate the JSON collection.\n"]
if self.base_url:
parts.append(f"The user's API instance base URL is: {self.base_url}")
if self.models:
parts.append(
f"The user wants endpoints for these specific models/resources: "
f"{', '.join(self.models)}\n"
f"Generate a full CRUD set for each model."
)
parts.append("\n--- DOCUMENTATION ---\n")
parts.append(content)
return "\n".join(parts)
# ── Main Dialog ───────────────────────────────────────────────────────────────
class AIAssistantDialog(QDialog):
collection_imported = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("AI Assistant")
self.setMinimumSize(900, 680)
self._worker: AnalysisWorker | None = None
self._result: dict | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header ────────────────────────────────────────────────────────────
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(52)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("AI Assistant")
title.setObjectName("panelTitle")
hl.addWidget(title)
hl.addStretch()
sub = QLabel("EKIKA Odoo API Framework · OpenAPI · Any REST API")
sub.setObjectName("hintText")
hl.addWidget(sub)
layout.addWidget(header)
# ── Tabs ──────────────────────────────────────────────────────────────
self.tabs = QTabWidget()
self.tabs.addTab(self._build_ekika_tab(), " EKIKA Odoo API ")
self.tabs.addTab(self._build_generic_tab(), " Import from Docs ")
self.tabs.addTab(self._build_settings_tab(), " Settings ")
layout.addWidget(self.tabs, 1)
# ── Footer ────────────────────────────────────────────────────────────
footer = QWidget()
footer.setObjectName("panelFooter")
footer.setFixedHeight(52)
fl = QHBoxLayout(footer)
fl.setContentsMargins(16, 0, 16, 0)
self.status_label = QLabel("Ready")
self.status_label.setObjectName("aiStatusLabel")
fl.addWidget(self.status_label)
fl.addStretch()
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.accept)
fl.addWidget(close_btn)
layout.addWidget(footer)
# ══════════════════════════════════════════════════════════════════════════
# Tab 1 — EKIKA Odoo API Framework (dedicated, no AI tokens needed)
# ══════════════════════════════════════════════════════════════════════════
def _build_ekika_tab(self) -> QWidget:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(scroll.Shape.NoFrame)
w = QWidget()
w.setObjectName("panelBody")
layout = QVBoxLayout(w)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(14)
# ── Connection ────────────────────────────────────────────────────────
conn_group = QGroupBox("Connection")
cg = QFormLayout(conn_group)
cg.setSpacing(8)
self.ek_instance_url = QLineEdit()
self.ek_instance_url.setObjectName("urlBar")
self.ek_instance_url.setPlaceholderText("https://mycompany.odoo.com")
self.ek_instance_url.setText("https://api_framework-18.demo.odoo-apps.ekika.co")
self.ek_endpoint = QLineEdit()
self.ek_endpoint.setPlaceholderText("/user-jsonapi-apikey")
self.ek_endpoint.setText("/user-jsonapi-apikey")
self.ek_api_kind = QComboBox()
self.ek_api_kind.addItems(API_KINDS)
cg.addRow("Instance URL:", self.ek_instance_url)
cg.addRow("API Endpoint:", self.ek_endpoint)
cg.addRow("API Kind:", self.ek_api_kind)
layout.addWidget(conn_group)
# ── Authentication ────────────────────────────────────────────────────
auth_group = QGroupBox("Authentication")
ag = QVBoxLayout(auth_group)
auth_top = QHBoxLayout()
auth_top.addWidget(QLabel("Auth Type:"))
self.ek_auth_type = QComboBox()
self.ek_auth_type.addItems(AUTH_TYPES)
self.ek_auth_type.currentTextChanged.connect(self._on_ek_auth_changed)
auth_top.addWidget(self.ek_auth_type)
auth_top.addStretch()
ag.addLayout(auth_top)
# Auth fields stack (we show/hide rows as needed)
self.ek_auth_form = QFormLayout()
self.ek_auth_form.setSpacing(8)
self.ek_api_key_input = QLineEdit()
self.ek_api_key_input.setPlaceholderText("Your API key value")
self.ek_api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
show_ak = QCheckBox("Show")
show_ak.toggled.connect(lambda on: self.ek_api_key_input.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
ak_row = QHBoxLayout()
ak_row.addWidget(self.ek_api_key_input)
ak_row.addWidget(show_ak)
self._ek_ak_label = QLabel("API Key:")
self.ek_auth_form.addRow(self._ek_ak_label, ak_row)
self.ek_username = QLineEdit()
self.ek_username.setPlaceholderText("admin")
self._ek_user_label = QLabel("Username:")
self.ek_auth_form.addRow(self._ek_user_label, self.ek_username)
self.ek_password = QLineEdit()
self.ek_password.setEchoMode(QLineEdit.EchoMode.Password)
self.ek_password.setPlaceholderText("password")
show_pw = QCheckBox("Show")
show_pw.toggled.connect(lambda on: self.ek_password.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
pw_row = QHBoxLayout()
pw_row.addWidget(self.ek_password)
pw_row.addWidget(show_pw)
self._ek_pw_label = QLabel("Password:")
self.ek_auth_form.addRow(self._ek_pw_label, pw_row)
ag.addLayout(self.ek_auth_form)
layout.addWidget(auth_group)
self._on_ek_auth_changed("API Key") # set initial visibility
# ── Models ────────────────────────────────────────────────────────────
models_group = QGroupBox("Models")
mg = QVBoxLayout(models_group)
models_hint = QLabel(
"Enter Odoo model technical names (comma-separated).\n"
"Examples: sale.order, res.partner, account.move, product.template"
)
models_hint.setObjectName("hintText")
models_hint.setWordWrap(True)
mg.addWidget(models_hint)
self.ek_models = QLineEdit()
self.ek_models.setPlaceholderText("sale.order, res.partner, product.template")
self.ek_models.setText("sale.order")
mg.addWidget(self.ek_models)
layout.addWidget(models_group)
# ── Operations ────────────────────────────────────────────────────────
ops_group = QGroupBox("Operations to Generate")
og = QGridLayout(ops_group)
og.setSpacing(6)
self.ek_op_checks: dict[str, QCheckBox] = {}
default_ops = {"List Records", "Get Single Record", "Create Record",
"Update Record", "Delete Record"}
cols = 3
for i, op in enumerate(OPERATIONS):
cb = QCheckBox(op)
cb.setChecked(op in default_ops)
self.ek_op_checks[op] = cb
og.addWidget(cb, i // cols, i % cols)
layout.addWidget(ops_group)
# ── Collection name ───────────────────────────────────────────────────
name_row = QHBoxLayout()
name_label = QLabel("Collection Name:")
name_label.setObjectName("fieldLabel")
self.ek_col_name = QLineEdit()
self.ek_col_name.setPlaceholderText("Leave blank for auto-name")
name_row.addWidget(name_label)
name_row.addWidget(self.ek_col_name, 1)
layout.addLayout(name_row)
# ── Generate button ───────────────────────────────────────────────────
gen_row = QHBoxLayout()
self.ek_generate_btn = QPushButton("Generate Collection")
self.ek_generate_btn.setObjectName("accent")
self.ek_generate_btn.setFixedHeight(36)
self.ek_generate_btn.clicked.connect(self._ekika_generate)
gen_row.addWidget(self.ek_generate_btn)
gen_row.addStretch()
self.ek_import_btn = QPushButton("Import Collection")
self.ek_import_btn.setFixedHeight(36)
self.ek_import_btn.setEnabled(False)
self.ek_import_btn.clicked.connect(self._ekika_import)
gen_row.addWidget(self.ek_import_btn)
self.ek_env_btn = QPushButton("Create Environment")
self.ek_env_btn.setFixedHeight(36)
self.ek_env_btn.setEnabled(False)
self.ek_env_btn.clicked.connect(self._ekika_create_env)
gen_row.addWidget(self.ek_env_btn)
self.ek_both_btn = QPushButton("Import Both")
self.ek_both_btn.setObjectName("accent")
self.ek_both_btn.setFixedHeight(36)
self.ek_both_btn.setEnabled(False)
self.ek_both_btn.clicked.connect(self._ekika_import_both)
gen_row.addWidget(self.ek_both_btn)
layout.addLayout(gen_row)
# ── Preview ───────────────────────────────────────────────────────────
preview_label = QLabel("Preview:")
preview_label.setObjectName("fieldLabel")
layout.addWidget(preview_label)
self.ek_preview = QTextEdit()
self.ek_preview.setObjectName("aiOutput")
self.ek_preview.setReadOnly(True)
self.ek_preview.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
self.ek_preview.setPlaceholderText(
"Fill in the form above and click Generate Collection to preview.\n\n"
"No API key required — collection is generated instantly from the\n"
"EKIKA Odoo API Framework documentation."
)
self.ek_preview.setMaximumHeight(180)
layout.addWidget(self.ek_preview)
scroll.setWidget(w)
return scroll
def _on_ek_auth_changed(self, auth_type: str):
show_key = auth_type == "API Key"
show_user = auth_type in ("Basic Auth", "User Credentials")
show_pw = auth_type in ("Basic Auth", "User Credentials")
self._ek_ak_label.setVisible(show_key)
self.ek_api_key_input.setVisible(show_key)
# find the show checkbox (parent widget)
self._ek_user_label.setVisible(show_user)
self.ek_username.setVisible(show_user)
self._ek_pw_label.setVisible(show_pw)
self.ek_password.setVisible(show_pw)
def _ekika_generate(self):
instance_url = self.ek_instance_url.text().strip()
endpoint = self.ek_endpoint.text().strip()
api_kind = self.ek_api_kind.currentText()
auth_type = self.ek_auth_type.currentText()
models_raw = self.ek_models.text().strip()
models = [m.strip() for m in models_raw.split(",") if m.strip()]
operations = [op for op, cb in self.ek_op_checks.items() if cb.isChecked()]
col_name = self.ek_col_name.text().strip()
if not instance_url:
QMessageBox.warning(self, "Missing", "Enter the Odoo Instance URL.")
return
if not models:
QMessageBox.warning(self, "Missing", "Enter at least one model name.")
return
if not operations:
QMessageBox.warning(self, "Missing", "Select at least one operation.")
return
auth_creds = {
"api_key": self.ek_api_key_input.text().strip(),
"username": self.ek_username.text().strip(),
"password": self.ek_password.text().strip(),
}
self._result = generate_collection(
instance_url = instance_url,
endpoint = endpoint,
api_kind = api_kind,
auth_type = auth_type,
auth_creds = auth_creds,
models = models,
operations = operations,
collection_name = col_name,
)
eps = self._result["endpoints"]
envs = self._result["environment_variables"]
lines = [
f"✓ Collection: {self._result['collection_name']}",
f"✓ API Kind: {api_kind}",
f"✓ Auth: {auth_type}",
f"✓ Endpoints: {len(eps)} generated",
f"✓ Env vars: {list(envs.keys())}",
"",
"── Endpoints ─────────────────────────────────────",
]
for ep in eps:
lines.append(f" {ep['method']:<8} {ep['path']}")
self.ek_preview.setPlainText("\n".join(lines))
self.ek_import_btn.setEnabled(True)
self.ek_env_btn.setEnabled(True)
self.ek_both_btn.setEnabled(True)
self.status_label.setText(f"{len(eps)} endpoint(s) ready — click Import to save")
def _ekika_import(self):
if not self._result:
return
self._do_import(self._result)
def _ekika_create_env(self):
if not self._result:
return
self._do_create_env(self._result)
def _ekika_import_both(self):
if not self._result:
return
self._do_import(self._result)
self._do_create_env(self._result)
# ══════════════════════════════════════════════════════════════════════════
# Tab 2 — Generic AI analysis (OpenAPI / any docs URL)
# ══════════════════════════════════════════════════════════════════════════
def _build_generic_tab(self) -> QWidget:
w = QWidget()
w.setObjectName("panelBody")
layout = QVBoxLayout(w)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(10)
url_row = QHBoxLayout()
url_label = QLabel("Docs URL:")
url_label.setObjectName("fieldLabel")
url_label.setFixedWidth(80)
self.url_input = QLineEdit()
self.url_input.setObjectName("urlBar")
self.url_input.setPlaceholderText(
"https://api.example.com/openapi.json or https://docs.example.com"
)
self.url_input.returnPressed.connect(self._analyze)
self.analyze_btn = QPushButton("Analyze")
self.analyze_btn.setObjectName("accent")
self.analyze_btn.setFixedWidth(100)
self.analyze_btn.clicked.connect(self._analyze)
url_row.addWidget(url_label)
url_row.addWidget(self.url_input, 1)
url_row.addWidget(self.analyze_btn)
layout.addLayout(url_row)
ctx_row = QHBoxLayout()
base_label = QLabel("Base URL:")
base_label.setObjectName("fieldLabel")
base_label.setFixedWidth(80)
self.base_url_input = QLineEdit()
self.base_url_input.setPlaceholderText("https://myapi.example.com (optional)")
models_label = QLabel("Models:")
models_label.setObjectName("fieldLabel")
models_label.setFixedWidth(55)
self.models_input = QLineEdit()
self.models_input.setPlaceholderText("res.partner, sale.order (optional)")
ctx_row.addWidget(base_label)
ctx_row.addWidget(self.base_url_input, 2)
ctx_row.addSpacing(8)
ctx_row.addWidget(models_label)
ctx_row.addWidget(self.models_input, 3)
layout.addLayout(ctx_row)
paste_hint = QLabel("Or paste raw documentation / OpenAPI JSON / YAML:")
paste_hint.setObjectName("hintText")
layout.addWidget(paste_hint)
self.paste_editor = QTextEdit()
self.paste_editor.setObjectName("codeEditor")
self.paste_editor.setPlaceholderText("Paste OpenAPI JSON, Swagger YAML, or raw API docs…")
self.paste_editor.setMaximumHeight(110)
self.paste_editor.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
layout.addWidget(self.paste_editor)
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 0)
self.progress_bar.setFixedHeight(4)
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
results_label = QLabel("Analysis Result:")
results_label.setObjectName("fieldLabel")
layout.addWidget(results_label)
self.result_view = QTextEdit()
self.result_view.setObjectName("aiOutput")
self.result_view.setReadOnly(True)
self.result_view.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
self.result_view.setPlaceholderText(
"Results will appear here.\n\n"
"• OpenAPI/Swagger specs are parsed instantly (no API key needed)\n"
"• Other documentation is analyzed by Claude AI"
)
layout.addWidget(self.result_view, 1)
action_row = QHBoxLayout()
self.import_btn = QPushButton("Import Collection")
self.import_btn.setObjectName("accent")
self.import_btn.setFixedWidth(160)
self.import_btn.setEnabled(False)
self.import_btn.clicked.connect(self._generic_import)
self.env_btn = QPushButton("Create Environment")
self.env_btn.setFixedWidth(160)
self.env_btn.setEnabled(False)
self.env_btn.clicked.connect(self._generic_create_env)
self.both_btn = QPushButton("Import Both")
self.both_btn.setObjectName("accent")
self.both_btn.setFixedWidth(120)
self.both_btn.setEnabled(False)
self.both_btn.clicked.connect(self._generic_import_both)
action_row.addWidget(self.import_btn)
action_row.addWidget(self.env_btn)
action_row.addWidget(self.both_btn)
action_row.addStretch()
layout.addLayout(action_row)
return w
def _analyze(self):
url = self.url_input.text().strip()
raw_text = self.paste_editor.toPlainText().strip()
base_url = self.base_url_input.text().strip()
models_raw = self.models_input.text().strip()
models = [m.strip() for m in models_raw.split(",") if m.strip()]
if not url and not raw_text:
QMessageBox.warning(self, "Input Required", "Enter a URL or paste documentation text.")
return
self._generic_result = None
self.analyze_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.result_view.clear()
self._set_generic_action_buttons(False)
self._worker = AnalysisWorker(url=url, raw_text=raw_text,
base_url=base_url, models=models)
self._worker.progress.connect(lambda m: self.status_label.setText(m))
self._worker.finished.connect(self._on_generic_finished)
self._worker.error.connect(self._on_generic_error)
self._worker.start()
def _on_generic_finished(self, result: dict):
self._generic_result = result
self.analyze_btn.setEnabled(True)
self.progress_bar.setVisible(False)
self._set_generic_action_buttons(True)
source = result.pop("_source", "ai")
src_label = {"openapi": "OpenAPI spec (local)", "ai": "Claude AI"}.get(source, source)
endpoints = result.get("endpoints", [])
env_vars = result.get("environment_variables", {})
notes = result.get("notes", "")
lines = [
f"✓ Parsed via: {src_label}",
f"✓ Collection: {result.get('collection_name', 'Unnamed')}",
f"✓ Base URL: {result.get('base_url', '')}",
f"✓ Auth type: {result.get('auth_type', 'none')}",
f"✓ Endpoints: {len(endpoints)} found",
f"✓ Env vars: {list(env_vars.keys()) or ''}",
]
if notes:
lines += ["", "── Notes ─────────────────", notes]
lines += ["", "── Endpoints ─────────────────────────────────────"]
for ep in endpoints:
lines.append(f" {ep['method']:<8} {ep['path']} ({ep.get('name','')})")
if env_vars:
lines += ["", "── Environment Variables ──────────────────────────"]
for k, v in env_vars.items():
lines.append(f" {k} = {v!r}")
self.result_view.setPlainText("\n".join(lines))
self.status_label.setText(f"✓ Found {len(endpoints)} endpoint(s)")
def _on_generic_error(self, msg: str):
self.analyze_btn.setEnabled(True)
self.progress_bar.setVisible(False)
self.result_view.setPlainText(f"✗ Error:\n\n{msg}")
self.status_label.setText("Error — see results panel")
def _set_generic_action_buttons(self, enabled: bool):
self.import_btn.setEnabled(enabled)
self.env_btn.setEnabled(enabled)
self.both_btn.setEnabled(enabled)
def _generic_import(self):
if hasattr(self, "_generic_result") and self._generic_result:
self._do_import(self._generic_result)
def _generic_create_env(self):
if hasattr(self, "_generic_result") and self._generic_result:
self._do_create_env(self._generic_result)
def _generic_import_both(self):
if hasattr(self, "_generic_result") and self._generic_result:
self._do_import(self._generic_result)
self._do_create_env(self._generic_result)
# ══════════════════════════════════════════════════════════════════════════
# Tab 3 — Settings
# ══════════════════════════════════════════════════════════════════════════
def _build_settings_tab(self) -> QWidget:
w = QWidget()
w.setObjectName("panelBody")
outer = QVBoxLayout(w)
outer.setContentsMargins(24, 20, 24, 20)
outer.setSpacing(16)
hint = QLabel(
"EKIKA AI Assistant uses Claude by Anthropic to analyze plain-text API documentation.\n"
"OpenAPI/Swagger specs and EKIKA Odoo Framework collections are generated locally — "
"no API key required for those."
)
hint.setObjectName("hintText")
hint.setWordWrap(True)
outer.addWidget(hint)
form = QFormLayout()
form.setSpacing(10)
self.api_key_input = QLineEdit()
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
self.api_key_input.setPlaceholderText("sk-ant-…")
self.api_key_input.setText(ai_client.get_api_key())
show_key = QCheckBox("Show")
show_key.toggled.connect(lambda on: self.api_key_input.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
key_row = QHBoxLayout()
key_row.addWidget(self.api_key_input)
key_row.addWidget(show_key)
form.addRow("Anthropic API Key:", key_row)
outer.addLayout(form)
save_key_btn = QPushButton("Save API Key")
save_key_btn.setObjectName("accent")
save_key_btn.setFixedWidth(130)
save_key_btn.clicked.connect(self._save_api_key)
outer.addWidget(save_key_btn)
outer.addStretch()
info = QLabel(
"Get your key at console.anthropic.com\n"
"Keys are stored locally in the EKIKA database only."
)
info.setObjectName("hintText")
info.setWordWrap(True)
outer.addWidget(info)
return w
def _save_api_key(self):
ai_client.set_api_key(self.api_key_input.text().strip())
QMessageBox.information(self, "Saved", "Anthropic API key saved.")
# ══════════════════════════════════════════════════════════════════════════
# Shared import helpers
# ══════════════════════════════════════════════════════════════════════════
def _do_import(self, result: dict):
col_name = result.get("collection_name", "AI Import")
endpoints = result.get("endpoints", [])
base_url = result.get("base_url", "")
col_id = storage.add_collection(col_name)
for ep in endpoints:
# Build full URL with {{base_url}} variable
path = ep.get("path", "")
if not path.startswith("http") and base_url:
url = f"{{{{base_url}}}}{path}"
else:
url = ep.get("url", path)
req = HttpRequest(
name = ep.get("name", ""),
method = ep.get("method", "GET"),
url = url,
headers = ep.get("headers", {}),
params = ep.get("params", {}),
body = ep.get("body", ""),
body_type = ep.get("body_type", "raw"),
content_type = ep.get("content_type", ""),
test_script = ep.get("test_script", ""),
)
storage.save_request(col_id, req)
self.collection_imported.emit()
QMessageBox.information(
self, "Collection Imported",
f"✓ Imported '{col_name}'\n"
f" {len(endpoints)} request(s) added to the sidebar."
)
def _do_create_env(self, result: dict):
env_vars = result.get("environment_variables", {})
col_name = result.get("collection_name", "AI Import")
env_name = f"{col_name} — Environment"
if not env_vars:
QMessageBox.information(self, "No Variables", "No environment variables detected.")
return
env = Environment(name=env_name, variables=env_vars)
env_id = storage.save_environment(env)
QMessageBox.information(
self, "Environment Created",
f"✓ Created environment '{env_name}'\n"
f" Variables: {', '.join(env_vars.keys())}"
)

76
app/ui/code_gen_dialog.py Normal file
View File

@@ -0,0 +1,76 @@
"""APIClient - Agent — Code Generation Dialog."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QComboBox,
QTextEdit, QPushButton, QLabel, QApplication, QWidget
)
from PyQt6.QtGui import QFont
from app.ui.theme import Colors
from app.core.code_gen import GENERATORS
from app.models import HttpRequest
class CodeGenDialog(QDialog):
def __init__(self, req: HttpRequest, parent=None):
super().__init__(parent)
self.req = req
self.setWindowTitle("Generate Code Snippet")
self.setMinimumSize(720, 540)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(52)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Generate Code")
title.setObjectName("panelTitle")
hl.addWidget(title)
hl.addStretch()
lang_label = QLabel("Language:")
lang_label.setObjectName("fieldLabel")
self.lang_combo = QComboBox()
self.lang_combo.addItems(list(GENERATORS.keys()))
self.lang_combo.setMinimumWidth(200)
self.lang_combo.currentTextChanged.connect(self._generate)
hl.addWidget(lang_label)
hl.addWidget(self.lang_combo)
layout.addWidget(header)
# Code view
self.code_view = QTextEdit()
self.code_view.setObjectName("codeEditor")
self.code_view.setReadOnly(True)
self.code_view.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas", 11))
layout.addWidget(self.code_view, 1)
# Footer
footer = QWidget()
footer.setObjectName("panelFooter")
footer.setFixedHeight(52)
fl = QHBoxLayout(footer)
fl.setContentsMargins(16, 0, 16, 0)
fl.addStretch()
copy_btn = QPushButton("Copy to Clipboard")
copy_btn.setObjectName("accent")
copy_btn.setFixedWidth(150)
copy_btn.clicked.connect(self._copy)
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.accept)
fl.addWidget(copy_btn)
fl.addWidget(close_btn)
layout.addWidget(footer)
self._generate(self.lang_combo.currentText())
def _generate(self, lang: str):
gen = GENERATORS.get(lang)
if gen:
self.code_view.setPlainText(gen(self.req))
def _copy(self):
QApplication.clipboard().setText(self.code_view.toPlainText())

194
app/ui/collection_runner.py Normal file
View File

@@ -0,0 +1,194 @@
"""APIClient - Agent — Collection Runner dialog."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTreeWidget, QTreeWidgetItem, QComboBox, QHeaderView, QProgressBar, QWidget
)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QBrush, QColor
from app.ui.theme import Colors
from app.core import storage, http_client
from app.core.test_runner import run_tests
from app.models import HttpRequest, CollectionRunResult
class RunnerWorker(QThread):
result_ready = pyqtSignal(object)
finished = pyqtSignal()
def __init__(self, requests: list[dict], variables: dict):
super().__init__()
self.requests = requests
self.variables = variables
def run(self):
for r in self.requests:
req = HttpRequest(
method = r.get("method") or "GET",
url = r.get("url") or "",
headers = r.get("headers") or {},
params = r.get("params") or {},
body = r.get("body") or "",
body_type = r.get("body_type") or "raw",
auth_type = r.get("auth_type") or "none",
auth_data = r.get("auth_data") or {},
test_script = r.get("test_script") or "",
name = r.get("name") or r.get("url", ""),
timeout = r.get("timeout") or 30,
ssl_verify = bool(r.get("ssl_verify", 1)),
)
resp = http_client.send_request(req, self.variables)
test_results = run_tests(req.test_script, resp)
result = CollectionRunResult(
request_name = req.name or req.url,
method = req.method,
url = req.url,
status = resp.status,
elapsed_ms = resp.elapsed_ms,
test_results = test_results,
error = resp.error,
)
self.result_ready.emit(result)
self.finished.emit()
class CollectionRunnerDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Collection Runner")
self.setMinimumSize(800, 550)
self._worker = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header ────────────────────────────────────────────────────────────
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(52)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Collection Runner")
title.setObjectName("panelTitle")
hl.addWidget(title)
hl.addStretch()
col_label = QLabel("Collection:")
col_label.setObjectName("fieldLabel")
self.col_combo = QComboBox()
self.col_combo.setMinimumWidth(200)
self._collections = storage.get_collections()
for c in self._collections:
self.col_combo.addItem(c["name"], c["id"])
self.run_btn = QPushButton("Run All")
self.run_btn.setObjectName("accent")
self.run_btn.setFixedWidth(100)
self.run_btn.clicked.connect(self._run)
hl.addWidget(col_label)
hl.addWidget(self.col_combo)
hl.addSpacing(8)
hl.addWidget(self.run_btn)
layout.addWidget(header)
# ── Body ──────────────────────────────────────────────────────────────
body = QWidget()
body.setObjectName("panelBody")
bl = QVBoxLayout(body)
bl.setContentsMargins(16, 12, 16, 12)
self.progress = QProgressBar()
self.progress.setValue(0)
bl.addWidget(self.progress)
self.result_tree = QTreeWidget()
self.result_tree.setHeaderLabels(["Request", "Status", "Time", "Tests"])
self.result_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.result_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
self.result_tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.result_tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
bl.addWidget(self.result_tree)
self.summary_label = QLabel("")
self.summary_label.setObjectName("fieldLabel")
bl.addWidget(self.summary_label)
layout.addWidget(body, 1)
# ── Footer ────────────────────────────────────────────────────────────
footer = QWidget()
footer.setObjectName("panelFooter")
footer.setFixedHeight(52)
fl = QHBoxLayout(footer)
fl.setContentsMargins(16, 0, 16, 0)
fl.addStretch()
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.accept)
fl.addWidget(close_btn)
layout.addWidget(footer)
def _run(self):
col_id = self.col_combo.currentData()
if col_id is None:
return
requests = storage.get_all_requests(col_id)
if not requests:
self.summary_label.setText("No requests in this collection.")
return
self.result_tree.clear()
self.progress.setMaximum(len(requests))
self.progress.setValue(0)
self.run_btn.setEnabled(False)
self._done = 0
self._passed_tests = 0
self._total_tests = 0
env = storage.get_active_environment()
variables = env.variables if env else {}
self._worker = RunnerWorker(requests, variables)
self._worker.result_ready.connect(self._on_result)
self._worker.finished.connect(self._on_finished)
self._worker.start()
def _on_result(self, result: CollectionRunResult):
self._done += 1
self.progress.setValue(self._done)
passed = sum(1 for t in result.test_results if t.passed)
total = len(result.test_results)
self._passed_tests += passed
self._total_tests += total
if result.error:
status_str = "Error"
row_color = Colors.ERROR
else:
status_str = str(result.status)
row_color = Colors.SUCCESS if result.status < 400 else Colors.ERROR
test_str = f"{passed}/{total}" if total > 0 else ""
item = QTreeWidgetItem([
f"{result.method} {result.request_name}",
status_str,
f"{result.elapsed_ms:.0f} ms",
test_str,
])
item.setForeground(1, QBrush(QColor(row_color)))
self.result_tree.addTopLevelItem(item)
for tr in result.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)))
item.addChild(child)
item.setExpanded(True)
def _on_finished(self):
self.run_btn.setEnabled(True)
self.summary_label.setText(
f"Completed: {self._done} request(s) — "
f"Tests: {self._passed_tests}/{self._total_tests} passed"
)

View File

@@ -0,0 +1,238 @@
"""APIClient - Agent — Environment Manager Dialog."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QTableWidget, QTableWidgetItem, QHeaderView,
QLabel, QInputDialog, QMessageBox, QSplitter, QWidget
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QBrush, QColor
from app.ui.theme import Colors
from app.core import storage
from app.models import Environment
class EnvironmentDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Manage Environments")
self.setMinimumSize(760, 520)
self._current_env: Environment | None = None
self._dirty = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Title bar ─────────────────────────────────────────────────────────
title_bar = QWidget()
title_bar.setObjectName("panelHeader")
title_bar.setFixedHeight(48)
tl = QHBoxLayout(title_bar)
tl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Manage Environments")
title.setObjectName("panelTitle")
tl.addWidget(title)
layout.addWidget(title_bar)
# ── Splitter ──────────────────────────────────────────────────────────
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setHandleWidth(1)
# Left: environment list
left = QWidget()
left.setObjectName("sidebarPanel")
left.setFixedWidth(220)
ll = QVBoxLayout(left)
ll.setContentsMargins(0, 0, 0, 0)
ll.setSpacing(0)
list_header = QWidget()
list_header.setObjectName("sectionHeader")
list_header.setFixedHeight(36)
lh = QHBoxLayout(list_header)
lh.setContentsMargins(12, 0, 8, 0)
env_heading = QLabel("ENVIRONMENTS")
env_heading.setObjectName("sectionLabel")
lh.addWidget(env_heading)
lh.addStretch()
add_env_btn = QPushButton("+")
add_env_btn.setObjectName("ghost")
add_env_btn.setFixedSize(26, 26)
add_env_btn.setToolTip("Add Environment")
add_env_btn.clicked.connect(self._add_env)
lh.addWidget(add_env_btn)
ll.addWidget(list_header)
self.env_list = QListWidget()
self.env_list.setObjectName("sidebarList")
self.env_list.currentItemChanged.connect(self._on_env_selected)
ll.addWidget(self.env_list)
btn_row = QHBoxLayout()
btn_row.setContentsMargins(8, 6, 8, 6)
btn_row.setSpacing(6)
self.activate_btn = QPushButton("Set Active")
self.activate_btn.clicked.connect(self._set_active)
self.del_btn = QPushButton("Delete")
self.del_btn.setObjectName("danger")
self.del_btn.clicked.connect(self._delete_env)
btn_row.addWidget(self.activate_btn)
btn_row.addWidget(self.del_btn)
ll.addLayout(btn_row)
splitter.addWidget(left)
# Right: variable table
right = QWidget()
right.setObjectName("panelBody")
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
rl.setSpacing(0)
var_header = QWidget()
var_header.setObjectName("panelHeader")
var_header.setFixedHeight(36)
vh = QHBoxLayout(var_header)
vh.setContentsMargins(16, 0, 12, 0)
var_label = QLabel("Variables")
var_label.setObjectName("fieldLabel")
vh.addWidget(var_label)
vh.addStretch()
add_var_btn = QPushButton("+ Add Variable")
add_var_btn.setObjectName("ghost")
add_var_btn.clicked.connect(self._add_var_row)
vh.addWidget(add_var_btn)
rl.addWidget(var_header)
self.var_table = QTableWidget(0, 2)
self.var_table.setHorizontalHeaderLabels(["Variable", "Value"])
self.var_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.var_table.verticalHeader().setVisible(False)
self.var_table.setAlternatingRowColors(True)
self.var_table.itemChanged.connect(self._on_var_changed)
rl.addWidget(self.var_table, 1)
splitter.addWidget(right)
splitter.setSizes([220, 520])
layout.addWidget(splitter, 1)
# ── Bottom bar ────────────────────────────────────────────────────────
bottom = QWidget()
bottom.setObjectName("panelFooter")
bottom.setFixedHeight(52)
bl = QHBoxLayout(bottom)
bl.setContentsMargins(16, 0, 16, 0)
bl.addStretch()
save_btn = QPushButton("Save & Close")
save_btn.setObjectName("accent")
save_btn.setFixedWidth(120)
save_btn.clicked.connect(self._save_and_close)
bl.addWidget(save_btn)
layout.addWidget(bottom)
self._load_envs()
# ── Data loading ──────────────────────────────────────────────────────────
def _load_envs(self):
self.env_list.clear()
for env in storage.get_environments():
label = f"{'' if env.is_active else ' '} {env.name}"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, env)
if env.is_active:
item.setForeground(QBrush(QColor(Colors.ACCENT)))
self.env_list.addItem(item)
def _on_env_selected(self, current, _previous):
if self._current_env and self._dirty:
self._current_env.variables = self._get_vars()
if not current:
return
self._current_env = current.data(Qt.ItemDataRole.UserRole)
self._dirty = False
self._load_vars(self._current_env.variables)
def _load_vars(self, variables: dict):
self.var_table.blockSignals(True)
self.var_table.setRowCount(0)
for k, v in variables.items():
row = self.var_table.rowCount()
self.var_table.insertRow(row)
self.var_table.setItem(row, 0, QTableWidgetItem(k))
self.var_table.setItem(row, 1, QTableWidgetItem(str(v)))
self.var_table.blockSignals(False)
def _on_var_changed(self):
if self._current_env:
self._dirty = True
def _get_vars(self) -> dict:
result = {}
for row in range(self.var_table.rowCount()):
k = self.var_table.item(row, 0)
v = self.var_table.item(row, 1)
if k and k.text().strip():
result[k.text().strip()] = v.text() if v else ""
return result
# ── Actions ───────────────────────────────────────────────────────────────
def _add_var_row(self):
if not self._current_env:
QMessageBox.information(
self, "No Environment Selected",
"Select or create an environment first."
)
return
row = self.var_table.rowCount()
self.var_table.insertRow(row)
self.var_table.setItem(row, 0, QTableWidgetItem(""))
self.var_table.setItem(row, 1, QTableWidgetItem(""))
self.var_table.editItem(self.var_table.item(row, 0))
def _add_env(self):
name, ok = QInputDialog.getText(self, "New Environment", "Name:")
if not ok or not name.strip():
return
env = Environment(name=name.strip())
env_id = storage.save_environment(env)
env.id = env_id
self._load_envs()
for i in range(self.env_list.count()):
item = self.env_list.item(i)
if item.data(Qt.ItemDataRole.UserRole).id == env_id:
self.env_list.setCurrentItem(item)
break
def _delete_env(self):
item = self.env_list.currentItem()
if not item:
return
env = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(
self, "Delete Environment",
f"Delete '{env.name}'? This cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Yes:
storage.delete_environment(env.id)
self._current_env = None
self._dirty = False
self.var_table.setRowCount(0)
self._load_envs()
def _set_active(self):
item = self.env_list.currentItem()
if not item:
return
env = item.data(Qt.ItemDataRole.UserRole)
storage.set_active_environment(env.id)
self._load_envs()
def _save_and_close(self):
if self._current_env:
self._current_env.variables = self._get_vars()
storage.save_environment(self._current_env)
self.accept()

32
app/ui/highlighter.py Normal file
View File

@@ -0,0 +1,32 @@
"""Simple JSON/general syntax highlighter for QTextEdit."""
import re
from PyQt6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont
class JsonHighlighter(QSyntaxHighlighter):
def __init__(self, parent=None):
super().__init__(parent)
self._rules = []
def fmt(color, bold=False):
f = QTextCharFormat()
f.setForeground(QColor(color))
if bold:
f.setFontWeight(QFont.Weight.Bold)
return f
# Keys
self._rules.append((re.compile(r'"([^"\\]|\\.)*"\s*(?=:)'), fmt("#9CDCFE")))
# String values
self._rules.append((re.compile(r'(?<!:)\s*"([^"\\]|\\.)*"'), fmt("#CE9178")))
# Numbers
self._rules.append((re.compile(r'\b-?\d+(\.\d+)?([eE][+-]?\d+)?\b'), fmt("#B5CEA8")))
# Booleans & null
self._rules.append((re.compile(r'\b(true|false|null)\b'), fmt("#569CD6", bold=True)))
# Braces/brackets
self._rules.append((re.compile(r'[{}\[\]]'), fmt("#FFD700")))
def highlightBlock(self, text):
for pattern, fmt in self._rules:
for m in pattern.finditer(text):
self.setFormat(m.start(), m.end() - m.start(), fmt)

138
app/ui/import_dialog.py Normal file
View File

@@ -0,0 +1,138 @@
"""APIClient - Agent — Import Dialog."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QTextEdit, QPushButton, QLabel, QFileDialog, QMessageBox
)
from PyQt6.QtGui import QFont
from app.ui.theme import Colors
from app.core import importer, storage
from app.models import HttpRequest
class ImportDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Import")
self.setMinimumSize(640, 460)
self.imported_req: HttpRequest | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(48)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Import")
title.setObjectName("panelTitle")
hl.addWidget(title)
layout.addWidget(header)
self.tabs = QTabWidget()
self.tabs.addTab(self._make_postman_tab(), "Postman Collection")
self.tabs.addTab(self._make_curl_tab(), "cURL Command")
layout.addWidget(self.tabs, 1)
# ── Tab builders ──────────────────────────────────────────────────────────
def _make_postman_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(8)
hint = QLabel("Paste a Postman Collection v2.1 JSON or load from file:")
hint.setObjectName("hintText")
layout.addWidget(hint)
self.postman_editor = QTextEdit()
self.postman_editor.setFont(
QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)
)
self.postman_editor.setPlaceholderText(
'{\n "info": {"name": "My Collection"},\n "item": [...]\n}'
)
layout.addWidget(self.postman_editor)
btn_row = QHBoxLayout()
load_btn = QPushButton("Load File…")
load_btn.clicked.connect(self._load_postman_file)
import_btn = QPushButton("Import Collection")
import_btn.setObjectName("accent")
import_btn.clicked.connect(self._import_postman)
btn_row.addWidget(load_btn)
btn_row.addStretch()
btn_row.addWidget(import_btn)
layout.addLayout(btn_row)
return w
def _make_curl_tab(self):
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(8)
hint = QLabel("Paste a cURL command — it will open as a new request tab:")
hint.setObjectName("hintText")
layout.addWidget(hint)
self.curl_editor = QTextEdit()
self.curl_editor.setFont(
QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)
)
self.curl_editor.setPlaceholderText(
"curl -X POST https://api.example.com/data \\\n"
" -H 'Content-Type: application/json' \\\n"
" -d '{\"key\": \"value\"}'"
)
layout.addWidget(self.curl_editor)
btn_row = QHBoxLayout()
import_btn = QPushButton("Import as Request")
import_btn.setObjectName("accent")
import_btn.clicked.connect(self._import_curl)
btn_row.addStretch()
btn_row.addWidget(import_btn)
layout.addLayout(btn_row)
return w
# ── Import actions ────────────────────────────────────────────────────────
def _load_postman_file(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open Collection", "", "JSON Files (*.json);;All Files (*)"
)
if path:
with open(path, "r", encoding="utf-8") as f:
self.postman_editor.setPlainText(f.read())
def _import_postman(self):
text = self.postman_editor.toPlainText().strip()
if not text:
return
try:
col_name, requests = importer.from_postman_collection(text)
col_id = storage.add_collection(col_name)
for req in requests:
storage.save_request(col_id, req)
QMessageBox.information(
self, "Import Successful",
f"Imported collection '{col_name}' with {len(requests)} request(s)."
)
self.accept()
except Exception as e:
QMessageBox.critical(self, "Import Error", str(e))
def _import_curl(self):
text = self.curl_editor.toPlainText().strip()
if not text:
return
try:
self.imported_req = importer.from_curl(text)
self.accept()
except Exception as e:
QMessageBox.critical(self, "Import Error", str(e))

466
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,466 @@
"""APIClient - Agent — Main Window."""
from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QComboBox, QPushButton, QStatusBar, QTabWidget,
QInputDialog, QMessageBox, QFileDialog, QApplication
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QKeySequence, QShortcut
from app.ui.tabs_manager import TabsManager
from app.ui.response_panel import ResponsePanel
from app.ui.sidebar import CollectionsSidebar
from app.ui.theme import Colors, toggle as toggle_theme, is_dark
from app.core import http_client, storage
from app.core.test_runner import run_tests
from app.models import HttpRequest
APP_VERSION = "2.0.0"
APP_NAME = "APIClient - Agent"
class RequestWorker(QThread):
finished = pyqtSignal(object, list)
def __init__(self, req: HttpRequest, variables: dict):
super().__init__()
self.req = req
self.variables = variables
self._cancelled = False
def run(self):
if self.req.pre_request_script.strip():
try:
exec(self.req.pre_request_script, {"__builtins__": {}}) # noqa: S102
except Exception:
pass
if self._cancelled:
return
resp = http_client.send_request(self.req, self.variables)
tests = run_tests(self.req.test_script, resp)
self.finished.emit(resp, tests)
def cancel(self):
self._cancelled = True
class EnvBar(QWidget):
"""Top branding + environment selector bar."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("envBar")
self.setFixedHeight(46)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 0, 16, 0)
layout.setSpacing(6)
brand = QLabel("APIClient")
brand.setObjectName("brandName")
sub = QLabel("Agent")
sub.setObjectName("brandSub")
layout.addWidget(brand)
layout.addWidget(sub)
layout.addStretch()
env_label = QLabel("ENV")
env_label.setObjectName("envChip")
layout.addWidget(env_label)
self.env_combo = QComboBox()
self.env_combo.setObjectName("methodCombo")
self.env_combo.setMinimumWidth(180)
layout.addWidget(self.env_combo)
self.manage_btn = QPushButton("Manage")
self.manage_btn.setObjectName("ghost")
self.manage_btn.setToolTip("Manage Environments (Ctrl+E)")
layout.addWidget(self.manage_btn)
self.ai_btn = QPushButton("✦ AI")
self.ai_btn.setObjectName("accent")
self.ai_btn.setFixedWidth(60)
self.ai_btn.setToolTip("Toggle AI Chat (Ctrl+Shift+A)")
layout.addWidget(self.ai_btn)
self.theme_btn = QPushButton("")
self.theme_btn.setObjectName("ghost")
self.theme_btn.setFixedWidth(32)
self.theme_btn.setToolTip("Toggle Light / Dark Theme")
layout.addWidget(self.theme_btn)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.setMinimumSize(1280, 800)
self._worker: RequestWorker | None = None
storage.init_db()
self._build_ui()
self._build_menu()
self._build_shortcuts()
self._update_env_selector()
# ── UI Construction ───────────────────────────────────────────────────────
def _build_ui(self):
root = QWidget()
root_layout = QVBoxLayout(root)
root_layout.setContentsMargins(0, 0, 0, 0)
root_layout.setSpacing(0)
self.env_bar = EnvBar()
self.env_bar.env_combo.currentIndexChanged.connect(self._on_env_changed)
self.env_bar.manage_btn.clicked.connect(self._open_env_dialog)
self.env_bar.theme_btn.clicked.connect(self._toggle_theme)
self.env_bar.ai_btn.clicked.connect(self._toggle_ai_chat)
root_layout.addWidget(self.env_bar)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setHandleWidth(1)
self.sidebar = CollectionsSidebar()
self.sidebar.request_selected.connect(self._load_request_in_tab)
splitter.addWidget(self.sidebar)
self.workspace = QTabWidget()
self.workspace.setObjectName("workspaceTabs")
self.workspace.addTab(self._build_http_workspace(), " HTTP ")
from app.ui.websocket_panel import WebSocketPanel
self.ws_panel = WebSocketPanel()
self.workspace.addTab(self.ws_panel, " WebSocket ")
from app.ui.mock_server_panel import MockServerPanel
self.mock_panel = MockServerPanel()
self.workspace.addTab(self.mock_panel, " Mock Server ")
splitter.addWidget(self.workspace)
from app.ui.ai_chat_panel import AIChatPanel
self.chat_panel = AIChatPanel()
splitter.addWidget(self.chat_panel)
splitter.setSizes([260, 940, 360]) # give chat panel real size first
self.chat_panel.hide() # THEN hide — splitter remembers 360
self._main_splitter = splitter
# Wire apply signals
self.chat_panel.apply_body.connect(lambda c: self._ai_apply("body", c))
self.chat_panel.apply_params.connect(lambda c: self._ai_apply("params", c))
self.chat_panel.apply_headers.connect(lambda c: self._ai_apply("headers", c))
self.chat_panel.apply_test.connect(lambda c: self._ai_apply("test", c))
root_layout.addWidget(splitter)
self.setCentralWidget(root)
self._status_bar = QStatusBar()
self._status_bar.setFixedHeight(26)
self.setStatusBar(self._status_bar)
self._status_bar.showMessage(f"Ready — {APP_NAME} v{APP_VERSION}")
def _build_http_workspace(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.tabs_manager = TabsManager()
self.tabs_manager.send_requested.connect(self._send)
self.response_panel = ResponsePanel()
splitter = QSplitter(Qt.Orientation.Vertical)
splitter.setHandleWidth(1)
splitter.addWidget(self.tabs_manager)
splitter.addWidget(self.response_panel)
splitter.setSizes([400, 400])
layout.addWidget(splitter)
return w
# ── Menu ──────────────────────────────────────────────────────────────────
def _build_menu(self):
mb = self.menuBar()
file_m = mb.addMenu("File")
file_m.addAction("New Tab", self.tabs_manager.new_tab).setShortcut("Ctrl+T")
file_m.addAction("Close Tab", self.tabs_manager.close_current_tab).setShortcut("Ctrl+W")
file_m.addSeparator()
file_m.addAction("Save to Collection", self._save_to_collection).setShortcut("Ctrl+S")
file_m.addSeparator()
file_m.addAction("Import…", self._import)
file_m.addAction("Export Collection…", self._export)
file_m.addSeparator()
file_m.addAction("Quit", self.close).setShortcut("Ctrl+Q")
view_m = mb.addMenu("View")
view_m.addAction("Search Requests", self._open_search).setShortcut("Ctrl+F")
view_m.addAction("Toggle Theme", self._toggle_theme)
tools_m = mb.addMenu("Tools")
tools_m.addAction("Environments…", self._open_env_dialog).setShortcut("Ctrl+E")
tools_m.addAction("Collection Runner…", self._open_runner)
tools_m.addAction("Generate Code…", self._open_code_gen)
tools_m.addSeparator()
tools_m.addAction("AI Assistant…", self._open_ai_assistant)
mb.addMenu("Help").addAction(
f"About {APP_NAME}",
lambda: QMessageBox.about(
self, APP_NAME,
f"<b>{APP_NAME} v{APP_VERSION}</b><br>"
"Enterprise-grade API testing tool with AI co-pilot.<br><br>"
"Built with Python + PyQt6"
)
)
def _build_shortcuts(self):
QShortcut(QKeySequence("Ctrl+Return"), self, self._send_current)
QShortcut(QKeySequence("Ctrl+T"), self, self.tabs_manager.new_tab)
QShortcut(QKeySequence("Ctrl+W"), self, self.tabs_manager.close_current_tab)
QShortcut(QKeySequence("Ctrl+S"), self, self._save_to_collection)
QShortcut(QKeySequence("Ctrl+F"), self, self._open_search)
QShortcut(QKeySequence("Ctrl+E"), self, self._open_env_dialog)
QShortcut(QKeySequence("Ctrl+Shift+A"), self, self._toggle_ai_chat)
QShortcut(QKeySequence("Escape"), self, self._cancel_request)
QShortcut(QKeySequence("Ctrl+Q"), self, self.close)
# ── Environment ───────────────────────────────────────────────────────────
def _update_env_selector(self):
combo = self.env_bar.env_combo
combo.blockSignals(True)
combo.clear()
combo.addItem("No Environment", None)
active = storage.get_active_environment()
for e in storage.get_environments():
combo.addItem(e.name, e.id)
if active and e.id == active.id:
combo.setCurrentIndex(combo.count() - 1)
combo.blockSignals(False)
def _on_env_changed(self, _):
env_id = self.env_bar.env_combo.currentData()
storage.set_active_environment(env_id)
self._set_status(f"Environment: {self.env_bar.env_combo.currentText()}")
def _get_active_variables(self) -> dict:
env = storage.get_active_environment()
return env.variables if env else {}
# ── Sending ───────────────────────────────────────────────────────────────
def _send_current(self):
tab = self.tabs_manager.current_tab()
if tab:
self._send(tab.get_request())
def _send(self, req: HttpRequest):
if not req.url.strip():
self._set_status("Enter a URL first", error=True)
return
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.wait(500)
tab = self.tabs_manager.current_tab()
if tab:
tab.request_panel.send_btn.setEnabled(False)
tab.request_panel.send_btn.setText("Sending…")
self.response_panel.set_loading(True)
storage.add_to_history(req)
self.sidebar.refresh()
self._worker = RequestWorker(req, self._get_active_variables())
self._worker.finished.connect(self._on_response)
self._worker.start()
self._set_status(f"{req.method} {req.url}")
if self.chat_panel.isVisible():
self.chat_panel.set_context(req=req, env_vars=self._get_active_variables())
def _cancel_request(self):
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.wait(500)
self.response_panel.set_loading(False)
self._restore_send_btn()
self._set_status("Request cancelled")
def _restore_send_btn(self):
tab = self.tabs_manager.current_tab()
if tab:
tab.request_panel.send_btn.setEnabled(True)
tab.request_panel.send_btn.setText("Send")
def _on_response(self, resp, tests):
self.response_panel.set_loading(False)
self.response_panel.display(resp, tests)
self._restore_send_btn()
tab = self.tabs_manager.current_tab()
req = tab.get_request() if tab else None
if self.chat_panel.isVisible():
self.chat_panel.set_context(req=req, resp=resp, env_vars=self._get_active_variables())
if resp.error:
self._set_status(f"{resp.error}", error=True)
else:
size = resp.size_bytes or len(resp.body.encode())
self._set_status(
f"{resp.status} {resp.reason} {resp.elapsed_ms:.0f} ms {_fmt_size(size)}"
)
# ── Request loading ───────────────────────────────────────────────────────
def _load_request_in_tab(self, req: HttpRequest):
self.tabs_manager.load_request_in_new_tab(req)
if self.chat_panel.isVisible():
self.chat_panel.set_context(req=req, env_vars=self._get_active_variables())
# ── Save ──────────────────────────────────────────────────────────────────
def _save_to_collection(self):
cols = storage.get_collections()
if not cols:
QMessageBox.information(self, "No Collections",
"Create a collection first using the + button in the sidebar.")
return
col_name, ok = QInputDialog.getItem(
self, "Save Request", "Collection:", [c["name"] for c in cols], 0, False)
if not ok:
return
col_id = next(c["id"] for c in cols if c["name"] == col_name)
tab = self.tabs_manager.current_tab()
if not tab:
return
req = tab.get_request()
req_name, ok2 = QInputDialog.getText(
self, "Request Name", "Name:", text=req.name or req.url or "New Request")
if not ok2 or not req_name.strip():
return
req.name = req_name.strip()
storage.save_request(col_id, req)
self.tabs_manager.rename_current_tab(req.name)
self.sidebar.refresh()
self._set_status(f"✓ Saved '{req.name}''{col_name}'")
# ── Dialogs ───────────────────────────────────────────────────────────────
def _open_env_dialog(self):
from app.ui.environment_dialog import EnvironmentDialog
EnvironmentDialog(self).exec()
self._update_env_selector()
def _open_runner(self):
from app.ui.collection_runner import CollectionRunnerDialog
CollectionRunnerDialog(self).exec()
def _open_code_gen(self):
tab = self.tabs_manager.current_tab()
if not tab:
return
from app.ui.code_gen_dialog import CodeGenDialog
CodeGenDialog(tab.get_request(), self).exec()
def _import(self):
from app.ui.import_dialog import ImportDialog
dlg = ImportDialog(self)
dlg.exec()
if dlg.imported_req:
self.tabs_manager.load_request_in_new_tab(dlg.imported_req)
self.sidebar.refresh()
def _export(self):
cols = storage.get_collections()
if not cols:
QMessageBox.information(self, "Nothing to Export", "No collections found.")
return
name, ok = QInputDialog.getItem(
self, "Export Collection", "Collection:", [c["name"] for c in cols], 0, False)
if not ok:
return
col_id = next(c["id"] for c in cols if c["name"] == name)
path, _ = QFileDialog.getSaveFileName(
self, "Save", f"{name}.json", "JSON (*.json)")
if not path:
return
from app.core.exporter import export_collection
with open(path, "w", encoding="utf-8") as f:
f.write(export_collection(col_id))
self._set_status(f"✓ Exported to {path}")
def _open_search(self):
from app.ui.search_dialog import SearchDialog
dlg = SearchDialog(self)
dlg.request_selected.connect(self._load_request_in_tab)
dlg.exec()
def _open_ai_assistant(self):
from app.ui.ai_panel import AIAssistantDialog
dlg = AIAssistantDialog(self)
dlg.collection_imported.connect(self.sidebar.refresh)
dlg.exec()
self._update_env_selector()
self.sidebar.refresh()
def _toggle_ai_chat(self):
if self.chat_panel.isVisible():
self.chat_panel.hide()
else:
# Ensure the splitter gives the panel a visible width
sizes = self._main_splitter.sizes()
if sizes[2] < 50:
total = self._main_splitter.width() - 2 # 2 for handles
chat_w = 360
side_w = sizes[0]
work_w = max(total - side_w - chat_w, 400)
self._main_splitter.setSizes([side_w, work_w, chat_w])
self.chat_panel.show()
tab = self.tabs_manager.current_tab()
if tab:
self.chat_panel.set_context(
req=tab.get_request(),
env_vars=self._get_active_variables()
)
def _ai_apply(self, atype: str, content: str):
tab = self.tabs_manager.current_tab()
if not tab:
return
rp = tab.request_panel
if atype == "body":
rp.apply_body(content)
elif atype == "params":
rp.apply_params(content)
elif atype == "headers":
rp.apply_headers(content)
elif atype == "test":
rp.apply_test_script(content)
# ── Theme ─────────────────────────────────────────────────────────────────
def _toggle_theme(self):
toggle_theme(QApplication.instance())
self.env_bar.theme_btn.setText("" if is_dark() else "")
# ── Status Bar ────────────────────────────────────────────────────────────
def _set_status(self, msg: str, error: bool = False):
color = Colors.ERROR if error else Colors.TEXT_SECONDARY
self._status_bar.setStyleSheet(
f"QStatusBar {{ color: {color}; background: {Colors.BG_DARKEST}; "
f"border-top: 1px solid {Colors.BORDER}; font-size: 11px; }}"
)
self._status_bar.showMessage(msg, 8000)
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"

222
app/ui/mock_server_panel.py Normal file
View File

@@ -0,0 +1,222 @@
"""APIClient - Agent — Mock Server Panel."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QTableWidget, QTableWidgetItem, QHeaderView, QDialog,
QFormLayout, QLineEdit, QComboBox, QTextEdit, QSpinBox,
QDialogButtonBox, QMessageBox
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QBrush, QColor
from app.ui.theme import Colors, restyle
from app.core import mock_server, storage
from app.models import MockEndpoint
class EndpointDialog(QDialog):
def __init__(self, ep: MockEndpoint = None, parent=None):
super().__init__(parent)
self.setWindowTitle("Mock Endpoint")
self.setMinimumWidth(480)
self.ep = MockEndpoint() if ep is None else MockEndpoint(
id=ep.id, name=ep.name, method=ep.method, path=ep.path,
status_code=ep.status_code, response_headers=ep.response_headers,
response_body=ep.response_body
)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(44)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Configure Mock Endpoint")
title.setObjectName("panelTitle")
hl.addWidget(title)
layout.addWidget(header)
body = QWidget()
bl = QVBoxLayout(body)
form = QFormLayout()
form.setContentsMargins(16, 12, 16, 12)
form.setSpacing(10)
self.name_input = QLineEdit(self.ep.name or "")
self.name_input.setPlaceholderText("Optional display name")
self.method_combo = QComboBox()
self.method_combo.addItems(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"])
self.method_combo.setCurrentText(self.ep.method or "GET")
self.path_input = QLineEdit(self.ep.path or "/")
self.path_input.setPlaceholderText("/api/v1/resource")
self.status_spin = QSpinBox()
self.status_spin.setRange(100, 599)
self.status_spin.setValue(self.ep.status_code or 200)
self.body_editor = QTextEdit()
self.body_editor.setPlaceholderText('{"message": "ok"}')
self.body_editor.setPlainText(self.ep.response_body or "")
self.body_editor.setMaximumHeight(140)
form.addRow("Name:", self.name_input)
form.addRow("Method:", self.method_combo)
form.addRow("Path:", self.path_input)
form.addRow("Status Code:", self.status_spin)
form.addRow("Response Body:", self.body_editor)
bl.addLayout(form)
layout.addWidget(body, 1)
footer = QWidget()
footer.setObjectName("panelFooter")
fl = QVBoxLayout(footer)
fl.setContentsMargins(12, 8, 12, 8)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(self._save)
btns.rejected.connect(self.reject)
fl.addWidget(btns)
layout.addWidget(footer)
def _save(self):
path = self.path_input.text().strip()
if not path:
QMessageBox.warning(self, "Validation", "Path is required.")
return
if not path.startswith("/"):
path = "/" + path
self.ep.name = self.name_input.text().strip()
self.ep.method = self.method_combo.currentText()
self.ep.path = path
self.ep.status_code = self.status_spin.value()
self.ep.response_body = self.body_editor.toPlainText()
self.accept()
class MockServerPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# ── Server controls ───────────────────────────────────────────────────
top = QHBoxLayout()
port_label = QLabel("Port:")
port_label.setObjectName("fieldLabel")
self.port_input = QLineEdit("8888")
self.port_input.setFixedWidth(70)
self.port_input.setToolTip("Listening port for the mock server")
self.toggle_btn = QPushButton("Start Server")
self.toggle_btn.setObjectName("accent")
self.toggle_btn.setFixedWidth(120)
self.toggle_btn.clicked.connect(self._toggle_server)
self.status_label = QLabel("● Stopped")
self.status_label.setObjectName("statusErr")
top.addWidget(port_label)
top.addWidget(self.port_input)
top.addWidget(self.toggle_btn)
top.addStretch()
top.addWidget(self.status_label)
layout.addLayout(top)
# ── Endpoint table ────────────────────────────────────────────────────
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["Name", "Method", "Path", "Status"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.doubleClicked.connect(self._edit_endpoint)
layout.addWidget(self.table)
# ── Action buttons ────────────────────────────────────────────────────
btn_row = QHBoxLayout()
add_btn = QPushButton("+ Add Endpoint")
add_btn.clicked.connect(self._add_endpoint)
del_btn = QPushButton("Delete")
del_btn.setObjectName("danger")
del_btn.clicked.connect(self._delete_endpoint)
btn_row.addWidget(add_btn)
btn_row.addWidget(del_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
self._load_endpoints()
# ── Data loading ──────────────────────────────────────────────────────────
def _load_endpoints(self):
self.table.setRowCount(0)
for ep in storage.get_mock_endpoints():
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(ep.name or ""))
method_item = QTableWidgetItem(ep.method)
method_item.setForeground(QBrush(QColor(Colors.INFO)))
self.table.setItem(row, 1, method_item)
self.table.setItem(row, 2, QTableWidgetItem(ep.path))
self.table.setItem(row, 3, QTableWidgetItem(str(ep.status_code)))
self.table.item(row, 0).setData(Qt.ItemDataRole.UserRole, ep)
# ── Actions ───────────────────────────────────────────────────────────────
def _add_endpoint(self):
dlg = EndpointDialog(parent=self)
if dlg.exec():
storage.save_mock_endpoint(dlg.ep)
self._load_endpoints()
def _edit_endpoint(self):
row = self.table.currentRow()
if row < 0:
return
item = self.table.item(row, 0)
if not item:
return
ep = item.data(Qt.ItemDataRole.UserRole)
dlg = EndpointDialog(ep=ep, parent=self)
if dlg.exec():
storage.save_mock_endpoint(dlg.ep)
self._load_endpoints()
def _delete_endpoint(self):
row = self.table.currentRow()
if row < 0:
return
item = self.table.item(row, 0)
if not item:
return
ep = item.data(Qt.ItemDataRole.UserRole)
if ep and ep.id:
storage.delete_mock_endpoint(ep.id)
self._load_endpoints()
def _toggle_server(self):
if mock_server.is_running():
mock_server.stop()
self.toggle_btn.setText("Start Server")
restyle(self.status_label, "statusErr")
self.status_label.setText("● Stopped")
else:
try:
port = int(self.port_input.text())
except ValueError:
port = 8888
msg = mock_server.start(port)
if mock_server.is_running():
self.toggle_btn.setText("Stop Server")
restyle(self.status_label, "statusOk")
self.status_label.setText(f"● Running on :{port}")
else:
QMessageBox.warning(self, "Mock Server", msg)

467
app/ui/request_panel.py Normal file
View File

@@ -0,0 +1,467 @@
"""APIClient - Agent — Request Panel."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLineEdit,
QPushButton, QTabWidget, QTableWidget, QTableWidgetItem,
QTextEdit, QHeaderView, QLabel, QFormLayout, QStackedWidget,
QCheckBox, QSpinBox
)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QFont
from app.ui.theme import Colors, method_color
from app.ui.highlighter import JsonHighlighter
from app.models import HttpRequest
HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
class KeyValueTable(QTableWidget):
"""Editable key-value table with enable/disable checkboxes per row."""
def __init__(self, key_hint: str = "Key", val_hint: str = "Value", parent=None):
super().__init__(0, 3, parent)
self.setHorizontalHeaderLabels(["", key_hint, val_hint])
hh = self.horizontalHeader()
hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
hh.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.setColumnWidth(0, 32)
self.verticalHeader().setVisible(False)
self.verticalHeader().setDefaultSectionSize(36)
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._add_empty_row()
self.itemChanged.connect(self._on_item_changed)
def _make_checkbox_item(self, checked: bool = True) -> QTableWidgetItem:
item = QTableWidgetItem()
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
return item
def _add_empty_row(self):
row = self.rowCount()
self.blockSignals(True)
self.insertRow(row)
self.setItem(row, 0, self._make_checkbox_item(True))
self.setItem(row, 1, QTableWidgetItem(""))
self.setItem(row, 2, QTableWidgetItem(""))
self.blockSignals(False)
def _on_item_changed(self, item):
if item.column() in (1, 2) and item.row() == self.rowCount() - 1 and item.text().strip():
self._add_empty_row()
def get_pairs(self) -> dict:
result = {}
for row in range(self.rowCount()):
chk = self.item(row, 0)
if chk and chk.checkState() == Qt.CheckState.Unchecked:
continue
k = self.item(row, 1)
v = self.item(row, 2)
key = k.text().strip() if k else ""
val = v.text().strip() if v else ""
if key:
result[key] = val
return result
def set_pairs(self, pairs: dict):
self.blockSignals(True)
self.setRowCount(0)
for k, v in pairs.items():
row = self.rowCount()
self.insertRow(row)
self.setItem(row, 0, self._make_checkbox_item(True))
self.setItem(row, 1, QTableWidgetItem(str(k)))
self.setItem(row, 2, QTableWidgetItem(str(v)))
self.blockSignals(False)
self._add_empty_row()
class AuthWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(10)
row = QHBoxLayout()
row.addWidget(QLabel("Auth Type:"))
self.type_combo = QComboBox()
self.type_combo.addItems(["None", "Bearer Token", "Basic Auth", "API Key"])
self.type_combo.setMaximumWidth(200)
self.type_combo.currentIndexChanged.connect(self._on_type)
row.addWidget(self.type_combo)
row.addStretch()
layout.addLayout(row)
self.stack = QStackedWidget()
# None page
none_lbl = QLabel("No authentication configured.")
none_lbl.setObjectName("authNone")
self.stack.addWidget(none_lbl)
# Bearer page
bearer_w = QWidget()
fl = QFormLayout(bearer_w)
fl.setContentsMargins(0, 8, 0, 0)
self.bearer_token = QLineEdit()
self.bearer_token.setPlaceholderText("{{token}}")
self.bearer_token.setEchoMode(QLineEdit.EchoMode.Password)
show_bearer = QCheckBox("Show")
show_bearer.toggled.connect(
lambda on: self.bearer_token.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password
)
)
tok_row = QHBoxLayout()
tok_row.addWidget(self.bearer_token)
tok_row.addWidget(show_bearer)
fl.addRow("Token:", tok_row)
self.stack.addWidget(bearer_w)
# Basic page
basic_w = QWidget()
fl2 = QFormLayout(basic_w)
fl2.setContentsMargins(0, 8, 0, 0)
self.basic_user = QLineEdit()
self.basic_user.setPlaceholderText("username")
self.basic_pass = QLineEdit()
self.basic_pass.setPlaceholderText("password")
self.basic_pass.setEchoMode(QLineEdit.EchoMode.Password)
show_pass = QCheckBox("Show")
show_pass.toggled.connect(
lambda on: self.basic_pass.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password
)
)
pass_row = QHBoxLayout()
pass_row.addWidget(self.basic_pass)
pass_row.addWidget(show_pass)
fl2.addRow("Username:", self.basic_user)
fl2.addRow("Password:", pass_row)
self.stack.addWidget(basic_w)
# API Key page
apikey_w = QWidget()
fl3 = QFormLayout(apikey_w)
fl3.setContentsMargins(0, 8, 0, 0)
self.apikey_key = QLineEdit()
self.apikey_key.setPlaceholderText("X-API-Key")
self.apikey_value = QLineEdit()
self.apikey_value.setPlaceholderText("{{api_key}}")
self.apikey_in = QComboBox()
self.apikey_in.addItems(["header", "query"])
fl3.addRow("Key:", self.apikey_key)
fl3.addRow("Value:", self.apikey_value)
fl3.addRow("Add to:", self.apikey_in)
self.stack.addWidget(apikey_w)
layout.addWidget(self.stack)
layout.addStretch()
def _on_type(self, idx: int):
self.stack.setCurrentIndex(idx)
def get_auth(self) -> tuple[str, dict]:
idx = self.type_combo.currentIndex()
if idx == 1:
return "bearer", {"token": self.bearer_token.text()}
if idx == 2:
return "basic", {"username": self.basic_user.text(), "password": self.basic_pass.text()}
if idx == 3:
return "apikey", {
"key": self.apikey_key.text(),
"value": self.apikey_value.text(),
"in": self.apikey_in.currentText(),
}
return "none", {}
def set_auth(self, auth_type: str, auth_data: dict):
idx_map = {"none": 0, "bearer": 1, "basic": 2, "apikey": 3}
self.type_combo.setCurrentIndex(idx_map.get(auth_type, 0))
if auth_type == "bearer":
self.bearer_token.setText(auth_data.get("token", ""))
elif auth_type == "basic":
self.basic_user.setText(auth_data.get("username", ""))
self.basic_pass.setText(auth_data.get("password", ""))
elif auth_type == "apikey":
self.apikey_key.setText(auth_data.get("key", ""))
self.apikey_value.setText(auth_data.get("value", ""))
self.apikey_in.setCurrentText(auth_data.get("in", "header"))
def _mono_editor(placeholder: str = "") -> QTextEdit:
e = QTextEdit()
e.setObjectName("codeEditor")
e.setPlaceholderText(placeholder)
e.setAcceptRichText(False)
e.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11))
return e
class RequestPanel(QWidget):
send_requested = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── URL bar ──────────────────────────────────────────────────────────
url_bar = QWidget()
url_bar.setObjectName("urlBarStrip")
url_bar.setFixedHeight(56)
url_layout = QHBoxLayout(url_bar)
url_layout.setContentsMargins(12, 0, 12, 0)
url_layout.setSpacing(8)
self.method_combo = QComboBox()
self.method_combo.setObjectName("methodCombo")
self.method_combo.addItems(HTTP_METHODS)
self.method_combo.setFixedWidth(105)
self.method_combo.currentTextChanged.connect(self._on_method_changed)
self.url_input = QLineEdit()
self.url_input.setObjectName("urlBar")
self.url_input.setPlaceholderText("Enter URL — e.g. https://api.example.com/v1/users")
self.url_input.returnPressed.connect(self._send)
self.send_btn = QPushButton("Send")
self.send_btn.setObjectName("sendBtn")
self.send_btn.setFixedWidth(90)
self.send_btn.setToolTip("Send request (Ctrl+Enter)")
self.send_btn.clicked.connect(self._send)
url_layout.addWidget(self.method_combo)
url_layout.addWidget(self.url_input, 1)
url_layout.addWidget(self.send_btn)
layout.addWidget(url_bar)
# ── Request tabs ─────────────────────────────────────────────────────
self.tabs = QTabWidget()
self.tabs.setObjectName("innerTabs")
# Params / Headers
self.params_table = KeyValueTable("Parameter", "Value")
self.headers_table = KeyValueTable("Header", "Value")
# Auth
self.auth_widget = AuthWidget()
# Body tab
body_w = QWidget()
bl = QVBoxLayout(body_w)
bl.setContentsMargins(12, 8, 12, 8)
bl.setSpacing(6)
type_row = QHBoxLayout()
type_row.addWidget(QLabel("Format:"))
self.body_type_combo = QComboBox()
self.body_type_combo.addItems(["raw", "form-urlencoded", "form-data"])
self.body_type_combo.setMaximumWidth(160)
self.body_type_combo.currentTextChanged.connect(self._on_body_type_changed)
type_row.addWidget(self.body_type_combo)
type_row.addSpacing(12)
self.ct_label = QLabel("Content-Type:")
self.ct_label.setObjectName("fieldLabel")
self.ct_combo = QComboBox()
self.ct_combo.addItems([
"application/vnd.api+json",
"application/json",
"application/xml",
"text/plain",
"text/html",
"application/x-www-form-urlencoded",
])
self.ct_combo.setMaximumWidth(230)
type_row.addWidget(self.ct_label)
type_row.addWidget(self.ct_combo)
type_row.addStretch()
fmt_btn = QPushButton("{ } Format")
fmt_btn.setObjectName("ghost")
fmt_btn.setFixedHeight(28)
fmt_btn.setToolTip("Pretty-print / format JSON body (Ctrl+Shift+F)")
fmt_btn.clicked.connect(self._format_body)
type_row.addWidget(fmt_btn)
bl.addLayout(type_row)
self.body_editor = _mono_editor('{\n "key": "value"\n}')
self._body_hl = JsonHighlighter(self.body_editor.document())
bl.addWidget(self.body_editor)
# Pre-request scripts
pre_w = QWidget()
pl = QVBoxLayout(pre_w)
pl.setContentsMargins(12, 8, 12, 8)
hint = QLabel("Python executed before the request. Use pm.environment.get('key') to read variables.")
hint.setObjectName("hintText")
hint.setWordWrap(True)
pl.addWidget(hint)
self.pre_script_editor = _mono_editor("# Example:\n# pm.environment.set('token', 'my-value')")
pl.addWidget(self.pre_script_editor)
# Test scripts
test_w = QWidget()
tl = QVBoxLayout(test_w)
tl.setContentsMargins(12, 8, 12, 8)
hint2 = QLabel("Assertions run automatically after each response is received.")
hint2.setObjectName("hintText")
hint2.setWordWrap(True)
tl.addWidget(hint2)
self.test_editor = _mono_editor(
"pm.test('Status is 200', lambda: pm.response.to_have_status(200))\n"
"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())"
)
tl.addWidget(self.test_editor)
# Settings tab (timeout, SSL)
settings_filler = QWidget()
sl_outer = QVBoxLayout(settings_filler)
sl_outer.setContentsMargins(0, 0, 0, 0)
sl = QFormLayout()
sl.setContentsMargins(16, 12, 16, 12)
sl.setSpacing(10)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(1, 300)
self.timeout_spin.setValue(30)
self.timeout_spin.setSuffix(" s")
self.timeout_spin.setToolTip("Request timeout in seconds")
sl.addRow("Timeout:", self.timeout_spin)
self.ssl_check = QCheckBox("Verify SSL certificate")
self.ssl_check.setChecked(True)
self.ssl_check.setToolTip("Uncheck to allow self-signed or invalid certificates")
sl.addRow("SSL:", self.ssl_check)
sl_outer.addLayout(sl)
sl_outer.addStretch()
self.tabs.addTab(self.params_table, "Params")
self.tabs.addTab(self.headers_table, "Headers")
self.tabs.addTab(self.auth_widget, "Auth")
self.tabs.addTab(body_w, "Body")
self.tabs.addTab(pre_w, "Pre-request")
self.tabs.addTab(test_w, "Tests")
self.tabs.addTab(settings_filler, "Settings")
layout.addWidget(self.tabs, 1)
# Apply initial method color after all widgets are built
self._on_method_changed(self.method_combo.currentText())
# ── Slots ────────────────────────────────────────────────────────────────
def _on_method_changed(self, method: str):
# Inline style is intentional here — color is dynamic per method value
color = method_color(method)
self.method_combo.setStyleSheet(f"QComboBox#methodCombo {{ color: {color}; }}")
def _on_body_type_changed(self, body_type: str):
raw = body_type == "raw"
self.ct_label.setVisible(raw)
self.ct_combo.setVisible(raw)
def _format_body(self):
import json
text = self.body_editor.toPlainText().strip()
if not text:
return
try:
parsed = json.loads(text)
self.body_editor.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
pass # not valid JSON — leave as-is
def _send(self):
self.send_requested.emit(self._build_request())
# ── Public API ────────────────────────────────────────────────────────────
def get_request(self) -> HttpRequest:
return self._build_request()
def _build_request(self) -> HttpRequest:
auth_type, auth_data = self.auth_widget.get_auth()
body_type = self.body_type_combo.currentText()
content_type = self.ct_combo.currentText() if body_type == "raw" else ""
return HttpRequest(
method = self.method_combo.currentText(),
url = self.url_input.text().strip(),
headers = self.headers_table.get_pairs(),
params = self.params_table.get_pairs(),
body = self.body_editor.toPlainText(),
body_type = body_type,
content_type = content_type,
auth_type = auth_type,
auth_data = auth_data,
pre_request_script = self.pre_script_editor.toPlainText(),
test_script = self.test_editor.toPlainText(),
timeout = self.timeout_spin.value(),
ssl_verify = self.ssl_check.isChecked(),
)
def load_request(self, req: HttpRequest):
idx = self.method_combo.findText(req.method)
if idx >= 0:
self.method_combo.setCurrentIndex(idx)
self.url_input.setText(req.url)
self.headers_table.set_pairs(req.headers or {})
self.params_table.set_pairs(req.params or {})
self.body_editor.setPlainText(req.body or "")
self.body_type_combo.setCurrentText(req.body_type or "raw")
if req.content_type:
idx_ct = self.ct_combo.findText(req.content_type)
if idx_ct >= 0:
self.ct_combo.setCurrentIndex(idx_ct)
self.auth_widget.set_auth(req.auth_type or "none", req.auth_data or {})
self.pre_script_editor.setPlainText(req.pre_request_script or "")
self.test_editor.setPlainText(req.test_script or "")
self.timeout_spin.setValue(req.timeout or 30)
self.ssl_check.setChecked(req.ssl_verify if req.ssl_verify is not None else True)
def apply_body(self, content: str):
"""Set body from AI suggestion and switch to Body tab."""
self.body_editor.setPlainText(content)
self.tabs.setCurrentIndex(3) # Body tab
def apply_params(self, content: str):
"""Parse key=value lines and merge into params table."""
pairs = {}
for line in content.splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, _, v = line.partition("=")
if k.strip():
pairs[k.strip()] = v.strip()
if pairs:
existing = self.params_table.get_pairs()
existing.update(pairs)
self.params_table.set_pairs(existing)
self.tabs.setCurrentIndex(0) # Params tab
def apply_headers(self, content: str):
"""Parse Header: value lines and merge into headers table."""
pairs = {}
for line in content.splitlines():
line = line.strip()
if ":" in line and not line.startswith("#"):
k, _, v = line.partition(":")
if k.strip():
pairs[k.strip()] = v.strip()
if pairs:
existing = self.headers_table.get_pairs()
existing.update(pairs)
self.headers_table.set_pairs(existing)
self.tabs.setCurrentIndex(1) # Headers tab
def apply_test_script(self, content: str):
"""Set test script from AI suggestion and switch to Tests tab."""
self.test_editor.setPlainText(content)
self.tabs.setCurrentIndex(5) # Tests tab

290
app/ui/response_panel.py Normal file
View File

@@ -0,0 +1,290 @@
"""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)

133
app/ui/search_dialog.py Normal file
View File

@@ -0,0 +1,133 @@
"""APIClient - Agent — Request Search Dialog."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QLabel, QWidget
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QBrush, QColor
from app.ui.theme import Colors, method_color
from app.core import storage
from app.models import HttpRequest
class SearchDialog(QDialog):
request_selected = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Search Requests")
self.setMinimumSize(580, 460)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header ────────────────────────────────────────────────────────────
header = QWidget()
header.setObjectName("panelHeader")
header.setFixedHeight(48)
hl = QHBoxLayout(header)
hl.setContentsMargins(16, 0, 16, 0)
title = QLabel("Search Requests")
title.setObjectName("panelTitle")
hl.addWidget(title)
layout.addWidget(header)
# ── Search bar ────────────────────────────────────────────────────────
search_bar = QWidget()
search_bar.setObjectName("urlBarStrip")
sl = QHBoxLayout(search_bar)
sl.setContentsMargins(16, 10, 16, 10)
self.search_input = QLineEdit()
self.search_input.setObjectName("urlBar")
self.search_input.setPlaceholderText("Search by name or URL…")
self.search_input.textChanged.connect(self._search)
sl.addWidget(self.search_input)
layout.addWidget(search_bar)
# ── Results ───────────────────────────────────────────────────────────
body = QWidget()
body.setObjectName("panelBody")
bl = QVBoxLayout(body)
bl.setContentsMargins(16, 8, 16, 8)
bl.setSpacing(6)
self.count_label = QLabel("")
self.count_label.setObjectName("hintText")
bl.addWidget(self.count_label)
self.results_list = QListWidget()
self.results_list.itemDoubleClicked.connect(self._on_selected)
bl.addWidget(self.results_list)
layout.addWidget(body, 1)
# ── Footer ────────────────────────────────────────────────────────────
footer = QWidget()
footer.setObjectName("panelFooter")
footer.setFixedHeight(52)
fl = QHBoxLayout(footer)
fl.setContentsMargins(16, 0, 16, 0)
open_btn = QPushButton("Open in Tab")
open_btn.setObjectName("accent")
open_btn.setFixedWidth(120)
open_btn.clicked.connect(self._on_selected_btn)
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.reject)
fl.addWidget(open_btn)
fl.addStretch()
fl.addWidget(close_btn)
layout.addWidget(footer)
self.search_input.setFocus()
self._search("")
# ── Logic ─────────────────────────────────────────────────────────────────
def _search(self, query: str):
self.results_list.clear()
results = storage.search_requests(query) if query.strip() else []
count = len(results)
self.count_label.setText(
f"{count} result{'s' if count != 1 else ''}"
if query.strip() else "Type to search…"
)
for r in results:
method = r.get("method", "GET")
name = r.get("name") or r.get("url", "")
col_name = r.get("collection_name", "")
label = f"[{col_name}] {method} {name}" if col_name else f"{method} {name}"
item = QListWidgetItem(label)
item.setForeground(QBrush(QColor(method_color(method))))
item.setData(Qt.ItemDataRole.UserRole, r)
self.results_list.addItem(item)
def _build_request(self, r: dict) -> HttpRequest:
return HttpRequest(
method = r.get("method", "GET"),
url = r.get("url", ""),
headers = r.get("headers") or {},
params = r.get("params") or {},
body = r.get("body") or "",
body_type = r.get("body_type") or "raw",
content_type = r.get("content_type") or "",
auth_type = r.get("auth_type") or "none",
auth_data = r.get("auth_data") or {},
name = r.get("name") or "",
id = r.get("id"),
timeout = r.get("timeout") or 30,
ssl_verify = bool(r.get("ssl_verify", 1)),
)
def _on_selected(self, item: QListWidgetItem = None):
if item is None:
item = self.results_list.currentItem()
if not item:
return
r = item.data(Qt.ItemDataRole.UserRole)
self.request_selected.emit(self._build_request(r))
self.accept()
def _on_selected_btn(self):
self._on_selected(self.results_list.currentItem())

268
app/ui/sidebar.py Normal file
View File

@@ -0,0 +1,268 @@
"""APIClient - Agent — Collections Sidebar."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem,
QPushButton, QInputDialog, QMenu, QLineEdit, QLabel, QMessageBox
)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QColor, QFont, QBrush
from app.ui.theme import Colors, method_color
from app.core import storage
from app.models import HttpRequest
class CollectionsSidebar(QWidget):
request_selected = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("sidebar")
self.setMinimumWidth(240)
self.setMaximumWidth(380)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header ────────────────────────────────────────────────────────────
header = QWidget()
header.setObjectName("sidebarHeader")
header.setFixedHeight(44)
h_layout = QHBoxLayout(header)
h_layout.setContentsMargins(12, 0, 8, 0)
title = QLabel("COLLECTIONS")
title.setObjectName("sectionLabel")
self.add_col_btn = QPushButton("+")
self.add_col_btn.setObjectName("ghost")
self.add_col_btn.setFixedSize(28, 28)
self.add_col_btn.setToolTip("New Collection (Ctrl+Shift+N)")
self.add_col_btn.clicked.connect(self._add_collection)
h_layout.addWidget(title)
h_layout.addStretch()
h_layout.addWidget(self.add_col_btn)
layout.addWidget(header)
# ── Filter ────────────────────────────────────────────────────────────
search_wrap = QWidget()
search_wrap.setObjectName("sidebarSearch")
sw = QHBoxLayout(search_wrap)
sw.setContentsMargins(10, 6, 10, 6)
self.search_input = QLineEdit()
self.search_input.setObjectName("filterInput")
self.search_input.setPlaceholderText("Filter collections…")
self.search_input.textChanged.connect(self._filter)
sw.addWidget(self.search_input)
layout.addWidget(search_wrap)
# ── Tree ─────────────────────────────────────────────────────────────
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setIndentation(16)
self.tree.setAnimated(True)
self.tree.itemDoubleClicked.connect(self._on_double_click)
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._context_menu)
layout.addWidget(self.tree)
# History root (always last in tree)
self._history_root = QTreeWidgetItem([" History"])
self._history_root.setForeground(0, QBrush(QColor(Colors.TEXT_MUTED)))
self._history_root.setFont(0, self._section_font())
self.tree.addTopLevelItem(self._history_root)
self._load_collections()
self._load_history()
# ── Helpers ───────────────────────────────────────────────────────────────
def _section_font(self) -> QFont:
f = QFont()
f.setPointSize(9)
f.setWeight(QFont.Weight.Bold)
return f
def _make_req_item(self, req: dict) -> QTreeWidgetItem:
method = req.get("method", "GET")
name = req.get("name") or req.get("url", "Untitled")
item = QTreeWidgetItem()
item.setText(0, f" {method} {name}")
item.setForeground(0, QBrush(QColor(method_color(method))))
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "request", "req": req})
return item
def _make_collection_item(self, col: dict) -> QTreeWidgetItem:
item = QTreeWidgetItem([f" {col['name']}"])
item.setForeground(0, QBrush(QColor(Colors.TEXT_PRIMARY)))
item.setFont(0, self._section_font())
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "collection", "id": col["id"]})
return item
def _make_folder_item(self, folder: dict) -> QTreeWidgetItem:
item = QTreeWidgetItem([f"{folder['name']}"])
item.setForeground(0, QBrush(QColor(Colors.TEXT_SECONDARY)))
item.setData(
0, Qt.ItemDataRole.UserRole,
{"type": "folder", "id": folder["id"], "col_id": folder["collection_id"]}
)
return item
def _dict_to_request(self, r: dict) -> HttpRequest:
return HttpRequest(
method = r.get("method") or "GET",
url = r.get("url") or "",
headers = r.get("headers") or {},
params = r.get("params") or {},
body = r.get("body") or "",
body_type = r.get("body_type") or "raw",
content_type = r.get("content_type") or "",
auth_type = r.get("auth_type") or "none",
auth_data = r.get("auth_data") or {},
pre_request_script = r.get("pre_request_script") or "",
test_script = r.get("test_script") or "",
name = r.get("name") or "",
id = r.get("id"),
timeout = r.get("timeout") or 30,
ssl_verify = bool(r.get("ssl_verify", 1)),
)
# ── Data loading ──────────────────────────────────────────────────────────
def _load_collections(self, filter_text: str = ""):
for i in range(self.tree.topLevelItemCount() - 1, -1, -1):
if self.tree.topLevelItem(i) is not self._history_root:
self.tree.takeTopLevelItem(i)
for col in storage.get_collections():
if filter_text and filter_text.lower() not in col["name"].lower():
continue
col_item = self._make_collection_item(col)
for folder in storage.get_folders(col["id"]):
folder_item = self._make_folder_item(folder)
for req in storage.get_requests(col["id"], folder["id"]):
folder_item.addChild(self._make_req_item(req))
col_item.addChild(folder_item)
for req in storage.get_requests(col["id"]):
col_item.addChild(self._make_req_item(req))
self.tree.insertTopLevelItem(0, col_item)
col_item.setExpanded(True)
def _load_history(self):
self._history_root.takeChildren()
for h in storage.get_history(30):
item = QTreeWidgetItem()
method = h.get("method", "GET")
url = h.get("url", "")
item.setText(0, f" {method} {url}")
item.setForeground(0, QBrush(QColor(method_color(method))))
item.setData(0, Qt.ItemDataRole.UserRole, {"type": "history", "req": h})
self._history_root.addChild(item)
def _filter(self, text: str):
self._load_collections(filter_text=text)
def refresh(self):
self._load_collections(self.search_input.text())
self._load_history()
# ── Interaction ───────────────────────────────────────────────────────────
def _on_double_click(self, item, _column):
data = item.data(0, Qt.ItemDataRole.UserRole)
if not data or data["type"] not in ("request", "history"):
return
self.request_selected.emit(self._dict_to_request(data["req"]))
def _context_menu(self, pos):
item = self.tree.itemAt(pos)
if not item:
return
data = item.data(0, Qt.ItemDataRole.UserRole)
if not data:
return
menu = QMenu(self)
t = data["type"]
if t == "collection":
menu.addAction("Add Folder", lambda: self._add_folder(data["id"]))
menu.addAction("Rename", lambda: self._rename_collection(item, data["id"]))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_collection(item, data["id"]))
elif t == "folder":
menu.addAction("Rename", lambda: self._rename_folder(item, data["id"]))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_folder(data["id"]))
elif t == "request":
menu.addAction("Open in Tab", lambda: self.request_selected.emit(
self._dict_to_request(data["req"])
))
menu.addSeparator()
menu.addAction("Delete", lambda: self._delete_request(data["req"].get("id")))
elif t == "history":
menu.addAction("Open in Tab", lambda: self.request_selected.emit(
self._dict_to_request(data["req"])
))
menu.addSeparator()
menu.addAction("Clear All History", self._clear_history)
menu.exec(self.tree.mapToGlobal(pos))
# ── CRUD actions ──────────────────────────────────────────────────────────
def _add_collection(self):
name, ok = QInputDialog.getText(self, "New Collection", "Collection name:")
if ok and name.strip():
storage.add_collection(name.strip())
self._load_collections()
def _add_folder(self, col_id: int):
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
if ok and name.strip():
storage.add_folder(col_id, name.strip())
self._load_collections()
def _rename_collection(self, item, col_id: int):
current = item.text(0).strip()
name, ok = QInputDialog.getText(self, "Rename Collection", "Name:", text=current)
if ok and name.strip():
storage.rename_collection(col_id, name.strip())
self._load_collections()
def _rename_folder(self, item, folder_id: int):
current = item.text(0).strip().lstrip("").strip()
name, ok = QInputDialog.getText(self, "Rename Folder", "Name:", text=current)
if ok and name.strip():
storage.rename_folder(folder_id, name.strip())
self._load_collections()
def _delete_collection(self, item, col_id: int):
name = item.text(0).strip()
reply = QMessageBox.question(
self, "Delete Collection",
f"Delete '{name}' and all its requests? This cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Yes:
storage.delete_collection(col_id)
self._load_collections()
def _delete_folder(self, folder_id: int):
reply = QMessageBox.question(
self, "Delete Folder",
"Delete this folder and all its requests?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Yes:
storage.delete_folder(folder_id)
self._load_collections()
def _delete_request(self, req_id):
if req_id:
storage.delete_request(req_id)
self._load_collections()
def _clear_history(self):
storage.clear_history()
self._load_history()

97
app/ui/tabs_manager.py Normal file
View File

@@ -0,0 +1,97 @@
"""APIClient - Agent — Multi-tab request manager."""
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QPushButton, QTabBar
from PyQt6.QtCore import pyqtSignal, Qt
from app.ui.request_panel import RequestPanel
from app.models import HttpRequest
class RequestTab(QWidget):
send_requested = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.request_panel = RequestPanel()
self.request_panel.send_requested.connect(self.send_requested)
layout.addWidget(self.request_panel)
def load_request(self, req: HttpRequest):
self.request_panel.load_request(req)
def get_request(self) -> HttpRequest:
return self.request_panel.get_request()
class TabsManager(QWidget):
send_requested = pyqtSignal(object)
current_tab_changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.tab_widget = QTabWidget()
self.tab_widget.setTabsClosable(False) # we manage close buttons ourselves
self.tab_widget.setMovable(True)
self.tab_widget.currentChanged.connect(lambda _: self.current_tab_changed.emit())
# "+" new tab button in the corner
new_btn = QPushButton("+")
new_btn.setObjectName("ghost")
new_btn.setFixedSize(28, 28)
new_btn.setToolTip("New Tab (Ctrl+T)")
new_btn.clicked.connect(lambda: self.new_tab())
self.tab_widget.setCornerWidget(new_btn, Qt.Corner.TopRightCorner)
layout.addWidget(self.tab_widget)
self._tab_counter = 0
self.new_tab()
# ── Public API ────────────────────────────────────────────────────────────
def new_tab(self, req: HttpRequest = None) -> RequestTab:
tab = RequestTab()
tab.send_requested.connect(self.send_requested)
if req:
tab.load_request(req)
self._tab_counter += 1
label = req.name if (req and req.name) else f"Request {self._tab_counter}"
idx = self.tab_widget.addTab(tab, label)
self.tab_widget.setCurrentIndex(idx)
self._add_close_button(idx, tab)
return tab
def _add_close_button(self, idx: int, tab: RequestTab):
btn = QPushButton("×")
btn.setObjectName("tabCloseBtn")
btn.setFixedSize(18, 18)
btn.setToolTip("Close Tab")
btn.clicked.connect(lambda: self._close_tab(self.tab_widget.indexOf(tab)))
self.tab_widget.tabBar().setTabButton(idx, QTabBar.ButtonPosition.RightSide, btn)
def close_current_tab(self):
self._close_tab(self.tab_widget.currentIndex())
def _close_tab(self, index: int):
if self.tab_widget.count() > 1:
self.tab_widget.removeTab(index)
def current_tab(self) -> RequestTab | None:
w = self.tab_widget.currentWidget()
return w if isinstance(w, RequestTab) else None
def load_request_in_new_tab(self, req: HttpRequest):
self.new_tab(req)
def load_request_in_current_tab(self, req: HttpRequest):
tab = self.current_tab()
if tab:
tab.load_request(req)
def rename_current_tab(self, name: str):
self.tab_widget.setTabText(self.tab_widget.currentIndex(), name)

981
app/ui/theme.py Normal file
View File

@@ -0,0 +1,981 @@
"""
APIClient - Agent — Central Theme Engine
All styling lives here in the global QSS.
UI widgets use setObjectName() selectors — never inline setStyleSheet() for static colors.
Only truly dynamic values (per-request method color, status badge) stay inline.
"""
from PyQt6.QtGui import QColor, QPalette
from PyQt6.QtWidgets import QApplication
# ── Color Palettes ────────────────────────────────────────────────────────────
class DarkColors:
BG_DARKEST = "#0D0D0D"
BG_SIDEBAR = "#111111"
BG_MAIN = "#181818"
BG_PANEL = "#1E1E1E"
BG_ELEVATED = "#242424"
BG_INPUT = "#2A2A2A"
BG_HOVER = "#303030"
BG_SELECTED = "#383838"
BORDER = "#2C2C2C"
BORDER_FOCUS = "#505050"
TEXT_PRIMARY = "#E4E4E4"
TEXT_SECONDARY = "#8A8A8A"
TEXT_MUTED = "#505050"
TEXT_DISABLED = "#3A3A3A"
ACCENT = "#E05C2C"
ACCENT_HOVER = "#F06030"
ACCENT_PRESSED = "#C04C20"
ACCENT_SUBTLE = "#2A1208"
SUCCESS = "#3FB950"
WARNING = "#D29922"
ERROR = "#F85149"
INFO = "#58A6FF"
METHOD_GET = "#61AFFE"
METHOD_POST = "#49CC90"
METHOD_PUT = "#FCA130"
METHOD_PATCH = "#50E3C2"
METHOD_DELETE = "#F93E3E"
METHOD_HEAD = "#9012FE"
METHOD_OPTIONS = "#0D5AA7"
STATUS_1XX = "#8C8C8C"
STATUS_2XX = "#3FB950"
STATUS_3XX = "#D29922"
STATUS_4XX = "#F85149"
STATUS_5XX = "#FF4444"
class LightColors:
BG_DARKEST = "#E2E2E2"
BG_SIDEBAR = "#ECECEC"
BG_MAIN = "#F2F2F2"
BG_PANEL = "#FFFFFF"
BG_ELEVATED = "#E8E8E8"
BG_INPUT = "#FFFFFF"
BG_HOVER = "#DCDCDC"
BG_SELECTED = "#D0D0D0"
BORDER = "#D0D0D0"
BORDER_FOCUS = "#A0A0A0"
TEXT_PRIMARY = "#1A1A1A"
TEXT_SECONDARY = "#555555"
TEXT_MUTED = "#999999"
TEXT_DISABLED = "#BBBBBB"
ACCENT = "#C94A14"
ACCENT_HOVER = "#E05520"
ACCENT_PRESSED = "#A83C0E"
ACCENT_SUBTLE = "#FDEEE6"
SUCCESS = "#1A7F37"
WARNING = "#7A5800"
ERROR = "#C01020"
INFO = "#0550AE"
METHOD_GET = "#0550AE"
METHOD_POST = "#1A7F37"
METHOD_PUT = "#7A3800"
METHOD_PATCH = "#116329"
METHOD_DELETE = "#C01020"
METHOD_HEAD = "#6639BA"
METHOD_OPTIONS = "#0550AE"
STATUS_1XX = "#777777"
STATUS_2XX = "#1A7F37"
STATUS_3XX = "#7A5800"
STATUS_4XX = "#C01020"
STATUS_5XX = "#C01020"
# ── Active palette (module-level singleton) ───────────────────────────────────
Colors = DarkColors
_is_dark = True
def method_color(method: str) -> str:
return {
"GET": Colors.METHOD_GET,
"POST": Colors.METHOD_POST,
"PUT": Colors.METHOD_PUT,
"PATCH": Colors.METHOD_PATCH,
"DELETE": Colors.METHOD_DELETE,
"HEAD": Colors.METHOD_HEAD,
"OPTIONS": Colors.METHOD_OPTIONS,
}.get(method.upper(), Colors.TEXT_SECONDARY)
def status_color(code: int) -> str:
if code < 200: return Colors.STATUS_1XX
if code < 300: return Colors.STATUS_2XX
if code < 400: return Colors.STATUS_3XX
if code < 500: return Colors.STATUS_4XX
return Colors.STATUS_5XX
# ── Global Stylesheet ─────────────────────────────────────────────────────────
# Everything static lives here. Object names are the API between theme and UI.
def _build_stylesheet(C) -> str:
return f"""
/* ════════════════════════════════════════════════════════
BASE
════════════════════════════════════════════════════════ */
QWidget {{
background-color: {C.BG_MAIN};
color: {C.TEXT_PRIMARY};
font-family: "Segoe UI", "SF Pro Text", "Inter", "Helvetica Neue", sans-serif;
font-size: 13px;
border: none;
outline: none;
}}
QMainWindow, QDialog {{
background-color: {C.BG_PANEL};
}}
/* ════════════════════════════════════════════════════════
SCROLLBARS
════════════════════════════════════════════════════════ */
QScrollBar:vertical {{
background: transparent; width: 8px; margin: 0;
}}
QScrollBar::handle:vertical {{
background: {C.BORDER_FOCUS}; border-radius: 4px; min-height: 28px;
}}
QScrollBar::handle:vertical:hover {{ background: {C.TEXT_MUTED}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{
background: transparent; height: 8px; margin: 0;
}}
QScrollBar::handle:horizontal {{
background: {C.BORDER_FOCUS}; border-radius: 4px; min-width: 28px;
}}
QScrollBar::handle:horizontal:hover {{ background: {C.TEXT_MUTED}; }}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
/* ════════════════════════════════════════════════════════
SPLITTER
════════════════════════════════════════════════════════ */
QSplitter::handle {{ background: {C.BORDER}; }}
QSplitter::handle:horizontal {{ width: 1px; }}
QSplitter::handle:vertical {{ height: 1px; }}
/* ════════════════════════════════════════════════════════
MENU
════════════════════════════════════════════════════════ */
QMenuBar {{
background-color: {C.BG_DARKEST};
color: {C.TEXT_SECONDARY};
border-bottom: 1px solid {C.BORDER};
padding: 2px 4px;
spacing: 4px;
}}
QMenuBar::item {{
background: transparent; padding: 4px 10px; border-radius: 4px;
}}
QMenuBar::item:selected, QMenuBar::item:pressed {{
background-color: {C.BG_ELEVATED}; color: {C.TEXT_PRIMARY};
}}
QMenu {{
background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 6px;
padding: 4px;
}}
QMenu::item {{ padding: 7px 28px 7px 12px; border-radius: 4px; }}
QMenu::item:selected {{ background-color: {C.BG_HOVER}; }}
QMenu::item:disabled {{ color: {C.TEXT_MUTED}; }}
QMenu::separator {{ height: 1px; background: {C.BORDER}; margin: 4px 8px; }}
/* ════════════════════════════════════════════════════════
INPUTS
════════════════════════════════════════════════════════ */
QLineEdit {{
background-color: {C.BG_INPUT};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 6px 10px;
selection-background-color: {C.ACCENT};
}}
QLineEdit:focus {{
border: 1px solid {C.BORDER_FOCUS};
background-color: {C.BG_ELEVATED};
}}
QLineEdit:disabled {{
color: {C.TEXT_DISABLED};
background-color: {C.BG_MAIN};
}}
QLineEdit::placeholder {{ color: {C.TEXT_MUTED}; }}
QTextEdit, QPlainTextEdit {{
background-color: {C.BG_INPUT};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 6px;
selection-background-color: {C.ACCENT};
}}
QTextEdit:focus, QPlainTextEdit:focus {{
border: 1px solid {C.BORDER_FOCUS};
}}
QSpinBox {{
background-color: {C.BG_INPUT};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 5px 8px;
}}
QSpinBox:focus {{ border-color: {C.BORDER_FOCUS}; }}
QSpinBox::up-button, QSpinBox::down-button {{
background: {C.BG_ELEVATED};
border: none;
width: 18px;
}}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {{
background: {C.BG_HOVER};
}}
/* ════════════════════════════════════════════════════════
COMBOBOX
════════════════════════════════════════════════════════ */
QComboBox {{
background-color: {C.BG_INPUT};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 5px 10px;
min-width: 80px;
}}
QComboBox:hover {{ border-color: {C.BORDER_FOCUS}; }}
QComboBox:focus {{ border-color: {C.BORDER_FOCUS}; }}
QComboBox::drop-down {{ border: none; width: 20px; subcontrol-origin: padding; }}
QComboBox::down-arrow {{ width: 10px; height: 10px; }}
QComboBox QAbstractItemView {{
background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 4px;
selection-background-color: {C.BG_SELECTED};
outline: none;
padding: 2px;
}}
/* ════════════════════════════════════════════════════════
CHECKBOX
════════════════════════════════════════════════════════ */
QCheckBox {{
color: {C.TEXT_SECONDARY};
spacing: 6px;
background: transparent;
}}
QCheckBox::indicator {{
width: 14px; height: 14px;
border: 1px solid {C.BORDER_FOCUS};
border-radius: 3px;
background: {C.BG_INPUT};
}}
QCheckBox::indicator:checked {{
background: {C.ACCENT}; border-color: {C.ACCENT};
}}
/* ════════════════════════════════════════════════════════
BUTTONS
════════════════════════════════════════════════════════ */
QPushButton {{
background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 6px 14px;
font-weight: 500;
}}
QPushButton:hover {{
background-color: {C.BG_HOVER};
border-color: {C.BORDER_FOCUS};
}}
QPushButton:pressed {{ background-color: {C.BG_SELECTED}; }}
QPushButton:disabled {{ color: {C.TEXT_MUTED}; border-color: {C.BORDER}; }}
QPushButton#accent {{
background-color: {C.ACCENT};
color: #FFFFFF;
border: none;
font-weight: 600;
}}
QPushButton#accent:hover {{ background-color: {C.ACCENT_HOVER}; }}
QPushButton#accent:pressed {{ background-color: {C.ACCENT_PRESSED}; }}
QPushButton#accent:disabled {{
background-color: {C.ACCENT_SUBTLE};
color: {C.TEXT_MUTED};
}}
QPushButton#ghost {{
background: transparent;
border: none;
color: {C.TEXT_SECONDARY};
padding: 4px 8px;
border-radius: 4px;
}}
QPushButton#ghost:hover {{
color: {C.TEXT_PRIMARY};
background-color: {C.BG_HOVER};
}}
QPushButton#ghost:pressed {{ background-color: {C.BG_SELECTED}; }}
QPushButton#danger {{
background-color: transparent;
color: {C.ERROR};
border: 1px solid {C.ERROR};
}}
QPushButton#danger:hover {{ background-color: {C.ACCENT_SUBTLE}; }}
QPushButton#sendBtn {{
background-color: {C.ACCENT};
color: white;
border: none;
border-radius: 6px;
padding: 8px 22px;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.3px;
}}
QPushButton#sendBtn:hover {{ background-color: {C.ACCENT_HOVER}; }}
QPushButton#sendBtn:pressed {{ background-color: {C.ACCENT_PRESSED}; }}
QPushButton#sendBtn:disabled {{
background-color: {C.ACCENT_SUBTLE};
color: {C.TEXT_MUTED};
}}
/* ════════════════════════════════════════════════════════
TABS
════════════════════════════════════════════════════════ */
QTabWidget::pane {{
border: none;
background-color: {C.BG_PANEL};
}}
QTabBar {{ background: transparent; }}
QTabBar::tab {{
background: transparent;
color: {C.TEXT_SECONDARY};
border: none;
border-bottom: 2px solid transparent;
padding: 8px 16px;
font-size: 12px;
font-weight: 500;
}}
QTabBar::tab:selected {{
color: {C.TEXT_PRIMARY};
border-bottom: 2px solid {C.ACCENT};
}}
QTabBar::tab:hover:!selected {{
color: {C.TEXT_PRIMARY};
background-color: {C.BG_HOVER};
border-radius: 4px 4px 0 0;
}}
QTabBar::close-button {{
subcontrol-position: right;
border-radius: 3px;
margin: 3px 2px;
padding: 0;
width: 14px;
height: 14px;
}}
/* Request/Response inner tab bars sit on BG_MAIN strip */
QTabWidget#innerTabs QTabBar {{
background: {C.BG_MAIN};
border-bottom: 1px solid {C.BORDER};
}}
/* Top workspace tab bar (HTTP / WebSocket / Mock Server) */
QTabWidget#workspaceTabs QTabBar::tab {{
background: {C.BG_DARKEST};
color: {C.TEXT_SECONDARY};
border: none;
border-right: 1px solid {C.BORDER};
padding: 10px 20px;
font-size: 12px;
font-weight: 600;
border-bottom: none;
border-top: 2px solid transparent;
}}
QTabWidget#workspaceTabs QTabBar::tab:selected {{
background: {C.BG_MAIN};
color: {C.TEXT_PRIMARY};
border-top: 2px solid {C.ACCENT};
}}
QTabWidget#workspaceTabs QTabBar::tab:hover:!selected {{
background: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
}}
/* ════════════════════════════════════════════════════════
TABLES
════════════════════════════════════════════════════════ */
QTableWidget {{
background-color: {C.BG_PANEL};
alternate-background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: none;
gridline-color: {C.BORDER};
selection-background-color: {C.BG_SELECTED};
selection-color: {C.TEXT_PRIMARY};
}}
QTableWidget::item {{
padding: 5px 8px;
border-bottom: 1px solid {C.BORDER};
}}
QTableWidget::item:selected {{
background-color: {C.BG_SELECTED};
color: {C.TEXT_PRIMARY};
}}
QHeaderView::section {{
background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: none;
border-bottom: 1px solid {C.BORDER};
border-right: 1px solid {C.BORDER};
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
QHeaderView::section:last {{ border-right: none; }}
/* ════════════════════════════════════════════════════════
TREE
════════════════════════════════════════════════════════ */
QTreeWidget {{
background-color: {C.BG_SIDEBAR};
color: {C.TEXT_PRIMARY};
border: none;
outline: none;
show-decoration-selected: 1;
}}
QTreeWidget::item {{
padding: 4px 4px;
border-radius: 3px;
}}
QTreeWidget::item:selected {{
background-color: {C.BG_SELECTED};
color: {C.TEXT_PRIMARY};
}}
QTreeWidget::item:hover:!selected {{ background-color: {C.BG_HOVER}; }}
QTreeWidget::branch {{ background: {C.BG_SIDEBAR}; }}
/* ════════════════════════════════════════════════════════
LIST
════════════════════════════════════════════════════════ */
QListWidget {{
background-color: {C.BG_PANEL};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
outline: none;
}}
QListWidget::item {{ padding: 8px 10px; border-radius: 3px; }}
QListWidget::item:selected {{
background-color: {C.BG_SELECTED}; color: {C.TEXT_PRIMARY};
}}
QListWidget::item:hover:!selected {{ background-color: {C.BG_HOVER}; }}
/* ════════════════════════════════════════════════════════
SIDEBAR LIST (no border, flush)
════════════════════════════════════════════════════════ */
QListWidget#sidebarList {{
background: {C.BG_SIDEBAR};
border: none;
border-radius: 0;
}}
QListWidget#sidebarList::item {{
padding: 10px 14px;
border-bottom: 1px solid {C.BORDER};
border-radius: 0;
font-size: 13px;
}}
QListWidget#sidebarList::item:selected {{
background: {C.BG_SELECTED}; color: {C.TEXT_PRIMARY};
}}
QListWidget#sidebarList::item:hover:!selected {{ background: {C.BG_HOVER}; }}
/* ════════════════════════════════════════════════════════
STATUS BAR
════════════════════════════════════════════════════════ */
QStatusBar {{
background-color: {C.BG_DARKEST};
color: {C.TEXT_SECONDARY};
border-top: 1px solid {C.BORDER};
font-size: 11px;
padding: 0 8px;
}}
QStatusBar::item {{ border: none; }}
/* ════════════════════════════════════════════════════════
PROGRESS BAR
════════════════════════════════════════════════════════ */
QProgressBar {{
background-color: {C.BG_ELEVATED};
border: 1px solid {C.BORDER};
border-radius: 4px;
height: 6px;
text-align: center;
color: transparent;
}}
QProgressBar::chunk {{
background-color: {C.ACCENT}; border-radius: 4px;
}}
/* ════════════════════════════════════════════════════════
GROUP BOX
════════════════════════════════════════════════════════ */
QGroupBox {{
color: {C.TEXT_SECONDARY};
border: 1px solid {C.BORDER};
border-radius: 6px;
margin-top: 8px;
padding: 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
QGroupBox::title {{
subcontrol-origin: margin; left: 10px; padding: 0 4px;
}}
/* ════════════════════════════════════════════════════════
TOOLTIP
════════════════════════════════════════════════════════ */
QToolTip {{
background-color: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 4px;
padding: 5px 8px;
font-size: 12px;
}}
/* ════════════════════════════════════════════════════════
DIALOG BUTTON BOX
════════════════════════════════════════════════════════ */
QDialogButtonBox QPushButton {{ min-width: 80px; }}
/* ════════════════════════════════════════════════════════
FRAME SEPARATORS
════════════════════════════════════════════════════════ */
QFrame[frameShape="4"], QFrame[frameShape="5"] {{
background-color: {C.BORDER};
border: none;
max-height: 1px;
max-width: 1px;
}}
/* ════════════════════════════════════════════════════════
── NAMED WIDGET RULES (setObjectName API) ──
════════════════════════════════════════════════════════ */
/* Top brand / env bar */
QWidget#envBar {{
background-color: {C.BG_DARKEST};
border-bottom: 1px solid {C.BORDER};
}}
QLabel#brandName {{
color: {C.ACCENT};
font-size: 15px;
font-weight: 800;
letter-spacing: 2px;
background: transparent;
}}
QLabel#brandSub {{
color: {C.TEXT_MUTED};
font-size: 11px;
font-weight: 500;
background: transparent;
}}
QLabel#envChip {{
color: {C.TEXT_MUTED};
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
background: transparent;
}}
/* Sidebar */
QWidget#sidebar {{
background-color: {C.BG_SIDEBAR};
border-right: 1px solid {C.BORDER};
}}
QWidget#sidebarHeader {{
background-color: {C.BG_SIDEBAR};
border-bottom: 1px solid {C.BORDER};
}}
QWidget#sidebarSearch {{
background-color: {C.BG_SIDEBAR};
}}
/* URL bar strip */
QWidget#urlBarStrip {{
background-color: {C.BG_MAIN};
border-bottom: 1px solid {C.BORDER};
}}
QLineEdit#urlBar {{
background-color: {C.BG_INPUT};
border: 1.5px solid {C.BORDER};
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
color: {C.TEXT_PRIMARY};
}}
QLineEdit#urlBar:focus {{
border-color: {C.ACCENT};
background-color: {C.BG_ELEVATED};
}}
/* Method combo (color set inline per method, only layout here) */
QComboBox#methodCombo {{
font-weight: 800;
font-size: 12px;
border-radius: 6px;
padding: 8px 10px;
min-width: 100px;
border: 1px solid {C.BORDER};
background-color: {C.BG_INPUT};
}}
QComboBox#methodCombo:hover {{ border-color: {C.BORDER_FOCUS}; }}
QComboBox#methodCombo QAbstractItemView {{
background: {C.BG_ELEVATED};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
selection-background-color: {C.BG_SELECTED};
}}
/* Inner request/response tab strip */
QWidget#tabStrip {{
background-color: {C.BG_MAIN};
border-bottom: 1px solid {C.BORDER};
}}
/* Response top bar */
QWidget#responseBar {{
background-color: {C.BG_MAIN};
border-top: 1px solid {C.BORDER};
border-bottom: 1px solid {C.BORDER};
}}
QLabel#responseTitle {{
color: {C.TEXT_MUTED};
font-size: 10px;
font-weight: 700;
letter-spacing: 1.2px;
background: transparent;
}}
QLabel#metaLabel {{
color: {C.TEXT_MUTED};
font-size: 11px;
background: transparent;
padding: 0 6px;
}}
/* Section/panel headers used in dialogs */
QWidget#panelHeader {{
background-color: {C.BG_ELEVATED};
border-bottom: 1px solid {C.BORDER};
}}
QWidget#panelFooter {{
background-color: {C.BG_ELEVATED};
border-top: 1px solid {C.BORDER};
}}
QWidget#sectionHeader {{
background-color: {C.BG_SIDEBAR};
border-bottom: 1px solid {C.BORDER};
}}
QWidget#panelBody {{
background-color: {C.BG_PANEL};
}}
/* Labels inside panels */
QLabel#panelTitle {{
font-size: 14px;
font-weight: 700;
color: {C.TEXT_PRIMARY};
background: transparent;
}}
QLabel#sectionLabel {{
color: {C.TEXT_MUTED};
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
background: transparent;
}}
QLabel#hintText {{
color: {C.TEXT_MUTED};
font-size: 11px;
background: transparent;
}}
QLabel#fieldLabel {{
color: {C.TEXT_SECONDARY};
font-size: 12px;
background: transparent;
}}
/* Body/code editors */
QTextEdit#codeEditor {{
background-color: {C.BG_PANEL};
color: {C.TEXT_PRIMARY};
border: none;
padding: 8px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
font-size: 11px;
}}
/* Loading overlay */
QWidget#loadingOverlay {{
background-color: {C.BG_PANEL};
}}
QLabel#loadingLabel {{
color: {C.TEXT_MUTED};
font-size: 13px;
background: transparent;
}}
/* Search in response bar */
QLineEdit#searchBar {{
background: {C.BG_INPUT};
border: 1px solid {C.BORDER};
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
color: {C.TEXT_PRIMARY};
}}
QLineEdit#searchBar:focus {{ border-color: {C.BORDER_FOCUS}; }}
/* Sidebar filter input */
QLineEdit#filterInput {{
background: {C.BG_ELEVATED};
border: 1px solid {C.BORDER};
border-radius: 4px;
padding: 5px 8px;
font-size: 12px;
color: {C.TEXT_PRIMARY};
}}
QLineEdit#filterInput:focus {{ border-color: {C.BORDER_FOCUS}; }}
/* WebSocket / Mock status indicator labels */
QLabel#statusOk {{
color: {C.SUCCESS};
font-size: 12px;
font-weight: 600;
background: transparent;
}}
QLabel#statusWarn {{
color: {C.WARNING};
font-size: 12px;
font-weight: 600;
background: transparent;
}}
QLabel#statusErr {{
color: {C.ERROR};
font-size: 12px;
font-weight: 600;
background: transparent;
}}
/* Auth "none" hint */
QLabel#authNone {{
color: {C.TEXT_MUTED};
padding: 12px;
background: transparent;
}}
/* Sidebar panel (environment dialog left pane, etc.) */
QWidget#sidebarPanel {{
background-color: {C.BG_SIDEBAR};
}}
/* Custom tab close button */
QPushButton#tabCloseBtn {{
background: transparent;
border: none;
border-radius: 3px;
color: {C.TEXT_MUTED};
font-size: 14px;
font-weight: 700;
padding: 0;
}}
QPushButton#tabCloseBtn:hover {{
background-color: {C.ERROR};
color: #FFFFFF;
}}
/* AI Assistant panel */
QWidget#aiPanel {{
background-color: {C.BG_PANEL};
}}
QTextEdit#aiOutput {{
background-color: {C.BG_INPUT};
color: {C.TEXT_PRIMARY};
border: 1px solid {C.BORDER};
border-radius: 5px;
padding: 8px;
font-size: 12px;
}}
QLabel#aiStatusLabel {{
color: {C.TEXT_MUTED};
font-size: 11px;
background: transparent;
}}
/* ── AI Chat Panel ─────────────────────────────────────── */
QWidget#aiChatPanel {{
background-color: {C.BG_SIDEBAR};
border-left: 1px solid {C.BORDER};
}}
QWidget#aiChatHeader {{
background-color: {C.BG_ELEVATED};
border-bottom: 1px solid {C.BORDER};
}}
QLabel#aiChatTitle {{
color: {C.ACCENT};
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
background: transparent;
}}
QWidget#chatArea {{
background-color: {C.BG_SIDEBAR};
}}
QFrame#userBubble {{
background-color: {C.ACCENT_SUBTLE};
border: 1px solid {C.BORDER};
border-left: 3px solid {C.ACCENT};
border-radius: 6px;
margin: 0px;
}}
QFrame#aiBubble {{
background-color: {C.BG_ELEVATED};
border: 1px solid {C.BORDER};
border-radius: 6px;
margin: 0px;
}}
QLabel#chatRoleLabel {{
font-size: 10px;
font-weight: 700;
color: {C.TEXT_MUTED};
background: transparent;
letter-spacing: 0.5px;
text-transform: uppercase;
}}
QLabel#chatMessageText {{
color: {C.TEXT_PRIMARY};
background: transparent;
font-size: 12px;
line-height: 1.6;
}}
QWidget#chatInputArea {{
background-color: {C.BG_ELEVATED};
border-top: 1px solid {C.BORDER};
}}
QTextEdit#chatInput {{
background-color: {C.BG_INPUT};
border: 1px solid {C.BORDER};
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
color: {C.TEXT_PRIMARY};
}}
QTextEdit#chatInput:focus {{
border-color: {C.ACCENT};
}}
QWidget#quickActions {{
background-color: {C.BG_MAIN};
border-top: 1px solid {C.BORDER};
border-bottom: 1px solid {C.BORDER};
}}
QPushButton#qaBtn {{
background-color: {C.BG_INPUT};
border: 1px solid {C.BORDER};
border-radius: 10px;
padding: 2px 8px;
font-size: 11px;
color: {C.TEXT_SECONDARY};
font-weight: 500;
}}
QPushButton#qaBtn:hover {{
background-color: {C.BG_HOVER};
border-color: {C.ACCENT};
color: {C.TEXT_PRIMARY};
}}
QFrame#applyBlock {{
background-color: {C.BG_PANEL};
border: 1px solid {C.BORDER};
border-left: 3px solid {C.ACCENT};
border-radius: 4px;
}}
QTextEdit#applyCode {{
background-color: transparent;
border: none;
padding: 4px;
font-size: 10px;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
color: {C.TEXT_SECONDARY};
}}
"""
def _apply_palette(app: QApplication, C):
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(C.BG_MAIN))
palette.setColor(QPalette.ColorRole.WindowText, QColor(C.TEXT_PRIMARY))
palette.setColor(QPalette.ColorRole.Base, QColor(C.BG_INPUT))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(C.BG_ELEVATED))
palette.setColor(QPalette.ColorRole.Text, QColor(C.TEXT_PRIMARY))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(C.TEXT_MUTED))
palette.setColor(QPalette.ColorRole.Button, QColor(C.BG_ELEVATED))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(C.TEXT_PRIMARY))
palette.setColor(QPalette.ColorRole.Highlight, QColor(C.ACCENT))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
palette.setColor(QPalette.ColorRole.Link, QColor(C.INFO))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(C.BG_ELEVATED))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor(C.TEXT_PRIMARY))
app.setPalette(palette)
def apply(app: QApplication, dark: bool = True):
global Colors, _is_dark
_is_dark = dark
Colors = DarkColors if dark else LightColors
app.setStyle("Fusion")
_apply_palette(app, Colors)
app.setStyleSheet(_build_stylesheet(Colors))
def toggle(app: QApplication) -> bool:
"""Toggle dark/light theme. Returns True if now dark."""
global _is_dark
apply(app, dark=not _is_dark)
return _is_dark
def is_dark() -> bool:
return _is_dark
def restyle(widget, obj_name: str) -> None:
"""Change a widget's objectName and force Qt to re-evaluate CSS rules."""
widget.setObjectName(obj_name)
widget.style().unpolish(widget)
widget.style().polish(widget)

216
app/ui/websocket_panel.py Normal file
View File

@@ -0,0 +1,216 @@
"""APIClient - Agent — WebSocket client panel."""
import asyncio
import queue
import time
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QTextEdit, QLabel, QSplitter
)
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QFont
from app.ui.theme import Colors, restyle
class WsWorker(QThread):
"""Runs the asyncio WebSocket loop in a background thread.
Messages to send are passed via a thread-safe queue.
Received messages and connection events are emitted as Qt signals.
"""
message_received = pyqtSignal(str)
connected = pyqtSignal()
disconnected = pyqtSignal(str)
def __init__(self, url: str):
super().__init__()
self.url = url
self._send_q: queue.Queue = queue.Queue()
self._running: bool = False
self._loop: asyncio.AbstractEventLoop | None = None
# ── Called from UI thread ─────────────────────────────────────────────────
def send(self, message: str):
"""Thread-safe: enqueue a message to be sent."""
self._send_q.put(message)
def stop(self):
self._running = False
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
# ── Worker thread ─────────────────────────────────────────────────────────
def run(self):
try:
import websockets
except ImportError:
self.disconnected.emit("WebSocket support not installed.\nRun: pip install websockets")
return
self._loop = asyncio.new_event_loop()
self._running = True
try:
self._loop.run_until_complete(self._connect(websockets))
except Exception as e:
self.disconnected.emit(str(e))
finally:
self._loop.close()
self._loop = None
async def _connect(self, websockets):
try:
async with websockets.connect(self.url) as ws:
self.connected.emit()
while self._running:
# Drain outbound queue
while not self._send_q.empty():
try:
msg = self._send_q.get_nowait()
await ws.send(msg)
except queue.Empty:
break
# Non-blocking receive with timeout
try:
msg = await asyncio.wait_for(ws.recv(), timeout=0.1)
self.message_received.emit(f"{msg}")
except asyncio.TimeoutError:
continue
except Exception:
break
except Exception as e:
self.disconnected.emit(str(e))
return
self.disconnected.emit("Connection closed")
class WebSocketPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# ── Connection bar ────────────────────────────────────────────────────
conn_row = QHBoxLayout()
url_label = QLabel("URL:")
url_label.setObjectName("fieldLabel")
self.url_input = QLineEdit()
self.url_input.setObjectName("urlBar")
self.url_input.setPlaceholderText("ws://localhost:8080/socket")
self.connect_btn = QPushButton("Connect")
self.connect_btn.setObjectName("accent")
self.connect_btn.setFixedWidth(100)
self.connect_btn.clicked.connect(self._toggle_connection)
self.status_label = QLabel("● Disconnected")
self.status_label.setObjectName("statusErr")
conn_row.addWidget(url_label)
conn_row.addWidget(self.url_input, 1)
conn_row.addWidget(self.connect_btn)
conn_row.addWidget(self.status_label)
layout.addLayout(conn_row)
splitter = QSplitter(Qt.Orientation.Vertical)
# Message log
self.log = QTextEdit()
self.log.setReadOnly(True)
self.log.setFont(QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10))
splitter.addWidget(self.log)
# Send area
send_w = QWidget()
send_layout = QVBoxLayout(send_w)
send_layout.setContentsMargins(0, 4, 0, 0)
send_layout.setSpacing(6)
self.send_input = QTextEdit()
self.send_input.setPlaceholderText("Type message to send…")
self.send_input.setMaximumHeight(80)
self.send_input.setFont(QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10))
send_btn_row = QHBoxLayout()
self.send_btn = QPushButton("Send")
self.send_btn.setObjectName("sendBtn")
self.send_btn.setEnabled(False)
self.send_btn.setFixedWidth(90)
self.send_btn.clicked.connect(self._send_message)
clear_btn = QPushButton("Clear Log")
clear_btn.setObjectName("ghost")
clear_btn.clicked.connect(self.log.clear)
send_btn_row.addWidget(self.send_btn)
send_btn_row.addWidget(clear_btn)
send_btn_row.addStretch()
send_layout.addWidget(self.send_input)
send_layout.addLayout(send_btn_row)
splitter.addWidget(send_w)
splitter.setSizes([350, 150])
layout.addWidget(splitter)
self._worker: WsWorker | None = None
# ── Slots ─────────────────────────────────────────────────────────────────
def _toggle_connection(self):
if self._worker and self._worker.isRunning():
self._worker.stop()
self._worker.wait(1000)
self._worker = None
self._set_disconnected("Disconnected by user")
else:
url = self.url_input.text().strip()
if not url:
return
self._worker = WsWorker(url)
self._worker.connected.connect(self._on_connected)
self._worker.disconnected.connect(self._on_disconnected)
self._worker.message_received.connect(self._on_message)
self._worker.start()
self.connect_btn.setText("Connecting…")
self.connect_btn.setEnabled(False)
restyle(self.status_label, "statusWarn")
self.status_label.setText("● Connecting…")
def _on_connected(self):
restyle(self.status_label, "statusOk")
self.status_label.setText("● Connected")
self.connect_btn.setText("Disconnect")
self.connect_btn.setEnabled(True)
self.send_btn.setEnabled(True)
self._log("── Connected ──", Colors.SUCCESS)
def _on_disconnected(self, reason: str):
self._set_disconnected(reason)
self._log(f"── {reason} ──", Colors.ERROR)
def _set_disconnected(self, reason: str = "Disconnected"):
restyle(self.status_label, "statusErr")
self.status_label.setText(f"{reason}")
self.connect_btn.setText("Connect")
self.connect_btn.setEnabled(True)
self.send_btn.setEnabled(False)
def _on_message(self, msg: str):
self._log(msg, Colors.INFO)
def _send_message(self):
msg = self.send_input.toPlainText().strip()
if msg and self._worker:
self._worker.send(msg)
self._log(f"{msg}", Colors.WARNING)
self.send_input.clear()
def _log(self, text: str, color: str = "#D4D4D4"):
ts = time.strftime("%H:%M:%S")
safe_text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
self.log.append(f'<span style="color:{color}">[{ts}] {safe_text}</span>')