diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..65ab971
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+venv/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+*.egg-info/
+dist/
+build/
+*.spec
+*.db
+*.sqlite
+*.sqlite3
+.env
+*.log
+.DS_Store
+Thumbs.db
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3a47b40
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 EKIKA.co
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 0d3b28f..e30a098 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,391 @@
-# APIClient-Agent
+# APIClient - Agent
+> **AI-first API testing desktop client** — built with Python + PyQt6.
+> Specialised for the [EKIKA Odoo API Framework](https://apps.odoo.com/apps/modules/19.0/api_framework), but works with any REST, GraphQL, or WebSocket API.
+
+---
+
+## Features
+
+### Core API Testing
+- **Multi-tab request editor** — work on multiple requests simultaneously, drag to reorder
+- **All HTTP methods** — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
+- **Smart params & headers table** — per-row enable/disable checkboxes, 36 px comfortable rows, auto-expanding blank row
+- **Body editor** — raw JSON/XML/text with syntax highlighting, **Format JSON** button, `application/vnd.api+json` support
+- **Auth panel** — Bearer Token, Basic Auth, API Key (header or query)
+- **Pre-request scripts** — Python executed before each request; access `pm.environment.get/set`
+- **Test scripts** — assertions auto-run after every response; `pm.test(...)` / `expect(...)` DSL
+- **Response viewer** — syntax-highlighted body, headers table, test results, search, copy, save
+- **WebSocket client** — connect, send, receive, log messages
+- **Mock server** — local HTTP mock with configurable routes
+
+### Collections & Environments
+- **Collections sidebar** — import/export Postman Collection v2.1 JSON, cURL
+- **Environment variables** — `{{base_url}}`, `{{api_key}}`, etc. resolved at send time; per-environment values
+- **Collection runner** — run all requests in a collection, view pass/fail results
+- **History** — every sent request automatically saved
+
+### AI Co-pilot (Claude-powered)
+- **Persistent AI chat sidebar** — toggle with the `✦ AI` button or `Ctrl+Shift+A`
+- **Full context awareness** — AI sees your current request (method, URL, headers, body, params, test scripts) and the last response (status, body, errors); secrets are automatically redacted
+- **Streaming responses** — tokens stream in real time
+- **One-click Apply** — AI suggestions come with **Apply Body**, **Apply Params**, **Apply Headers**, **Apply Test Script** buttons that set the values directly in the request editor
+- **Multi-turn conversation** — full history maintained per session; Clear to reset
+- **Quick actions** — Analyze, Fix Error, Gen Body, Write Tests, Auth Help, Explain Response
+- **EKIKA Odoo collection generator** — generate complete collections for JSON-API, REST JSON, GraphQL, and Custom REST JSON without spending AI tokens; supports all auth types
+
+### EKIKA Odoo API Framework specialisation
+- Generates full CRUD + Execute / Export / Report / Fields / Access-Rights endpoints per model
+- Auth types: API Key (`x-api-key`), Basic Auth, User Credentials, OAuth2, JWT, Public
+- Correct JSON-API body format out of the box: `{"data": {"type": "sale.order", "attributes": {...}}}`
+- Automatic environment creation with `base_url`, `api_key`, tokens
+
+---
+
+## Installation
+
+### Requirements
+- Python 3.11+
+- Linux, macOS, or Windows
+
+```bash
+git clone https://git.ekika.co/EKIKA.co/APIClient-Agent.git
+cd APIClient-Agent
+
+python -m venv venv
+source venv/bin/activate # Windows: venv\Scripts\activate
+
+pip install -r requirements.txt
+python main.py
+```
+
+### requirements.txt
+```
+PyQt6>=6.6.0
+httpx>=0.27.0
+websockets>=12.0
+anthropic>=0.25.0
+pyyaml>=6.0
+pyinstaller>=6.0.0
+```
+
+---
+
+## Quick Start
+
+### 1 — Send your first request
+
+1. Launch the app: `python main.py`
+2. Type a URL in the bar, e.g. `https://jsonplaceholder.typicode.com/todos/1`
+3. Press **Send** (or `Ctrl+Enter`)
+4. See the JSON response with syntax highlighting in the bottom panel
+
+### 2 — Use environment variables
+
+1. Click **Manage** → **New Environment** → name it `My API`
+2. Add variables:
+ ```
+ base_url = https://api.example.com
+ api_key = your-secret-key
+ ```
+3. Select the environment in the top bar
+4. In the URL bar, type `{{base_url}}/v1/users`
+5. In Headers, add `Authorization: Bearer {{api_key}}`
+6. Variables are resolved automatically at send time
+
+### 3 — Import a collection
+
+**From Postman export:**
+1. `File → Import…`
+2. Paste Postman Collection v2.1 JSON or drop the file
+
+**From cURL:**
+```
+File → Import… → paste:
+curl -X POST https://api.example.com/v1/orders \
+ -H "Content-Type: application/json" \
+ -d '{"product_id": 42, "qty": 1}'
+```
+
+**From OpenAPI spec:**
+1. `Tools → AI Assistant → Import from Docs`
+2. Paste the OpenAPI JSON/YAML URL — parsed instantly, no AI tokens used
+
+---
+
+## EKIKA Odoo API Framework — Complete Example
+
+### Generate a collection in 30 seconds
+
+1. Open **Tools → AI Assistant** (or click `✦ AI` → `AI Assistant` in the menu)
+2. Select the **EKIKA Odoo API** tab
+3. Fill in:
+
+ | Field | Example |
+ |---|---|
+ | Instance URL | `https://mycompany.odoo.com` |
+ | API Endpoint | `/user-jsonapi-apikey` |
+ | API Kind | `JSON-API` |
+ | Auth Type | `API Key` |
+ | API Key | `EwKCljvZoHXsaGlxxvCHt1h4SvWLpuWW` |
+ | Models | `sale.order, res.partner, account.move` |
+ | Operations | ✓ List, Get, Create, Update, Delete |
+
+4. Click **Generate Collection** — preview appears instantly
+5. Click **Import Both** — collection + environment are saved
+
+This generates the following requests for each model with zero AI tokens:
+
+```
+GET {{base_url}}/user-jsonapi-apikey/sale.order
+GET {{base_url}}/user-jsonapi-apikey/sale.order/{{id}}
+POST {{base_url}}/user-jsonapi-apikey/sale.order
+PATCH {{base_url}}/user-jsonapi-apikey/sale.order/{{id}}
+DELETE {{base_url}}/user-jsonapi-apikey/sale.order/{{id}}
+```
+
+### Sending a JSON-API request
+
+The generated **Create sale.order** request body:
+```json
+{
+ "data": {
+ "type": "sale.order",
+ "attributes": {
+ "name": "New Order"
+ }
+ }
+}
+```
+
+Headers sent automatically:
+```
+x-api-key: EwKCljvZoHXsaGlxxvCHt1h4SvWLpuWW
+Content-Type: application/vnd.api+json
+Accept: application/vnd.api+json
+```
+
+### Filtering and pagination (List endpoint)
+
+The **List sale.order** request includes query params:
+```
+page[number] = 1
+page[size] = 10
+fields[sale.order] = id,name,display_name,partner_id
+sort = -id
+```
+
+To filter by customer, open the AI chat sidebar and ask:
+> *"Add a filter param to get only sale orders for partner ID 7"*
+
+AI responds with an apply block you can apply in one click.
+
+### GraphQL example
+
+Select **API Kind: GraphQL**. The generator creates:
+
+**List sale.order (GraphQL)**
+```json
+{
+ "query": "query {\n sale_order(\n filter: \"\"\n pageSize: 10\n pageNumber: 1\n ) {\n id\n name\n display_name\n }\n}"
+}
+```
+
+**Create sale.order (GraphQL)**
+```json
+{
+ "query": "mutation {\n create_sale_order(\n attributes: {\n name: \"New Record\"\n }\n ) {\n id\n name\n }\n}"
+}
+```
+
+---
+
+## AI Chat Co-pilot — Example Session
+
+Click **✦ AI** in the top bar to open the sidebar. The AI automatically knows what request you have open and the last response.
+
+### Debugging a 401 error
+
+```
+You: I'm getting 401 Unauthorized on all my Odoo API requests.
+
+AI: A 401 on the EKIKA JSON-API endpoint means the x-api-key header is
+ missing, empty, or incorrect.
+
+ Checklist:
+ 1. Confirm your environment has api_key set (Tools → Environments)
+ 2. Confirm the request Headers tab includes x-api-key: {{api_key}}
+ 3. In Odoo, verify the API key is still active under your user's
+ API Framework settings
+
+ Here are the correct headers to apply:
+
+ ┌─ apply:headers ──────────────────────────────────┐
+ │ x-api-key: {{api_key}} │
+ │ Accept: application/vnd.api+json │
+ └──────────────────────────────────────────────────┘
+ [ Apply Headers to Request ]
+```
+
+Click **Apply Headers to Request** — headers are set immediately and the Headers tab opens.
+
+### Generating a body for a complex model
+
+```
+You: Generate a body to create an invoice (account.move) for partner ID 14,
+ with one line for product ID 7, quantity 3, price 150.
+
+AI: Here is a JSON-API compliant body for creating an account.move:
+
+ ┌─ apply:body ─────────────────────────────────────┐
+ │ { │
+ │ "data": { │
+ │ "type": "account.move", │
+ │ "attributes": { │
+ │ "move_type": "out_invoice", │
+ │ "partner_id": 14, │
+ │ "invoice_line_ids": [[0, 0, { │
+ │ "product_id": 7, │
+ │ "quantity": 3, │
+ │ "price_unit": 150.0 │
+ │ }]] │
+ │ } │
+ │ } │
+ │ } │
+ └──────────────────────────────────────────────────┘
+ [ Apply Body to Request ]
+```
+
+### Writing test scripts
+
+```
+You: Write tests to verify a successful JSON-API list response.
+
+AI: ┌─ apply:test ─────────────────────────────────────┐
+ │ pm.test('Status 200', lambda: │
+ │ pm.response.to_have_status(200)) │
+ │ pm.test('Has data array', lambda: │
+ │ expect(pm.response.json()).to_have_key('data'))│
+ │ pm.test('Data is list', lambda: │
+ │ expect(pm.response.json()['data']).to_be_list())│
+ │ pm.test('Response time < 2s', lambda: │
+ │ expect(pm.response.response_time).to_be_below(2000))│
+ └──────────────────────────────────────────────────┘
+ [ Apply Test Script to Request ]
+```
+
+---
+
+## Keyboard Shortcuts
+
+| Shortcut | Action |
+|---|---|
+| `Ctrl+Enter` | Send request |
+| `Ctrl+T` | New tab |
+| `Ctrl+W` | Close tab |
+| `Ctrl+S` | Save to collection |
+| `Ctrl+F` | Search requests |
+| `Ctrl+E` | Manage environments |
+| `Ctrl+Shift+A` | Toggle AI chat sidebar |
+| `Ctrl+Shift+F` | Format JSON body |
+| `Escape` | Cancel in-flight request |
+| `Ctrl+Q` | Quit |
+
+---
+
+## Project Structure
+
+```
+APIClient-Agent/
+├── main.py # Entry point
+├── requirements.txt
+├── LICENSE
+├── app/
+│ ├── models.py # HttpRequest, HttpResponse, Environment
+│ ├── core/
+│ │ ├── storage.py # SQLite persistence (collections, environments, history)
+│ │ ├── http_client.py # httpx-based request engine, variable resolution
+│ │ ├── ai_client.py # Claude API — collection generation from docs
+│ │ ├── ai_chat.py # Claude API — multi-turn conversational co-pilot
+│ │ ├── openapi_parser.py # OpenAPI 3.x / Swagger 2.0 local parser
+│ │ ├── ekika_odoo_generator.py# EKIKA Odoo framework collection generator
+│ │ ├── test_runner.py # pm.test / expect assertion engine
+│ │ ├── mock_server.py # Local HTTP mock server
+│ │ ├── code_gen.py # Code generation (curl, Python, JS, etc.)
+│ │ ├── exporter.py # Postman Collection v2.1 export
+│ │ └── importer.py # Postman Collection / cURL import
+│ └── ui/
+│ ├── main_window.py # Main window, splitter layout, env bar
+│ ├── theme.py # Central QSS stylesheet engine (dark/light)
+│ ├── request_panel.py # URL bar, params/headers/body/auth/tests editor
+│ ├── response_panel.py # Response viewer with status badge
+│ ├── tabs_manager.py # Multi-tab request manager
+│ ├── sidebar.py # Collections tree sidebar
+│ ├── ai_panel.py # AI Assistant dialog (collection generator)
+│ ├── ai_chat_panel.py # AI chat sidebar (co-pilot)
+│ ├── environment_dialog.py # Environment manager
+│ ├── collection_runner.py # Collection runner
+│ ├── websocket_panel.py # WebSocket client
+│ ├── mock_server_panel.py # Mock server UI
+│ ├── import_dialog.py # Import dialog
+│ ├── code_gen_dialog.py # Code generation dialog
+│ ├── search_dialog.py # Request search
+│ └── highlighter.py # JSON syntax highlighter
+```
+
+---
+
+## Configuration
+
+Settings are stored in an SQLite database at `~/.apiclient_agent/data.db` (created automatically).
+
+### Anthropic API Key (for AI features)
+
+1. Get a key at [console.anthropic.com](https://console.anthropic.com)
+2. In the app: `Tools → AI Assistant → Settings tab`
+3. Paste the key and click **Save API Key**
+
+The key is stored locally in the SQLite database only — never transmitted except to the Anthropic API.
+
+---
+
+## SSL / TLS Notes
+
+Some servers (especially demo/development instances) use self-signed certificates or wildcard certificates that don't match the exact hostname. If you see:
+
+```
+SSL certificate error — could not connect to https://...
+Tip: disable SSL verification in the request Settings tab.
+```
+
+Open the **Settings** tab in the request editor and uncheck **Verify SSL certificate**.
+
+---
+
+## Building a Standalone Executable
+
+```bash
+pyinstaller --onefile --windowed \
+ --name "APIClient-Agent" \
+ --add-data "app:app" \
+ main.py
+```
+
+The executable is produced in `dist/APIClient-Agent`.
+
+---
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch: `git checkout -b feature/my-feature`
+3. Commit your changes: `git commit -m "Add my feature"`
+4. Push and open a pull request
+
+Please keep UI styling in `theme.py` using `setObjectName()` selectors — never inline `setStyleSheet()` for static colors.
+
+---
+
+## License
+
+[MIT License](LICENSE) — Copyright (c) 2026 EKIKA.co
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/core/ai_chat.py b/app/core/ai_chat.py
new file mode 100644
index 0000000..4acbdf2
--- /dev/null
+++ b/app/core/ai_chat.py
@@ -0,0 +1,189 @@
+"""APIClient - Agent — Conversational AI co-pilot core."""
+import json
+import re
+import httpx
+from app.core import storage
+
+MAX_HISTORY_MSGS = 30
+
+_SYSTEM_PROMPT = """\
+You are APIClient - Agent, an expert AI API testing co-pilot embedded in the APIClient - Agent desktop application.
+
+Your responsibilities:
+• Help craft and debug HTTP requests (REST, JSON-API, GraphQL, Odoo APIs)
+• Analyze HTTP responses — status codes, headers, body structure, errors
+• Specialize in the EKIKA Odoo API Framework:
+ - JSON-API (Content-Type: application/vnd.api+json) — body format: {"data": {"type": model, "attributes": {...}}}
+ - REST JSON (Content-Type: application/json)
+ - GraphQL (POST with {"query": "..."} body)
+ - Auth: x-api-key header, Basic Auth, OAuth2 Bearer, JWT Bearer
+ - Odoo models: sale.order, res.partner, account.move, product.template, stock.picking, etc.
+• Generate request bodies, params, headers, and test scripts
+• Explain SSL/TLS errors, auth failures, and connection issues
+• Help with environment variable setup ({{base_url}}, {{api_key}}, etc.)
+
+When you produce content the user should apply to their request, use EXACTLY these fences:
+
+```apply:body
+{ "json": "here" }
+```
+
+```apply:params
+page=1
+limit=10
+fields=id,name
+```
+
+```apply:headers
+x-api-key: {{api_key}}
+Accept: application/vnd.api+json
+```
+
+```apply:test
+pm.test('Status 200', lambda: pm.response.to_have_status(200))
+pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data'))
+```
+
+Rules:
+- Be concise and actionable — explain WHY, not just WHAT
+- If you add apply blocks, briefly explain what each block does
+- For JSON-API responses: data is in response.data, errors in response.errors
+- For SSL cert errors: tell user to uncheck SSL verification in the Settings tab
+- For 401/403: check x-api-key header and environment variable values
+- For unresolved {{variable}}: tell user to set up environment via Tools → Environments
+"""
+
+
+class AIError(Exception):
+ pass
+
+
+def get_api_key() -> str:
+ return storage.get_setting("anthropic_api_key", "")
+
+
+def build_context(req=None, resp=None, env_vars: dict = None) -> str:
+ """Build compact context string for system prompt injection."""
+ parts = []
+
+ if req:
+ parts.append(f"METHOD: {req.method}")
+ parts.append(f"URL: {req.url}")
+ if req.headers:
+ safe = {
+ k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "auth", "pass"]) else v)
+ for k, v in req.headers.items()
+ }
+ parts.append(f"HEADERS: {json.dumps(safe)}")
+ if req.params:
+ parts.append(f"PARAMS: {json.dumps(req.params)}")
+ if req.body:
+ preview = req.body[:1500] + ("…" if len(req.body) > 1500 else "")
+ parts.append(f"BODY ({req.body_type}):\n{preview}")
+ if req.content_type:
+ parts.append(f"CONTENT-TYPE: {req.content_type}")
+ if req.test_script:
+ parts.append(f"TEST SCRIPT:\n{req.test_script}")
+
+ if resp:
+ if resp.error:
+ parts.append(f"\nRESPONSE ERROR: {resp.error}")
+ else:
+ parts.append(f"\nRESPONSE: {resp.status} {resp.reason} ({resp.elapsed_ms:.0f} ms)")
+ ct = (resp.headers or {}).get("content-type", (resp.headers or {}).get("Content-Type", ""))
+ if ct:
+ parts.append(f"RESPONSE CONTENT-TYPE: {ct}")
+ if resp.body:
+ preview = resp.body[:4000] + ("…" if len(resp.body) > 4000 else "")
+ parts.append(f"RESPONSE BODY:\n{preview}")
+
+ if env_vars:
+ safe_vars = {
+ k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "password", "pass"]) else v)
+ for k, v in env_vars.items()
+ }
+ parts.append(f"\nENVIRONMENT VARIABLES: {json.dumps(safe_vars)}")
+
+ return "\n".join(parts)
+
+
+def stream_chat(messages: list[dict], context: str = "", chunk_cb=None) -> str:
+ """
+ Stream a multi-turn conversation to Claude.
+ messages: list of {"role": "user"|"assistant", "content": str}
+ chunk_cb(chunk: str): called for each streamed text chunk
+ Returns full assistant response text.
+ Raises AIError on failure.
+ """
+ api_key = get_api_key()
+ if not api_key:
+ raise AIError(
+ "No Anthropic API key configured.\n"
+ "Go to Tools → AI Assistant → Settings to add your key."
+ )
+
+ system = _SYSTEM_PROMPT
+ if context:
+ system += f"\n\n## Current Request Context\n{context}"
+
+ headers = {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ }
+ payload = {
+ "model": "claude-opus-4-6",
+ "max_tokens": 2048,
+ "system": system,
+ "messages": messages[-MAX_HISTORY_MSGS:],
+ }
+
+ full_text = ""
+ try:
+ with httpx.stream(
+ "POST",
+ "https://api.anthropic.com/v1/messages",
+ headers=headers,
+ json=payload,
+ timeout=60.0,
+ ) as resp:
+ if resp.status_code != 200:
+ body = resp.read().decode()
+ raise AIError(f"Claude API error {resp.status_code}: {body[:400]}")
+
+ for line in resp.iter_lines():
+ if not line.startswith("data:"):
+ continue
+ data_str = line[5:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ event = json.loads(data_str)
+ delta = event.get("delta", {})
+ if delta.get("type") == "text_delta":
+ chunk = delta.get("text", "")
+ full_text += chunk
+ if chunk_cb:
+ chunk_cb(chunk)
+ except json.JSONDecodeError:
+ continue
+
+ except httpx.TimeoutException:
+ raise AIError("Request timed out. Try a shorter question or check your connection.")
+ except httpx.RequestError as e:
+ raise AIError(f"Network error: {e}")
+
+ return full_text
+
+
+def parse_apply_blocks(text: str) -> list[dict]:
+ """Parse ```apply:TYPE ... ``` blocks from AI response text."""
+ blocks = []
+ for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL):
+ blocks.append({"type": m.group(1), "content": m.group(2).strip()})
+ return blocks
+
+
+def strip_apply_blocks(text: str) -> str:
+ """Remove apply fences from display text, leaving the explanation."""
+ return re.sub(r"```apply:\w+\n.*?```", "", text, flags=re.DOTALL).strip()
diff --git a/app/core/ai_client.py b/app/core/ai_client.py
new file mode 100644
index 0000000..3a87a84
--- /dev/null
+++ b/app/core/ai_client.py
@@ -0,0 +1,219 @@
+"""APIClient - Agent — Claude AI integration."""
+import json
+import re
+import httpx
+
+from app.core import storage
+
+# Max characters to send to Claude (roughly 60k tokens)
+_MAX_CONTENT_CHARS = 80_000
+
+
+def _strip_html(html: str) -> str:
+ """Strip HTML tags and collapse whitespace for cleaner AI input."""
+ # Remove script/style blocks entirely
+ html = re.sub(r"<(script|style)[^>]*>.*?(script|style)>", " ", html, flags=re.S | re.I)
+ # Remove HTML tags
+ html = re.sub(r"<[^>]+>", " ", html)
+ # Decode common entities
+ html = (html
+ .replace("&", "&").replace("<", "<").replace(">", ">")
+ .replace(""", '"').replace("'", "'").replace(" ", " "))
+ # Collapse whitespace
+ html = re.sub(r"\s{3,}", "\n\n", html)
+ return html.strip()
+
+_SYSTEM_PROMPT = """\
+You are an expert API documentation analyzer for APIClient - Agent.
+Given API documentation (which may be a spec, a web page, framework docs, or raw text),
+extract or infer all useful API endpoints and return structured JSON.
+
+Return ONLY valid JSON — no markdown, no commentary, just the JSON object.
+
+Schema:
+{
+ "collection_name": "API Name",
+ "base_url": "https://api.example.com",
+ "auth_type": "bearer|basic|apikey|none",
+ "doc_type": "openapi|rest|framework|graphql|unknown",
+ "endpoints": [
+ {
+ "name": "Human readable name",
+ "method": "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS",
+ "path": "/v1/resource",
+ "description": "What this endpoint does",
+ "headers": {"Header-Name": "value or {{variable}}"},
+ "params": {"param_name": "example_value"},
+ "body": "",
+ "body_type": "raw|form-urlencoded|form-data",
+ "content_type": "application/json",
+ "test_script": "pm.test('Status 200', lambda: pm.response.to_have_status(200))"
+ }
+ ],
+ "environment_variables": {
+ "base_url": "https://api.example.com",
+ "token": ""
+ },
+ "notes": "Any important setup notes for the user"
+}
+
+Rules:
+- Use {{variable_name}} for ALL dynamic values (tokens, IDs, model names, etc.)
+- Always output realistic example values for query params and bodies
+- Generate a test_script for every endpoint
+- Detect auth pattern and add the correct header to every endpoint
+- If the documentation is a FRAMEWORK (e.g. it documents URL patterns like
+ {domain}/{endpoint}/{model} rather than fixed paths), do the following:
+ * Set doc_type to "framework"
+ * Use {{base_url}} as the domain placeholder
+ * Use {{model}} as a placeholder for the resource/model name
+ * Generate one endpoint per HTTP method the framework supports (GET list,
+ GET single, POST create, PATCH update, DELETE delete, plus any special ops)
+ * Set notes explaining that the user must replace {{model}} with actual model names
+ e.g. "res.partner", "sale.order", "product.template" etc.
+- If it is a GRAPHQL API, generate a POST /graphql endpoint with example query body
+- If auth options are shown (API key, OAuth, Basic), include ALL variants as separate
+ environment variables so the user can choose
+- Keep paths clean — strip trailing slashes, normalise to lowercase
+"""
+
+
+class AIError(Exception):
+ pass
+
+
+def get_api_key() -> str:
+ return storage.get_setting("anthropic_api_key", "")
+
+
+def set_api_key(key: str):
+ storage.set_setting("anthropic_api_key", key.strip())
+
+
+def analyze_docs(content: str, progress_cb=None) -> dict:
+ """
+ Send API documentation content to Claude and return parsed collection dict.
+ progress_cb(message: str) is called with status updates during streaming.
+ Raises AIError on failure.
+ """
+ api_key = get_api_key()
+ if not api_key:
+ raise AIError("No Anthropic API key configured. Go to Tools → AI Assistant → Settings.")
+
+ if progress_cb:
+ progress_cb("Sending to Claude AI…")
+
+ headers = {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ }
+ payload = {
+ "model": "claude-opus-4-6",
+ "max_tokens": 8192,
+ "system": _SYSTEM_PROMPT,
+ "messages": [{"role": "user", "content": content}],
+ }
+
+ full_text = ""
+ try:
+ with httpx.stream(
+ "POST",
+ "https://api.anthropic.com/v1/messages",
+ headers=headers,
+ json=payload,
+ timeout=120.0,
+ ) as resp:
+ if resp.status_code != 200:
+ body = resp.read().decode()
+ raise AIError(f"API error {resp.status_code}: {body[:300]}")
+
+ for line in resp.iter_lines():
+ if not line.startswith("data:"):
+ continue
+ data_str = line[5:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ event = json.loads(data_str)
+ delta = event.get("delta", {})
+ if delta.get("type") == "text_delta":
+ chunk = delta.get("text", "")
+ full_text += chunk
+ if progress_cb and len(full_text) % 500 < len(chunk):
+ progress_cb(f"Receiving response… ({len(full_text)} chars)")
+ except json.JSONDecodeError:
+ continue
+
+ except httpx.TimeoutException:
+ raise AIError("Request timed out. The documentation may be too large.")
+ except httpx.RequestError as e:
+ raise AIError(f"Network error: {e}")
+
+ if progress_cb:
+ progress_cb("Parsing AI response…")
+
+ return _parse_ai_response(full_text)
+
+
+def _parse_ai_response(text: str) -> dict:
+ """Extract and validate the JSON from the AI response."""
+ text = text.strip()
+
+ # Strip markdown code fences if present
+ if text.startswith("```"):
+ lines = text.split("\n")
+ text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
+
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ # Try to find JSON object in the text
+ start = text.find("{")
+ end = text.rfind("}") + 1
+ if start >= 0 and end > start:
+ try:
+ data = json.loads(text[start:end])
+ except json.JSONDecodeError:
+ raise AIError("AI returned invalid JSON. Try again or simplify the documentation.")
+ else:
+ raise AIError("AI response did not contain a JSON object.")
+
+ # Validate required keys
+ if "endpoints" not in data:
+ raise AIError("AI response missing 'endpoints' key.")
+
+ return data
+
+
+def fetch_url_content(url: str) -> str:
+ """Fetch content from a URL, strip HTML if needed, and truncate if too large."""
+ try:
+ resp = httpx.get(url, follow_redirects=True, timeout=30.0, headers={
+ "User-Agent": "EKIKA-API-Client/2.0 (documentation-fetcher)",
+ "Accept": "application/json, text/yaml, text/html, */*",
+ })
+ resp.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ raise AIError(f"HTTP {e.response.status_code} fetching URL.")
+ except httpx.RequestError as e:
+ raise AIError(f"Could not fetch URL: {e}")
+
+ ct = resp.headers.get("content-type", "")
+ text = resp.text
+
+ # If HTML page — strip tags for cleaner AI input
+ if "html" in ct and not _looks_like_spec(text):
+ text = _strip_html(text)
+
+ # Truncate if too large
+ if len(text) > _MAX_CONTENT_CHARS:
+ text = text[:_MAX_CONTENT_CHARS] + "\n\n[Content truncated for length]"
+
+ return text
+
+
+def _looks_like_spec(text: str) -> bool:
+ """Quick check: is this likely a JSON/YAML OpenAPI spec?"""
+ t = text.lstrip()
+ return t.startswith("{") or t.startswith("openapi:") or t.startswith("swagger:")
diff --git a/app/core/code_gen.py b/app/core/code_gen.py
new file mode 100644
index 0000000..4685863
--- /dev/null
+++ b/app/core/code_gen.py
@@ -0,0 +1,163 @@
+"""APIClient - Agent — Code snippet generators."""
+import json
+from urllib.parse import urlencode
+
+from app.models import HttpRequest
+
+
+def _qs(req: HttpRequest) -> str:
+ if not req.params:
+ return req.url
+ sep = "&" if "?" in req.url else "?"
+ return req.url + sep + urlencode(req.params)
+
+
+def to_curl(req: HttpRequest) -> str:
+ parts = [f"curl -X {req.method}"]
+ parts.append(f' "{_qs(req)}"')
+ for k, v in req.headers.items():
+ parts.append(f" -H '{k}: {v}'")
+ if req.body:
+ escaped = req.body.replace("'", r"'\''")
+ parts.append(f" -d '{escaped}'")
+ if not req.ssl_verify:
+ parts.append(" --insecure")
+ return " \\\n".join(parts)
+
+
+def to_python_requests(req: HttpRequest) -> str:
+ headers = dict(req.headers)
+ if req.body and "Content-Type" not in headers:
+ headers["Content-Type"] = "application/json"
+
+ lines = ["import requests", ""]
+ lines.append(f'url = "{req.url}"')
+ if headers:
+ lines.append(f"headers = {json.dumps(headers, indent=4)}")
+ if req.params:
+ lines.append(f"params = {json.dumps(req.params, indent=4)}")
+ lines.append("")
+
+ call_args = ["url"]
+ if headers:
+ call_args.append("headers=headers")
+ if req.params:
+ call_args.append("params=params")
+ if req.body:
+ lines.append(f"payload = {json.dumps(req.body)}")
+ call_args.append("data=payload")
+ if not req.ssl_verify:
+ call_args.append("verify=False")
+
+ lines.append(f"response = requests.{req.method.lower()}({', '.join(call_args)})")
+ lines.append("")
+ lines.append("print(response.status_code)")
+ lines.append("print(response.json())")
+ return "\n".join(lines)
+
+
+def to_python_httpx(req: HttpRequest) -> str:
+ headers = dict(req.headers)
+ lines = ["import httpx", ""]
+ lines.append(f'url = "{req.url}"')
+ if headers:
+ lines.append(f"headers = {json.dumps(headers, indent=4)}")
+ if req.params:
+ lines.append(f"params = {json.dumps(req.params, indent=4)}")
+ lines.append("")
+
+ call_args = ["url"]
+ if headers:
+ call_args.append("headers=headers")
+ if req.params:
+ call_args.append("params=params")
+ if req.body:
+ lines.append(f"payload = {json.dumps(req.body)}")
+ call_args.append("content=payload.encode()")
+ if not req.ssl_verify:
+ call_args.append("verify=False")
+
+ lines.append("with httpx.Client() as client:")
+ lines.append(f" response = client.{req.method.lower()}({', '.join(call_args)})")
+ lines.append(" print(response.status_code)")
+ lines.append(" print(response.json())")
+ return "\n".join(lines)
+
+
+def to_javascript_fetch(req: HttpRequest) -> str:
+ options: dict = {"method": req.method}
+ if req.headers:
+ options["headers"] = req.headers
+ if req.body:
+ options["body"] = req.body
+
+ lines = [
+ f'const response = await fetch("{_qs(req)}", {json.dumps(options, indent=2)});',
+ "",
+ "const data = await response.json();",
+ "console.log(response.status, data);",
+ ]
+ return "\n".join(lines)
+
+
+def to_javascript_axios(req: HttpRequest) -> str:
+ config: dict = {}
+ if req.headers:
+ config["headers"] = req.headers
+ if req.params:
+ config["params"] = req.params
+ if req.body:
+ config["data"] = req.body
+
+ lines = ["const axios = require('axios');", ""]
+ if config:
+ lines.append(f"const config = {json.dumps(config, indent=2)};")
+ lines.append("")
+ lines.append(f'const response = await axios.{req.method.lower()}("{req.url}", config);')
+ else:
+ lines.append(f'const response = await axios.{req.method.lower()}("{req.url}");')
+ lines.append("console.log(response.status, response.data);")
+ return "\n".join(lines)
+
+
+def to_ruby(req: HttpRequest) -> str:
+ lines = [
+ "require 'net/http'",
+ "require 'uri'",
+ "require 'json'",
+ "",
+ f'uri = URI.parse("{_qs(req)}")',
+ f"http = Net::HTTP.new(uri.host, uri.port)",
+ "http.use_ssl = uri.scheme == 'https'",
+ ]
+ if not req.ssl_verify:
+ lines.append("http.verify_mode = OpenSSL::SSL::VERIFY_NONE")
+ lines.append("")
+ klass_map = {
+ "GET": "Net::HTTP::Get", "POST": "Net::HTTP::Post",
+ "PUT": "Net::HTTP::Put", "PATCH": "Net::HTTP::Patch",
+ "DELETE": "Net::HTTP::Delete", "HEAD": "Net::HTTP::Head",
+ }
+ klass = klass_map.get(req.method, "Net::HTTP::Get")
+ lines.append(f"request = {klass}.new(uri.request_uri)")
+ for k, v in req.headers.items():
+ lines.append(f'request["{k}"] = "{v}"')
+ if req.body:
+ lines.append(f"request.body = {json.dumps(req.body)}")
+ lines += [
+ "",
+ "response = http.request(request)",
+ "puts response.code",
+ "puts response.body",
+ ]
+ return "\n".join(lines)
+
+
+GENERATORS = {
+ "curl": to_curl,
+ "Python (requests)": to_python_requests,
+ "Python (httpx)": to_python_httpx,
+ "JavaScript (fetch)": to_javascript_fetch,
+ "JavaScript (axios)": to_javascript_axios,
+ "Ruby (Net::HTTP)": to_ruby,
+}
diff --git a/app/core/ekika_odoo_generator.py b/app/core/ekika_odoo_generator.py
new file mode 100644
index 0000000..8bedca5
--- /dev/null
+++ b/app/core/ekika_odoo_generator.py
@@ -0,0 +1,613 @@
+"""EKIKA Odoo API Framework — Direct collection generator.
+
+Generates complete Postman-style collections from the EKIKA api_framework module
+without requiring any AI API calls. All URL patterns, body formats, auth headers,
+and special operations are derived directly from the framework documentation.
+
+Supported API kinds: JSON-API, REST JSON, GraphQL, Custom REST JSON
+Supported auth types: API Key, Basic Auth, User Credentials, OAuth2, JWT, Public
+"""
+import json
+import re
+
+
+# ── Constants ─────────────────────────────────────────────────────────────────
+
+API_KINDS = ["JSON-API", "REST JSON", "GraphQL", "Custom REST JSON"]
+
+AUTH_TYPES = [
+ "API Key",
+ "Basic Auth",
+ "User Credentials",
+ "OAuth2",
+ "JWT",
+ "Public",
+]
+
+OPERATIONS = [
+ "List Records",
+ "Get Single Record",
+ "Create Record",
+ "Update Record",
+ "Delete Record",
+ "Execute Method",
+ "Export to Excel",
+ "Generate Report",
+ "Get Fields",
+ "Check Access Rights",
+]
+
+# Content-Type per API kind
+_CT = {
+ "JSON-API": "application/vnd.api+json",
+ "REST JSON": "application/json",
+ "GraphQL": "application/json",
+ "Custom REST JSON": "application/json",
+}
+
+
+# ── Auth header builder ───────────────────────────────────────────────────────
+
+def _auth_headers(auth_type: str) -> dict:
+ if auth_type == "API Key":
+ return {"x-api-key": "{{api_key}}"}
+ if auth_type == "Basic Auth":
+ return {"Authorization": "Basic {{base64_user_pass}}"}
+ if auth_type == "User Credentials":
+ return {"username": "{{username}}", "password": "{{password}}"}
+ if auth_type == "OAuth2":
+ return {"Authorization": "Bearer {{access_token}}"}
+ if auth_type == "JWT":
+ return {"Authorization": "Bearer {{jwt_token}}"}
+ return {} # Public
+
+
+def _env_vars(instance_url: str, auth_type: str, extra: dict = None) -> dict:
+ base = instance_url.rstrip("/")
+ env: dict = {"base_url": base}
+
+ if auth_type == "API Key":
+ env["api_key"] = extra.get("api_key", "") if extra else ""
+ elif auth_type == "Basic Auth":
+ env["base64_user_pass"] = extra.get("base64_user_pass", "") if extra else ""
+ env["username"] = extra.get("username", "") if extra else ""
+ env["password"] = extra.get("password", "") if extra else ""
+ elif auth_type == "User Credentials":
+ env["username"] = extra.get("username", "") if extra else ""
+ env["password"] = extra.get("password", "") if extra else ""
+ elif auth_type == "OAuth2":
+ env["access_token"] = extra.get("access_token", "") if extra else ""
+ env["refresh_token"] = ""
+ env["client_id"] = ""
+ env["client_secret"] = ""
+ elif auth_type == "JWT":
+ env["jwt_token"] = extra.get("jwt_token", "") if extra else ""
+
+ return env
+
+
+def _clean_endpoint(endpoint: str) -> str:
+ """Normalise endpoint slug — ensure leading slash, strip trailing slash."""
+ ep = endpoint.strip().strip("/")
+ return f"/{ep}" if ep else "/api"
+
+
+def _model_gql(model: str) -> str:
+ """Convert 'sale.order' → 'sale_order' for GraphQL field names."""
+ return model.replace(".", "_")
+
+
+# ── Request builders ──────────────────────────────────────────────────────────
+
+def _jsonapi_test(status: int = 200) -> str:
+ return (
+ f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n"
+ f"pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data'))"
+ )
+
+
+def _rest_test(status: int = 200) -> str:
+ return (
+ f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n"
+ f"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())"
+ )
+
+
+def _build_jsonapi_endpoints(base_ep: str, model: str, headers: dict,
+ operations: list[str]) -> list[dict]:
+ ct = _CT["JSON-API"]
+ ep_path = f"{base_ep}/{model}"
+ eps = []
+
+ if "List Records" in operations:
+ eps.append({
+ "name": f"List {model}",
+ "method": "GET",
+ "path": ep_path,
+ "headers": {**headers, "Accept": ct},
+ "params": {
+ "page[number]": "1",
+ "page[size]": "10",
+ f"fields[{model}]": "id,name,display_name",
+ "sort": "id",
+ },
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _jsonapi_test(200),
+ "description": f"Fetch paginated list of {model} records with field selection, sorting and filtering.",
+ })
+
+ if "Get Single Record" in operations:
+ eps.append({
+ "name": f"Get {model} by ID",
+ "method": "GET",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers, "Accept": ct},
+ "params": {f"fields[{model}]": "id,name,display_name"},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _jsonapi_test(200),
+ "description": f"Fetch a single {model} record by its database ID.",
+ })
+
+ if "Create Record" in operations:
+ body = json.dumps({
+ "data": {
+ "type": model,
+ "attributes": {"name": f"New {model.split('.')[-1].replace('_', ' ').title()}"},
+ }
+ }, indent=2)
+ eps.append({
+ "name": f"Create {model}",
+ "method": "POST",
+ "path": ep_path,
+ "headers": {**headers, "Content-Type": ct, "Accept": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _jsonapi_test(201),
+ "description": f"Create a new {model} record.",
+ })
+
+ if "Update Record" in operations:
+ body = json.dumps({
+ "data": {
+ "type": model,
+ "id": "{{id}}",
+ "attributes": {"name": "Updated Name"},
+ }
+ }, indent=2)
+ eps.append({
+ "name": f"Update {model}",
+ "method": "PATCH",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers, "Content-Type": ct, "Accept": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _jsonapi_test(200),
+ "description": f"Update an existing {model} record by ID.",
+ })
+
+ if "Delete Record" in operations:
+ eps.append({
+ "name": f"Delete {model}",
+ "method": "DELETE",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers, "Accept": ct},
+ "params": {},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _jsonapi_test(200),
+ "description": f"Delete a {model} record by ID.",
+ })
+
+ if "Execute Method" in operations:
+ body = json.dumps({
+ "data": {
+ "type": model,
+ "id": "{{id}}",
+ "attributes": {
+ "method": "action_confirm",
+ "args": [],
+ "kwargs": {},
+ },
+ }
+ }, indent=2)
+ eps.append({
+ "name": f"Execute Method on {model}",
+ "method": "POST",
+ "path": f"{ep_path}/{{{{id}}}}/execute",
+ "headers": {**headers, "Content-Type": ct, "Accept": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"Execute an ORM method on a {model} record. Change 'action_confirm' to any valid method name.",
+ })
+
+ if "Export to Excel" in operations:
+ body = json.dumps({
+ "data": {
+ "type": model,
+ "attributes": {
+ "fields": ["id", "name", "display_name"],
+ "ids": [],
+ },
+ }
+ }, indent=2)
+ eps.append({
+ "name": f"Export {model} to Excel",
+ "method": "POST",
+ "path": f"{base_ep}/export",
+ "headers": {**headers, "Content-Type": ct, "Accept": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": "Returns a Base64-encoded Excel file with the specified fields.",
+ })
+
+ if "Generate Report" in operations:
+ body = json.dumps({
+ "data": {
+ "type": model,
+ "attributes": {
+ "report": f"{model.split('.')[0]}.report_{model.split('.')[-1]}",
+ "ids": ["{{id}}"],
+ "format": "pdf",
+ },
+ }
+ }, indent=2)
+ eps.append({
+ "name": f"Generate Report for {model}",
+ "method": "POST",
+ "path": f"{base_ep}/report",
+ "headers": {**headers, "Content-Type": ct, "Accept": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": "Generate a PDF/HTML/TEXT report. Change format to 'html' or 'text' as needed.",
+ })
+
+ if "Get Fields" in operations:
+ eps.append({
+ "name": f"Get Fields — {model}",
+ "method": "GET",
+ "path": f"{ep_path}/fields_get",
+ "headers": {**headers, "Accept": ct},
+ "params": {"attributes": "string,type,required,help"},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": f"Get all field definitions for {model}.",
+ })
+
+ if "Check Access Rights" in operations:
+ eps.append({
+ "name": f"Check Access — {model}",
+ "method": "GET",
+ "path": f"{ep_path}/check_access_rights",
+ "headers": {**headers, "Accept": ct},
+ "params": {"operation": "read"},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": "Check if the current user has the specified access right (read/write/create/unlink).",
+ })
+
+ return eps
+
+
+def _build_restjson_endpoints(base_ep: str, model: str, headers: dict,
+ operations: list[str]) -> list[dict]:
+ ct = _CT["REST JSON"]
+ ep_path = f"{base_ep}/{model}"
+ eps = []
+
+ if "List Records" in operations:
+ eps.append({
+ "name": f"List {model}",
+ "method": "GET",
+ "path": ep_path,
+ "headers": {**headers, "Accept": ct},
+ "params": {"page": "1", "limit": "10", "fields": "id,name,display_name", "sort": "id"},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": f"Fetch paginated list of {model} records.",
+ })
+
+ if "Get Single Record" in operations:
+ eps.append({
+ "name": f"Get {model} by ID",
+ "method": "GET",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers, "Accept": ct},
+ "params": {"fields": "id,name,display_name"},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": f"Fetch a single {model} record by ID.",
+ })
+
+ if "Create Record" in operations:
+ body = json.dumps({"name": f"New {model.split('.')[-1].title()}"}, indent=2)
+ eps.append({
+ "name": f"Create {model}",
+ "method": "POST",
+ "path": ep_path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(201),
+ "description": f"Create a new {model} record.",
+ })
+
+ if "Update Record" in operations:
+ body = json.dumps({"name": "Updated Name"}, indent=2)
+ eps.append({
+ "name": f"Update {model}",
+ "method": "PATCH",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"Update an existing {model} record.",
+ })
+
+ if "Delete Record" in operations:
+ eps.append({
+ "name": f"Delete {model}",
+ "method": "DELETE",
+ "path": f"{ep_path}/{{{{id}}}}",
+ "headers": {**headers},
+ "params": {},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": f"Delete a {model} record by ID.",
+ })
+
+ if "Execute Method" in operations:
+ body = json.dumps({"method": "action_confirm", "args": [], "kwargs": {}}, indent=2)
+ eps.append({
+ "name": f"Execute Method on {model}",
+ "method": "POST",
+ "path": f"{ep_path}/{{{{id}}}}/execute",
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": body,
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": "Execute an ORM method on a record.",
+ })
+
+ if "Get Fields" in operations:
+ eps.append({
+ "name": f"Get Fields — {model}",
+ "method": "GET",
+ "path": f"{ep_path}/fields_get",
+ "headers": {**headers},
+ "params": {},
+ "body": "",
+ "body_type": "raw",
+ "content_type": "",
+ "test_script": _rest_test(200),
+ "description": f"Get field definitions for {model}.",
+ })
+
+ return eps
+
+
+def _build_graphql_endpoints(base_ep: str, model: str, headers: dict,
+ operations: list[str]) -> list[dict]:
+ ct = _CT["GraphQL"]
+ gql = _model_gql(model)
+ path = f"{base_ep}/graphql"
+ eps = []
+
+ if "List Records" in operations:
+ query = (
+ f"query {{\n"
+ f" {gql}(\n"
+ f" filter: \"\"\n"
+ f" pageSize: 10\n"
+ f" pageNumber: 1\n"
+ f" ) {{\n"
+ f" id\n"
+ f" name\n"
+ f" display_name\n"
+ f" }}\n"
+ f"}}"
+ )
+ eps.append({
+ "name": f"GraphQL — List {model}",
+ "method": "POST",
+ "path": path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": json.dumps({"query": query}, indent=2),
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"GraphQL query to list {model} records.",
+ })
+
+ if "Get Single Record" in operations:
+ query = (
+ f"query {{\n"
+ f" {gql}(id: {{{{id}}}}) {{\n"
+ f" id\n"
+ f" name\n"
+ f" display_name\n"
+ f" }}\n"
+ f"}}"
+ )
+ eps.append({
+ "name": f"GraphQL — Get {model} by ID",
+ "method": "POST",
+ "path": path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": json.dumps({"query": query}, indent=2),
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"GraphQL query to get a single {model} record.",
+ })
+
+ if "Create Record" in operations:
+ mutation = (
+ f"mutation {{\n"
+ f" create_{gql}(\n"
+ f" attributes: {{\n"
+ f" name: \"New Record\"\n"
+ f" }}\n"
+ f" ) {{\n"
+ f" id\n"
+ f" name\n"
+ f" }}\n"
+ f"}}"
+ )
+ eps.append({
+ "name": f"GraphQL — Create {model}",
+ "method": "POST",
+ "path": path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": json.dumps({"query": mutation}, indent=2),
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"GraphQL mutation to create a {model} record.",
+ })
+
+ if "Update Record" in operations:
+ mutation = (
+ f"mutation {{\n"
+ f" update_{gql}(\n"
+ f" id: {{{{id}}}}\n"
+ f" attributes: {{\n"
+ f" name: \"Updated Name\"\n"
+ f" }}\n"
+ f" ) {{\n"
+ f" id\n"
+ f" name\n"
+ f" }}\n"
+ f"}}"
+ )
+ eps.append({
+ "name": f"GraphQL — Update {model}",
+ "method": "POST",
+ "path": path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": json.dumps({"query": mutation}, indent=2),
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"GraphQL mutation to update a {model} record.",
+ })
+
+ if "Delete Record" in operations:
+ mutation = (
+ f"mutation {{\n"
+ f" delete_{gql}(id: {{{{id}}}}) {{\n"
+ f" id\n"
+ f" }}\n"
+ f"}}"
+ )
+ eps.append({
+ "name": f"GraphQL — Delete {model}",
+ "method": "POST",
+ "path": path,
+ "headers": {**headers, "Content-Type": ct},
+ "params": {},
+ "body": json.dumps({"query": mutation}, indent=2),
+ "body_type": "raw",
+ "content_type": ct,
+ "test_script": _rest_test(200),
+ "description": f"GraphQL mutation to delete a {model} record.",
+ })
+
+ return eps
+
+
+# ── Main entry point ──────────────────────────────────────────────────────────
+
+def generate_collection(
+ instance_url: str,
+ endpoint: str,
+ api_kind: str,
+ auth_type: str,
+ auth_creds: dict,
+ models: list[str],
+ operations: list[str],
+ collection_name: str = "",
+) -> dict:
+ """
+ Generate an EKIKA API Framework collection dict ready for storage.
+
+ Returns same structure as ai_client.analyze_docs().
+ """
+ base_url = instance_url.rstrip("/")
+ base_ep = _clean_endpoint(endpoint)
+ headers = _auth_headers(auth_type)
+ env_vars = _env_vars(instance_url, auth_type, auth_creds)
+
+ all_endpoints = []
+ for model in models:
+ model = model.strip()
+ if not model:
+ continue
+ if api_kind == "JSON-API":
+ all_endpoints += _build_jsonapi_endpoints(base_ep, model, headers, operations)
+ elif api_kind == "REST JSON":
+ all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations)
+ elif api_kind == "GraphQL":
+ all_endpoints += _build_graphql_endpoints(base_ep, model, headers, operations)
+ else: # Custom REST JSON — same as REST JSON
+ all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations)
+
+ # Build URLs using {{base_url}} variable
+ for ep in all_endpoints:
+ if not ep["path"].startswith("http"):
+ ep["url"] = f"{{{{base_url}}}}{ep['path']}"
+
+ name = collection_name or f"EKIKA Odoo — {api_kind} — {', '.join(models[:3])}"
+
+ return {
+ "collection_name": name,
+ "base_url": base_url,
+ "auth_type": auth_type.lower().replace(" ", "_"),
+ "doc_type": "ekika_odoo_framework",
+ "endpoints": all_endpoints,
+ "environment_variables": env_vars,
+ "notes": (
+ f"API Kind: {api_kind} | Auth: {auth_type}\n"
+ f"Endpoint: {base_url}{base_ep}/{{model}}\n"
+ f"Replace {{{{id}}}} with actual record IDs before sending."
+ ),
+ "_source": "ekika_odoo",
+ }
diff --git a/app/core/exporter.py b/app/core/exporter.py
new file mode 100644
index 0000000..dcff792
--- /dev/null
+++ b/app/core/exporter.py
@@ -0,0 +1,66 @@
+"""Export collections to Postman Collection v2.1 JSON."""
+import json
+from app.models import HttpRequest
+from app.core import storage
+
+
+def export_collection(collection_id: int) -> str:
+ collections = storage.get_collections()
+ col = next((c for c in collections if c["id"] == collection_id), None)
+ if not col:
+ raise ValueError(f"Collection {collection_id} not found")
+
+ items = []
+
+ def make_request_item(r: dict) -> dict:
+ header = [{"key": k, "value": v} for k, v in r.get("headers", {}).items()]
+ url_raw = r.get("url", "")
+ params = r.get("params", {})
+ query = [{"key": k, "value": v} for k, v in params.items()]
+
+ body_obj = None
+ body = r.get("body", "")
+ body_type = r.get("body_type", "raw")
+ if body:
+ if body_type == "urlencoded":
+ pairs = []
+ for line in body.split("&"):
+ if "=" in line:
+ k, _, v = line.partition("=")
+ pairs.append({"key": k, "value": v})
+ body_obj = {"mode": "urlencoded", "urlencoded": pairs}
+ else:
+ body_obj = {"mode": "raw", "raw": body}
+
+ item = {
+ "name": r.get("name") or url_raw,
+ "request": {
+ "method": r.get("method", "GET"),
+ "header": header,
+ "url": {"raw": url_raw, "query": query},
+ }
+ }
+ if body_obj:
+ item["request"]["body"] = body_obj
+ return item
+
+ # Top-level requests (no folder)
+ for r in storage.get_requests(collection_id):
+ items.append(make_request_item(r))
+
+ # Folders
+ for folder in storage.get_folders(collection_id):
+ folder_item = {
+ "name": folder["name"],
+ "item": [make_request_item(r) for r in storage.get_requests(collection_id, folder["id"])]
+ }
+ items.append(folder_item)
+
+ collection = {
+ "info": {
+ "name": col["name"],
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "item": items
+ }
+ return json.dumps(collection, indent=2)
diff --git a/app/core/http_client.py b/app/core/http_client.py
new file mode 100644
index 0000000..8841f7b
--- /dev/null
+++ b/app/core/http_client.py
@@ -0,0 +1,161 @@
+"""APIClient - Agent — HTTP client engine."""
+import re
+import base64
+from copy import deepcopy
+
+import httpx
+
+from app.models import HttpRequest, HttpResponse
+
+
+def resolve_variables(text: str, variables: dict) -> str:
+ """Replace {{variable}} placeholders with environment values."""
+ if not text or not variables:
+ return text
+
+ def replacer(m):
+ key = m.group(1).strip()
+ return str(variables.get(key, m.group(0)))
+
+ return re.sub(r"\{\{(.+?)\}\}", replacer, text)
+
+
+def apply_variables(req: HttpRequest, variables: dict) -> HttpRequest:
+ """Return a deep copy of the request with all variables resolved."""
+ r = deepcopy(req)
+ r.url = resolve_variables(r.url, variables)
+ r.body = resolve_variables(r.body, variables)
+ r.headers = {k: resolve_variables(v, variables) for k, v in r.headers.items()}
+ r.params = {k: resolve_variables(v, variables) for k, v in r.params.items()}
+ if r.auth_data:
+ r.auth_data = {k: resolve_variables(str(v), variables) for k, v in r.auth_data.items()}
+ return r
+
+
+def _build_auth_headers(req: HttpRequest) -> dict:
+ headers = {}
+ if req.auth_type == "bearer":
+ token = req.auth_data.get("token", "")
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ elif req.auth_type == "basic":
+ user = req.auth_data.get("username", "")
+ pwd = req.auth_data.get("password", "")
+ encoded = base64.b64encode(f"{user}:{pwd}".encode()).decode()
+ headers["Authorization"] = f"Basic {encoded}"
+ elif req.auth_type == "apikey":
+ key = req.auth_data.get("key", "")
+ value = req.auth_data.get("value", "")
+ location = req.auth_data.get("in", "header")
+ if location == "header" and key:
+ headers[key] = value
+ return headers
+
+
+def send_request(req: HttpRequest, variables: dict = None) -> HttpResponse:
+ r = req # will be overwritten with resolved copy; kept here for exception handlers
+ try:
+ r = apply_variables(req, variables or {})
+
+ # Check for unresolved variables
+ unresolved = re.findall(r"\{\{(.+?)\}\}", r.url)
+ if unresolved:
+ return HttpResponse(
+ error=f"Unresolved variable(s): {', '.join(unresolved)}. "
+ "Go to Tools → Environments to define them."
+ )
+
+ headers = {**r.headers, **_build_auth_headers(r)}
+
+ # Query params (merge URL params dict + API-key-in-query)
+ params = r.params.copy()
+ if r.auth_type == "apikey" and r.auth_data.get("in") == "query":
+ params[r.auth_data.get("key", "")] = r.auth_data.get("value", "")
+
+ # Build request body
+ content = None
+ data = None
+ files = None
+
+ if r.body_type == "raw" and r.body:
+ content = r.body.encode()
+ # Auto Content-Type: honour explicit override, then try to detect JSON
+ if r.content_type:
+ headers.setdefault("Content-Type", r.content_type)
+ elif "Content-Type" not in headers:
+ stripped = r.body.lstrip()
+ if stripped.startswith(("{", "[")):
+ headers["Content-Type"] = "application/json"
+ else:
+ headers["Content-Type"] = "text/plain"
+
+ elif r.body_type == "urlencoded" and r.body:
+ pairs = {}
+ for line in r.body.splitlines():
+ if "=" in line:
+ k, _, v = line.partition("=")
+ pairs[k.strip()] = v.strip()
+ data = pairs
+
+ elif r.body_type == "form-data" and r.body:
+ # Expect "key=value" lines; values starting with "@" treated as file paths
+ pairs = {}
+ for line in r.body.splitlines():
+ if "=" in line:
+ k, _, v = line.partition("=")
+ pairs[k.strip()] = v.strip()
+ data = pairs
+
+ timeout = httpx.Timeout(
+ connect=10.0,
+ read=float(r.timeout),
+ write=float(r.timeout),
+ pool=5.0,
+ )
+
+ with httpx.Client(
+ follow_redirects=True,
+ timeout=timeout,
+ verify=r.ssl_verify,
+ ) as client:
+ response = client.request(
+ method=r.method,
+ url=r.url,
+ headers=headers,
+ params=params or None,
+ content=content,
+ data=data,
+ files=files,
+ )
+ body = response.text
+ size_bytes = len(response.content)
+ return HttpResponse(
+ status=response.status_code,
+ reason=response.reason_phrase,
+ headers=dict(response.headers),
+ body=body,
+ elapsed_ms=response.elapsed.total_seconds() * 1000,
+ size_bytes=size_bytes,
+ )
+
+ except httpx.InvalidURL:
+ return HttpResponse(error=f"Invalid URL: {r.url}")
+ except httpx.ConnectError as e:
+ detail = str(e)
+ if "CERTIFICATE_VERIFY_FAILED" in detail or "certificate" in detail.lower() or "SSL" in detail:
+ return HttpResponse(error=(
+ f"SSL certificate error — could not connect to {r.url}\n\n"
+ f"The server's certificate is not trusted or doesn't match the hostname.\n"
+ f"Tip: disable SSL verification in the request Settings tab."
+ ))
+ return HttpResponse(error=f"Connection refused — could not reach {r.url}")
+ except httpx.ConnectTimeout:
+ return HttpResponse(error=f"Connection timed out after {req.timeout}s")
+ except httpx.ReadTimeout:
+ return HttpResponse(error=f"Read timed out — server took too long to respond")
+ except httpx.SSLError as e:
+ return HttpResponse(error=f"SSL error: {e}. Disable SSL verification if using a self-signed cert.")
+ except httpx.TooManyRedirects:
+ return HttpResponse(error="Too many redirects — possible redirect loop")
+ except Exception as e:
+ return HttpResponse(error=str(e))
diff --git a/app/core/importer.py b/app/core/importer.py
new file mode 100644
index 0000000..a3de60c
--- /dev/null
+++ b/app/core/importer.py
@@ -0,0 +1,103 @@
+"""Import from Postman Collection v2.1 JSON or curl command."""
+import json
+import re
+import shlex
+from app.models import HttpRequest
+
+
+def from_postman_collection(json_text: str) -> tuple[str, list[HttpRequest]]:
+ """Returns (collection_name, list of HttpRequest)."""
+ data = json.loads(json_text)
+ name = data.get("info", {}).get("name", "Imported Collection")
+ requests = []
+
+ def parse_item(item):
+ if "request" in item:
+ r = item["request"]
+ method = r.get("method", "GET")
+ url_obj = r.get("url", {})
+ if isinstance(url_obj, str):
+ url = url_obj
+ params = {}
+ else:
+ raw = url_obj.get("raw", "")
+ url = raw.split("?")[0] if "?" in raw else raw
+ params = {}
+ for qp in url_obj.get("query", []):
+ if not qp.get("disabled"):
+ params[qp.get("key", "")] = qp.get("value", "")
+
+ headers = {}
+ for h in r.get("header", []):
+ if not h.get("disabled"):
+ headers[h.get("key", "")] = h.get("value", "")
+
+ body_obj = r.get("body", {})
+ body = ""
+ body_type = "raw"
+ if body_obj:
+ mode = body_obj.get("mode", "raw")
+ if mode == "raw":
+ body = body_obj.get("raw", "")
+ body_type = "raw"
+ elif mode == "urlencoded":
+ pairs = body_obj.get("urlencoded", [])
+ body = "&".join(f"{p['key']}={p.get('value','')}" for p in pairs if not p.get("disabled"))
+ body_type = "urlencoded"
+
+ requests.append(HttpRequest(
+ method=method, url=url, headers=headers,
+ params=params, body=body, body_type=body_type,
+ name=item.get("name", "")
+ ))
+ elif "item" in item:
+ for sub in item["item"]:
+ parse_item(sub)
+
+ for item in data.get("item", []):
+ parse_item(item)
+
+ return name, requests
+
+
+def from_curl(curl_cmd: str) -> HttpRequest:
+ """Parse a curl command string into an HttpRequest."""
+ # Normalize line continuations
+ cmd = curl_cmd.replace("\\\n", " ").strip()
+ try:
+ tokens = shlex.split(cmd)
+ except ValueError:
+ tokens = cmd.split()
+
+ req = HttpRequest(method="GET")
+ i = 1 # skip 'curl'
+ while i < len(tokens):
+ token = tokens[i]
+ if token in ("-X", "--request") and i + 1 < len(tokens):
+ req.method = tokens[i + 1].upper()
+ i += 2
+ elif token in ("-H", "--header") and i + 1 < len(tokens):
+ header = tokens[i + 1]
+ if ":" in header:
+ k, _, v = header.partition(":")
+ req.headers[k.strip()] = v.strip()
+ i += 2
+ elif token in ("-d", "--data", "--data-raw", "--data-binary") and i + 1 < len(tokens):
+ req.body = tokens[i + 1]
+ if req.method == "GET":
+ req.method = "POST"
+ i += 2
+ elif token in ("-u", "--user") and i + 1 < len(tokens):
+ user_pass = tokens[i + 1]
+ if ":" in user_pass:
+ u, _, p = user_pass.partition(":")
+ req.auth_type = "basic"
+ req.auth_data = {"username": u, "password": p}
+ i += 2
+ elif not token.startswith("-") and not req.url:
+ req.url = token.strip("'\"")
+ i += 1
+ else:
+ i += 1
+
+ return req
diff --git a/app/core/mock_server.py b/app/core/mock_server.py
new file mode 100644
index 0000000..b648338
--- /dev/null
+++ b/app/core/mock_server.py
@@ -0,0 +1,82 @@
+"""APIClient - Agent — Lightweight HTTP mock server."""
+import threading
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+from app.core import storage
+
+
+_server_instance: HTTPServer | None = None
+_server_thread: threading.Thread | None = None
+_server_port: int = 8888
+
+
+class _MockHandler(BaseHTTPRequestHandler):
+ """Queries the DB on every request so new/edited endpoints are served immediately."""
+
+ def log_message(self, fmt, *args):
+ pass # suppress default console logging
+
+ def _handle(self):
+ endpoints = storage.get_mock_endpoints()
+ matched = None
+ for ep in endpoints:
+ method_match = ep.method == "*" or ep.method == self.command
+ if method_match and ep.path == self.path:
+ matched = ep
+ break
+
+ if matched:
+ self.send_response(matched.status_code)
+ for k, v in matched.response_headers.items():
+ self.send_header(k, v)
+ body = matched.response_body.encode("utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ else:
+ body = b'{"error": "No mock endpoint matched"}'
+ self.send_response(404)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ do_GET = _handle
+ do_POST = _handle
+ do_PUT = _handle
+ do_PATCH = _handle
+ do_DELETE = _handle
+ do_HEAD = _handle
+ do_OPTIONS = _handle
+
+
+def start(port: int = 8888) -> str:
+ global _server_instance, _server_thread, _server_port
+ if _server_instance:
+ return f"Already running on port {_server_port}"
+ _server_port = port
+ try:
+ _server_instance = HTTPServer(("localhost", port), _MockHandler)
+ except OSError as e:
+ return f"Failed to start: {e}"
+ _server_thread = threading.Thread(target=_server_instance.serve_forever, daemon=True)
+ _server_thread.start()
+ return f"Mock server running on http://localhost:{port}"
+
+
+def stop() -> str:
+ global _server_instance, _server_thread
+ if _server_instance:
+ _server_instance.shutdown()
+ _server_instance = None
+ _server_thread = None
+ return "Mock server stopped"
+ return "Not running"
+
+
+def is_running() -> bool:
+ return _server_instance is not None
+
+
+def get_port() -> int:
+ return _server_port
diff --git a/app/core/openapi_parser.py b/app/core/openapi_parser.py
new file mode 100644
index 0000000..d1f378b
--- /dev/null
+++ b/app/core/openapi_parser.py
@@ -0,0 +1,236 @@
+"""APIClient - Agent — OpenAPI / Swagger spec parser.
+
+Parses OpenAPI 3.x and Swagger 2.0 specs (JSON or YAML) directly,
+without needing AI tokens.
+"""
+import json
+import re
+
+
+def _try_yaml(text: str) -> dict | None:
+ try:
+ import yaml
+ return yaml.safe_load(text)
+ except Exception:
+ return None
+
+
+def _try_json(text: str) -> dict | None:
+ try:
+ return json.loads(text)
+ except Exception:
+ return None
+
+
+def detect_spec(text: str) -> dict | None:
+ """Try to parse text as OpenAPI/Swagger JSON or YAML. Returns raw dict or None."""
+ data = _try_json(text) or _try_yaml(text)
+ if not isinstance(data, dict):
+ return None
+ if "openapi" in data or "swagger" in data:
+ return data
+ return None
+
+
+def parse_spec(data: dict) -> dict:
+ """
+ Parse an OpenAPI 3.x or Swagger 2.0 spec dict into EKIKA's internal format:
+ {
+ "collection_name": str,
+ "base_url": str,
+ "auth_type": str,
+ "endpoints": [...],
+ "environment_variables": {...}
+ }
+ """
+ version = str(data.get("openapi", data.get("swagger", "2")))
+ is_v3 = version.startswith("3")
+
+ # Collection name
+ info = data.get("info", {})
+ collection_name = info.get("title", "Imported API")
+
+ # Base URL
+ if is_v3:
+ servers = data.get("servers", [])
+ base_url = servers[0].get("url", "") if servers else ""
+ else:
+ host = data.get("host", "")
+ schemes = data.get("schemes", ["https"])
+ base_p = data.get("basePath", "/")
+ base_url = f"{schemes[0]}://{host}{base_p}" if host else ""
+
+ # Clean trailing slash
+ base_url = base_url.rstrip("/")
+
+ # Auth detection
+ security_schemes = {}
+ if is_v3:
+ security_schemes = data.get("components", {}).get("securitySchemes", {})
+ else:
+ security_schemes = data.get("securityDefinitions", {})
+
+ auth_type = "none"
+ for scheme in security_schemes.values():
+ t = scheme.get("type", "").lower()
+ if t in ("http", "bearer") or scheme.get("scheme", "").lower() == "bearer":
+ auth_type = "bearer"
+ break
+ if t == "apikey":
+ auth_type = "apikey"
+ break
+ if t in ("basic", "http") and scheme.get("scheme", "").lower() == "basic":
+ auth_type = "basic"
+ break
+
+ # Endpoints
+ endpoints = []
+ paths = data.get("paths", {})
+
+ for path, path_item in paths.items():
+ if not isinstance(path_item, dict):
+ continue
+ for method in ("get", "post", "put", "patch", "delete", "head", "options"):
+ op = path_item.get(method)
+ if not isinstance(op, dict):
+ continue
+
+ name = op.get("summary") or op.get("operationId") or f"{method.upper()} {path}"
+ description = op.get("description", "")
+
+ # Headers
+ headers: dict = {}
+
+ # Query params
+ params: dict = {}
+ body_example = ""
+ content_type = "application/json"
+ body_type = "raw"
+
+ # Parameters
+ for param in op.get("parameters", []):
+ if not isinstance(param, dict):
+ continue
+ p_in = param.get("in", "")
+ p_name = param.get("name", "")
+ if p_in == "query":
+ params[p_name] = param.get("example", "")
+ elif p_in == "header":
+ headers[p_name] = param.get("example", "")
+
+ # Request body (OpenAPI 3)
+ if is_v3 and "requestBody" in op:
+ rb = op["requestBody"]
+ content = rb.get("content", {})
+ if "application/json" in content:
+ schema = content["application/json"].get("schema", {})
+ body_example = _schema_to_example_str(schema)
+ content_type = "application/json"
+ elif "application/x-www-form-urlencoded" in content:
+ body_type = "form-urlencoded"
+ content_type = ""
+ elif content:
+ first_ct = next(iter(content))
+ content_type = first_ct
+
+ # Request body (Swagger 2)
+ if not is_v3:
+ consumes = op.get("consumes", data.get("consumes", ["application/json"]))
+ for param in op.get("parameters", []):
+ if param.get("in") == "body":
+ schema = param.get("schema", {})
+ body_example = _schema_to_example_str(schema)
+ if consumes:
+ content_type = consumes[0]
+
+ # Add auth header hint
+ if auth_type == "bearer":
+ headers.setdefault("Authorization", "Bearer {{token}}")
+ elif auth_type == "apikey":
+ headers.setdefault("X-API-Key", "{{api_key}}")
+
+ # Basic test script
+ test_script = (
+ f"pm.test('Status OK', lambda: pm.response.to_have_status(200))\n"
+ f"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())"
+ )
+
+ endpoints.append({
+ "name": name,
+ "method": method.upper(),
+ "path": path,
+ "description": description,
+ "headers": headers,
+ "params": params,
+ "body": body_example,
+ "body_type": body_type,
+ "content_type": content_type,
+ "test_script": test_script,
+ })
+
+ # Environment variables
+ env_vars: dict = {}
+ if base_url:
+ env_vars["base_url"] = base_url
+ if auth_type == "bearer":
+ env_vars["token"] = ""
+ elif auth_type == "apikey":
+ env_vars["api_key"] = ""
+ elif auth_type == "basic":
+ env_vars["username"] = ""
+ env_vars["password"] = ""
+
+ return {
+ "collection_name": collection_name,
+ "base_url": base_url,
+ "auth_type": auth_type,
+ "endpoints": endpoints,
+ "environment_variables": env_vars,
+ }
+
+
+def _schema_to_example_str(schema: dict) -> str:
+ """Generate a compact JSON example string from an OpenAPI schema."""
+ try:
+ example = _schema_to_example(schema)
+ return json.dumps(example, indent=2, ensure_ascii=False)
+ except Exception:
+ return ""
+
+
+def _schema_to_example(schema: dict, depth: int = 0) -> object:
+ if depth > 5:
+ return {}
+ if not isinstance(schema, dict):
+ return {}
+
+ # Use provided example first
+ if "example" in schema:
+ return schema["example"]
+ if "default" in schema:
+ return schema["default"]
+
+ t = schema.get("type", "object")
+
+ if t == "object" or "properties" in schema:
+ result = {}
+ for k, v in schema.get("properties", {}).items():
+ result[k] = _schema_to_example(v, depth + 1)
+ return result
+
+ if t == "array":
+ items = schema.get("items", {})
+ return [_schema_to_example(items, depth + 1)]
+
+ if t == "string":
+ fmt = schema.get("format", "")
+ if fmt == "date-time": return "2024-01-01T00:00:00Z"
+ if fmt == "date": return "2024-01-01"
+ if fmt == "email": return "user@example.com"
+ if fmt == "uuid": return "00000000-0000-0000-0000-000000000000"
+ return schema.get("enum", ["string"])[0]
+
+ if t == "integer": return 0
+ if t == "number": return 0.0
+ if t == "boolean": return True
+ return {}
diff --git a/app/core/storage.py b/app/core/storage.py
new file mode 100644
index 0000000..80b06a3
--- /dev/null
+++ b/app/core/storage.py
@@ -0,0 +1,436 @@
+"""APIClient - Agent — Storage layer (SQLite)."""
+import json
+import sqlite3
+from pathlib import Path
+from app.models import HttpRequest, Environment, MockEndpoint
+
+DB_PATH = Path.home() / ".ekika-api-client" / "data.db"
+
+
+def _get_conn() -> sqlite3.Connection:
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ return conn
+
+
+def _migrate(conn: sqlite3.Connection):
+ """Add columns/tables introduced after initial schema."""
+ existing = {row[1] for row in conn.execute("PRAGMA table_info(requests)")}
+ migrations = [
+ ("folder_id", "ALTER TABLE requests ADD COLUMN folder_id INTEGER"),
+ ("params", "ALTER TABLE requests ADD COLUMN params TEXT"),
+ ("body_type", "ALTER TABLE requests ADD COLUMN body_type TEXT DEFAULT 'raw'"),
+ ("auth_type", "ALTER TABLE requests ADD COLUMN auth_type TEXT DEFAULT 'none'"),
+ ("auth_data", "ALTER TABLE requests ADD COLUMN auth_data TEXT"),
+ ("pre_request_script", "ALTER TABLE requests ADD COLUMN pre_request_script TEXT"),
+ ("test_script", "ALTER TABLE requests ADD COLUMN test_script TEXT"),
+ ("timeout", "ALTER TABLE requests ADD COLUMN timeout INTEGER DEFAULT 30"),
+ ("ssl_verify", "ALTER TABLE requests ADD COLUMN ssl_verify INTEGER DEFAULT 1"),
+ ("content_type", "ALTER TABLE requests ADD COLUMN content_type TEXT"),
+ ("created_at", "ALTER TABLE requests ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP"),
+ ]
+ for col, sql in migrations:
+ if col not in existing:
+ conn.execute(sql)
+
+ hist_cols = {row[1] for row in conn.execute("PRAGMA table_info(history)")}
+ hist_migrations = [
+ ("params", "ALTER TABLE history ADD COLUMN params TEXT"),
+ ("body_type", "ALTER TABLE history ADD COLUMN body_type TEXT"),
+ ("auth_type", "ALTER TABLE history ADD COLUMN auth_type TEXT"),
+ ("auth_data", "ALTER TABLE history ADD COLUMN auth_data TEXT"),
+ ("timeout", "ALTER TABLE history ADD COLUMN timeout INTEGER DEFAULT 30"),
+ ("ssl_verify","ALTER TABLE history ADD COLUMN ssl_verify INTEGER DEFAULT 1"),
+ ]
+ for col, sql in hist_migrations:
+ if col not in hist_cols:
+ conn.execute(sql)
+
+
+def init_db():
+ with _get_conn() as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS collections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ FOREIGN KEY (collection_id) REFERENCES collections(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS requests (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ collection_id INTEGER,
+ folder_id INTEGER,
+ name TEXT,
+ method TEXT,
+ url TEXT,
+ headers TEXT,
+ params TEXT,
+ body TEXT,
+ body_type TEXT DEFAULT 'raw',
+ content_type TEXT,
+ auth_type TEXT DEFAULT 'none',
+ auth_data TEXT,
+ pre_request_script TEXT,
+ test_script TEXT,
+ timeout INTEGER DEFAULT 30,
+ ssl_verify INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (collection_id) REFERENCES collections(id),
+ FOREIGN KEY (folder_id) REFERENCES folders(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ method TEXT,
+ url TEXT,
+ headers TEXT,
+ params TEXT,
+ body TEXT,
+ body_type TEXT,
+ auth_type TEXT,
+ auth_data TEXT,
+ timeout INTEGER DEFAULT 30,
+ ssl_verify INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS environments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ variables TEXT DEFAULT '{}',
+ is_active INTEGER DEFAULT 0
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS mock_endpoints (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT,
+ method TEXT DEFAULT 'GET',
+ path TEXT NOT NULL,
+ status_code INTEGER DEFAULT 200,
+ response_headers TEXT DEFAULT '{}',
+ response_body TEXT DEFAULT ''
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ )
+ """)
+
+ # Performance indexes
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_collection ON requests(collection_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_folder ON requests(folder_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at DESC)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_url ON requests(url)")
+
+ _migrate(conn)
+
+
+# ── Collections ──────────────────────────────────────────────────────────────
+
+def get_collections() -> list[dict]:
+ with _get_conn() as conn:
+ rows = conn.execute("SELECT * FROM collections ORDER BY name COLLATE NOCASE").fetchall()
+ return [dict(r) for r in rows]
+
+
+def add_collection(name: str) -> int:
+ with _get_conn() as conn:
+ cur = conn.execute("INSERT INTO collections (name) VALUES (?)", (name,))
+ return cur.lastrowid
+
+
+def rename_collection(col_id: int, name: str):
+ with _get_conn() as conn:
+ conn.execute("UPDATE collections SET name=? WHERE id=?", (name, col_id))
+
+
+def delete_collection(col_id: int):
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM requests WHERE collection_id=?", (col_id,))
+ conn.execute("DELETE FROM folders WHERE collection_id=?", (col_id,))
+ conn.execute("DELETE FROM collections WHERE id=?", (col_id,))
+
+
+# ── Folders ───────────────────────────────────────────────────────────────────
+
+def get_folders(collection_id: int) -> list[dict]:
+ with _get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM folders WHERE collection_id=? ORDER BY name COLLATE NOCASE",
+ (collection_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+def add_folder(collection_id: int, name: str) -> int:
+ with _get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO folders (collection_id, name) VALUES (?,?)", (collection_id, name)
+ )
+ return cur.lastrowid
+
+
+def rename_folder(folder_id: int, name: str):
+ with _get_conn() as conn:
+ conn.execute("UPDATE folders SET name=? WHERE id=?", (name, folder_id))
+
+
+def delete_folder(folder_id: int):
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM requests WHERE folder_id=?", (folder_id,))
+ conn.execute("DELETE FROM folders WHERE id=?", (folder_id,))
+
+
+# ── Requests ──────────────────────────────────────────────────────────────────
+
+def _deserialize_request(r) -> dict:
+ d = dict(r)
+ d["headers"] = json.loads(d.get("headers") or "{}")
+ d["params"] = json.loads(d.get("params") or "{}")
+ d["auth_data"]= json.loads(d.get("auth_data")or "{}")
+ return d
+
+
+def get_requests(collection_id: int, folder_id: int = None) -> list[dict]:
+ with _get_conn() as conn:
+ if folder_id is not None:
+ rows = conn.execute(
+ "SELECT * FROM requests WHERE collection_id=? AND folder_id=? ORDER BY name COLLATE NOCASE",
+ (collection_id, folder_id)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM requests WHERE collection_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE",
+ (collection_id,)
+ ).fetchall()
+ return [_deserialize_request(r) for r in rows]
+
+
+def get_all_requests(collection_id: int) -> list[dict]:
+ with _get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM requests WHERE collection_id=? ORDER BY name COLLATE NOCASE",
+ (collection_id,)
+ ).fetchall()
+ return [_deserialize_request(r) for r in rows]
+
+
+def save_request(collection_id: int, req: HttpRequest, folder_id: int = None) -> int:
+ with _get_conn() as conn:
+ cur = conn.execute(
+ """INSERT INTO requests
+ (collection_id, folder_id, name, method, url, headers, params,
+ body, body_type, content_type, auth_type, auth_data,
+ pre_request_script, test_script, timeout, ssl_verify)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (collection_id, folder_id, req.name, req.method, req.url,
+ json.dumps(req.headers), json.dumps(req.params),
+ req.body, req.body_type, req.content_type,
+ req.auth_type, json.dumps(req.auth_data),
+ req.pre_request_script, req.test_script,
+ req.timeout, int(req.ssl_verify)),
+ )
+ return cur.lastrowid
+
+
+def update_request(req_id: int, req: HttpRequest):
+ with _get_conn() as conn:
+ conn.execute(
+ """UPDATE requests SET
+ name=?, method=?, url=?, headers=?, params=?,
+ body=?, body_type=?, content_type=?, auth_type=?, auth_data=?,
+ pre_request_script=?, test_script=?, timeout=?, ssl_verify=?
+ WHERE id=?""",
+ (req.name, req.method, req.url,
+ json.dumps(req.headers), json.dumps(req.params),
+ req.body, req.body_type, req.content_type,
+ req.auth_type, json.dumps(req.auth_data),
+ req.pre_request_script, req.test_script,
+ req.timeout, int(req.ssl_verify), req_id),
+ )
+
+
+def delete_request(req_id: int):
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM requests WHERE id=?", (req_id,))
+
+
+def search_requests(query: str) -> list[dict]:
+ with _get_conn() as conn:
+ like = f"%{query}%"
+ rows = conn.execute(
+ """SELECT r.*, c.name as collection_name
+ FROM requests r
+ LEFT JOIN collections c ON r.collection_id = c.id
+ WHERE r.name LIKE ? OR r.url LIKE ?
+ ORDER BY r.name COLLATE NOCASE""",
+ (like, like)
+ ).fetchall()
+ return [_deserialize_request(r) for r in rows]
+
+
+# ── History ───────────────────────────────────────────────────────────────────
+
+def add_to_history(req: HttpRequest):
+ with _get_conn() as conn:
+ # Trim history to 200 entries
+ conn.execute(
+ """DELETE FROM history WHERE id NOT IN (
+ SELECT id FROM history ORDER BY created_at DESC LIMIT 199
+ )"""
+ )
+ conn.execute(
+ """INSERT INTO history
+ (method, url, headers, params, body, body_type, auth_type, auth_data, timeout, ssl_verify)
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
+ (req.method, req.url,
+ json.dumps(req.headers), json.dumps(req.params),
+ req.body, req.body_type, req.auth_type, json.dumps(req.auth_data),
+ req.timeout, int(req.ssl_verify)),
+ )
+
+
+def get_history(limit: int = 50) -> list[dict]:
+ with _get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM history ORDER BY created_at DESC LIMIT ?", (limit,)
+ ).fetchall()
+ return [_deserialize_request(r) for r in rows]
+
+
+def clear_history():
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM history")
+
+
+# ── Environments ──────────────────────────────────────────────────────────────
+
+def get_environments() -> list[Environment]:
+ with _get_conn() as conn:
+ rows = conn.execute("SELECT * FROM environments ORDER BY name COLLATE NOCASE").fetchall()
+ return [
+ Environment(
+ id=r["id"], name=r["name"],
+ variables=json.loads(r["variables"] or "{}"),
+ is_active=bool(r["is_active"])
+ )
+ for r in rows
+ ]
+
+
+def get_active_environment() -> Environment | None:
+ with _get_conn() as conn:
+ row = conn.execute("SELECT * FROM environments WHERE is_active=1").fetchone()
+ if row:
+ return Environment(
+ id=row["id"], name=row["name"],
+ variables=json.loads(row["variables"] or "{}"),
+ is_active=True
+ )
+ return None
+
+
+def save_environment(env: Environment) -> int:
+ with _get_conn() as conn:
+ if env.id:
+ conn.execute(
+ "UPDATE environments SET name=?, variables=? WHERE id=?",
+ (env.name, json.dumps(env.variables), env.id)
+ )
+ return env.id
+ else:
+ cur = conn.execute(
+ "INSERT INTO environments (name, variables, is_active) VALUES (?,?,?)",
+ (env.name, json.dumps(env.variables), int(env.is_active))
+ )
+ return cur.lastrowid
+
+
+def set_active_environment(env_id: int | None):
+ with _get_conn() as conn:
+ conn.execute("UPDATE environments SET is_active=0")
+ if env_id:
+ conn.execute("UPDATE environments SET is_active=1 WHERE id=?", (env_id,))
+
+
+def delete_environment(env_id: int):
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM environments WHERE id=?", (env_id,))
+
+
+# ── Mock Endpoints ────────────────────────────────────────────────────────────
+
+def get_mock_endpoints() -> list[MockEndpoint]:
+ with _get_conn() as conn:
+ rows = conn.execute("SELECT * FROM mock_endpoints ORDER BY path").fetchall()
+ return [
+ MockEndpoint(
+ id=r["id"], name=r["name"], method=r["method"],
+ path=r["path"], status_code=r["status_code"],
+ response_headers=json.loads(r["response_headers"] or "{}"),
+ response_body=r["response_body"] or ""
+ )
+ for r in rows
+ ]
+
+
+def save_mock_endpoint(ep: MockEndpoint) -> int:
+ with _get_conn() as conn:
+ if ep.id:
+ conn.execute(
+ """UPDATE mock_endpoints
+ SET name=?, method=?, path=?, status_code=?, response_headers=?, response_body=?
+ WHERE id=?""",
+ (ep.name, ep.method, ep.path, ep.status_code,
+ json.dumps(ep.response_headers), ep.response_body, ep.id)
+ )
+ return ep.id
+ else:
+ cur = conn.execute(
+ """INSERT INTO mock_endpoints
+ (name, method, path, status_code, response_headers, response_body)
+ VALUES (?,?,?,?,?,?)""",
+ (ep.name, ep.method, ep.path, ep.status_code,
+ json.dumps(ep.response_headers), ep.response_body)
+ )
+ return cur.lastrowid
+
+
+def delete_mock_endpoint(ep_id: int):
+ with _get_conn() as conn:
+ conn.execute("DELETE FROM mock_endpoints WHERE id=?", (ep_id,))
+
+
+# ── Settings ──────────────────────────────────────────────────────────────────
+
+def get_setting(key: str, default: str = "") -> str:
+ with _get_conn() as conn:
+ row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
+ return row["value"] if row else default
+
+
+def set_setting(key: str, value: str):
+ with _get_conn() as conn:
+ conn.execute(
+ "INSERT INTO settings (key, value) VALUES (?,?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ (key, value)
+ )
diff --git a/app/core/test_runner.py b/app/core/test_runner.py
new file mode 100644
index 0000000..d5d376f
--- /dev/null
+++ b/app/core/test_runner.py
@@ -0,0 +1,86 @@
+"""Run test scripts after a response is received."""
+import json
+from app.models import HttpResponse, TestResult
+
+
+class TestContext:
+ """Exposes pm.test(), pm.expect(), pm.response in test scripts."""
+
+ def __init__(self, response: HttpResponse):
+ self.results: list[TestResult] = []
+ self.response = _ResponseProxy(response)
+ self.expect = _expect
+
+ def test(self, name: str, fn):
+ try:
+ fn()
+ self.results.append(TestResult(name=name, passed=True))
+ except AssertionError as e:
+ self.results.append(TestResult(name=name, passed=False, message=str(e)))
+ except Exception as e:
+ self.results.append(TestResult(name=name, passed=False, message=f"Error: {e}"))
+
+
+class _ResponseProxy:
+ def __init__(self, resp: HttpResponse):
+ self._resp = resp
+ self.status = resp.status
+ self.responseTime = resp.elapsed_ms
+ self.text = resp.body
+ try:
+ self._json = json.loads(resp.body)
+ except Exception:
+ self._json = None
+
+ def json(self):
+ return self._json
+
+ def to_have_status(self, code: int):
+ assert self._resp.status == code, f"Expected status {code}, got {self._resp.status}"
+
+
+class _Assertion:
+ def __init__(self, value):
+ self._value = value
+
+ def to_equal(self, expected):
+ assert self._value == expected, f"Expected {expected!r}, got {self._value!r}"
+ return self
+
+ def to_be_truthy(self):
+ assert self._value, f"Expected truthy, got {self._value!r}"
+ return self
+
+ def to_include(self, substr):
+ assert substr in str(self._value), f"Expected {substr!r} in {self._value!r}"
+ return self
+
+ def to_be_below(self, n):
+ assert self._value < n, f"Expected {self._value} < {n}"
+ return self
+
+ def to_have_property(self, key):
+ assert hasattr(self._value, key) or (isinstance(self._value, dict) and key in self._value), \
+ f"Expected property {key!r}"
+ return self
+
+
+def _expect(value) -> _Assertion:
+ return _Assertion(value)
+
+
+def run_tests(script: str, response: HttpResponse) -> list[TestResult]:
+ if not script or not script.strip():
+ return []
+
+ ctx = TestContext(response)
+ namespace = {
+ "pm": ctx,
+ "response": ctx.response,
+ "expect": _expect,
+ }
+ try:
+ exec(script, namespace)
+ except Exception as e:
+ ctx.results.append(TestResult(name="Script Error", passed=False, message=str(e)))
+ return ctx.results
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..34fd776
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,72 @@
+"""APIClient - Agent — Core data models."""
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@dataclass
+class HttpRequest:
+ method: str = "GET"
+ url: str = ""
+ headers: dict = field(default_factory=dict)
+ params: dict = field(default_factory=dict)
+ body: str = ""
+ body_type: str = "raw" # raw | form-data | urlencoded
+ content_type: str = "" # explicit Content-Type override
+ auth_type: str = "none" # none | bearer | basic | apikey
+ auth_data: dict = field(default_factory=dict)
+ pre_request_script: str = ""
+ test_script: str = ""
+ name: str = ""
+ collection_id: Optional[int] = None
+ folder_id: Optional[int] = None
+ id: Optional[int] = None # set when loaded from DB
+ timeout: int = 30 # seconds
+ ssl_verify: bool = True
+
+
+@dataclass
+class HttpResponse:
+ status: int = 0
+ reason: str = ""
+ headers: dict = field(default_factory=dict)
+ body: str = ""
+ elapsed_ms: float = 0.0
+ size_bytes: int = 0
+ error: str = ""
+
+
+@dataclass
+class Environment:
+ id: Optional[int] = None
+ name: str = ""
+ variables: dict = field(default_factory=dict)
+ is_active: bool = False
+
+
+@dataclass
+class MockEndpoint:
+ id: Optional[int] = None
+ method: str = "GET"
+ path: str = "/mock"
+ status_code: int = 200
+ response_headers: dict = field(default_factory=dict)
+ response_body: str = ""
+ name: str = ""
+
+
+@dataclass
+class TestResult:
+ name: str
+ passed: bool
+ message: str = ""
+
+
+@dataclass
+class CollectionRunResult:
+ request_name: str
+ method: str
+ url: str
+ status: int = 0
+ elapsed_ms: float = 0.0
+ test_results: list = field(default_factory=list)
+ error: str = ""
diff --git a/app/ui/__init__.py b/app/ui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/ui/ai_chat_panel.py b/app/ui/ai_chat_panel.py
new file mode 100644
index 0000000..604006c
--- /dev/null
+++ b/app/ui/ai_chat_panel.py
@@ -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()
diff --git a/app/ui/ai_panel.py b/app/ui/ai_panel.py
new file mode 100644
index 0000000..ccaa006
--- /dev/null
+++ b/app/ui/ai_panel.py
@@ -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())}"
+ )
diff --git a/app/ui/code_gen_dialog.py b/app/ui/code_gen_dialog.py
new file mode 100644
index 0000000..1a95ffb
--- /dev/null
+++ b/app/ui/code_gen_dialog.py
@@ -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())
diff --git a/app/ui/collection_runner.py b/app/ui/collection_runner.py
new file mode 100644
index 0000000..2648f12
--- /dev/null
+++ b/app/ui/collection_runner.py
@@ -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"
+ )
diff --git a/app/ui/environment_dialog.py b/app/ui/environment_dialog.py
new file mode 100644
index 0000000..cf2d3d9
--- /dev/null
+++ b/app/ui/environment_dialog.py
@@ -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()
diff --git a/app/ui/highlighter.py b/app/ui/highlighter.py
new file mode 100644
index 0000000..e0d8e03
--- /dev/null
+++ b/app/ui/highlighter.py
@@ -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'(? 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"{APP_NAME} v{APP_VERSION}
"
+ "Enterprise-grade API testing tool with AI co-pilot.
"
+ "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"
diff --git a/app/ui/mock_server_panel.py b/app/ui/mock_server_panel.py
new file mode 100644
index 0000000..759e0c7
--- /dev/null
+++ b/app/ui/mock_server_panel.py
@@ -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)
diff --git a/app/ui/request_panel.py b/app/ui/request_panel.py
new file mode 100644
index 0000000..43a2a77
--- /dev/null
+++ b/app/ui/request_panel.py
@@ -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
diff --git a/app/ui/response_panel.py b/app/ui/response_panel.py
new file mode 100644
index 0000000..45b9a00
--- /dev/null
+++ b/app/ui/response_panel.py
@@ -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)
diff --git a/app/ui/search_dialog.py b/app/ui/search_dialog.py
new file mode 100644
index 0000000..d124f01
--- /dev/null
+++ b/app/ui/search_dialog.py
@@ -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())
diff --git a/app/ui/sidebar.py b/app/ui/sidebar.py
new file mode 100644
index 0000000..f682329
--- /dev/null
+++ b/app/ui/sidebar.py
@@ -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()
diff --git a/app/ui/tabs_manager.py b/app/ui/tabs_manager.py
new file mode 100644
index 0000000..396e684
--- /dev/null
+++ b/app/ui/tabs_manager.py
@@ -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)
diff --git a/app/ui/theme.py b/app/ui/theme.py
new file mode 100644
index 0000000..45ddbcb
--- /dev/null
+++ b/app/ui/theme.py
@@ -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)
diff --git a/app/ui/websocket_panel.py b/app/ui/websocket_panel.py
new file mode 100644
index 0000000..5dd4dd1
--- /dev/null
+++ b/app/ui/websocket_panel.py
@@ -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("&", "&").replace("<", "<").replace(">", ">")
+ self.log.append(f'[{ts}] {safe_text}')
diff --git a/build_installer.sh b/build_installer.sh
new file mode 100755
index 0000000..020edae
--- /dev/null
+++ b/build_installer.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+set -e
+
+echo "=== Building API Client installer ==="
+
+# Install deps
+pip install -r requirements.txt
+
+# Build with PyInstaller
+pyinstaller \
+ --onedir \
+ --windowed \
+ --name "APIClient" \
+ --add-data "app:app" \
+ main.py
+
+echo ""
+echo "=== Build complete ==="
+echo "Executable: dist/APIClient/APIClient"
+echo ""
+
+# Optional: create .deb (requires fpm: gem install fpm)
+if command -v fpm &> /dev/null; then
+ echo "Creating .deb package..."
+ fpm -s dir -t deb \
+ -n api-client \
+ -v 1.0.0 \
+ --description "Postman-like API client" \
+ dist/APIClient/=/opt/api-client \
+ --after-install /dev/null
+ echo "Package: api-client_1.0.0_amd64.deb"
+else
+ echo "Tip: install fpm (gem install fpm) to also generate a .deb package"
+fi
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..42896ed
--- /dev/null
+++ b/main.py
@@ -0,0 +1,17 @@
+import sys
+from PyQt6.QtWidgets import QApplication
+from app.ui.theme import apply
+from app.ui.main_window import MainWindow
+
+APP_NAME = "APIClient - Agent"
+APP_VERSION = "2.0.0"
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ app.setApplicationName(APP_NAME)
+ app.setApplicationVersion(APP_VERSION)
+ app.setOrganizationName("EKIKA")
+ apply(app, dark=True)
+ window = MainWindow()
+ window.show()
+ sys.exit(app.exec())
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..5f1618d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+PyQt6>=6.6.0
+httpx>=0.27.0
+websockets>=12.0
+anthropic>=0.25.0
+pyyaml>=6.0
+pyinstaller>=6.0.0