diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..43f328f --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rae-agent" +version = "0.1.0" +description = "ReverseAPI agent sidecar" +requires-python = ">=3.11" +dependencies = [ + "websockets>=13.0", + "claude-agent-sdk>=0.1.48", +] + +[project.scripts] +rae-agent = "rae_agent.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["rae_agent"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/backend/rae_agent/__init__.py b/backend/rae_agent/__init__.py new file mode 100644 index 0000000..642609f --- /dev/null +++ b/backend/rae_agent/__init__.py @@ -0,0 +1,17 @@ +from rae_agent.protocol import ( + AgentEvent, + ChatRequest, + FlowSummary, + ProtocolError, + TargetLanguage, + sanitize_session_id, +) + +__all__ = [ + "AgentEvent", + "ChatRequest", + "FlowSummary", + "ProtocolError", + "TargetLanguage", + "sanitize_session_id", +] diff --git a/backend/rae_agent/prompts.py b/backend/rae_agent/prompts.py new file mode 100644 index 0000000..6d537ce --- /dev/null +++ b/backend/rae_agent/prompts.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from rae_agent.protocol import ChatRequest, TargetLanguage + +LANGUAGE_HINTS = { + TargetLanguage.PYTHON: """ +- Idiomatic Python 3.11+ +- Use `httpx` for HTTP, prefer async if the flows look paginated or interactive +- Type hints with `from __future__ import annotations` +- Pydantic models when response shapes are stable +""", + TargetLanguage.TYPESCRIPT: """ +- TypeScript 5+, ESM +- Use the global `fetch` API (no axios) +- Strict types, no `any` +- Export plain async functions; group them in a class only if state is required +""", + TargetLanguage.GO: """ +- Modern Go 1.22+ +- net/http standard client; no third-party HTTP libraries +- Errors wrapped with `%w` +- Structs with json tags +""", +} + + +SYSTEM_PROMPT = """You are ReverseAPI, an expert at reverse-engineering HTTP APIs from captured traffic. + +You will be given: +- A user request describing the API client they want +- A JSON file at a known path containing captured HTTP flows (request + response, headers and bodies) +- A target language + +Your job: +1. Read the flows file with the Read tool. +2. Identify the API endpoints involved in the user's request. +3. Detect authentication patterns (Bearer, cookies, custom headers). +4. Detect content negotiation, pagination, retries, rate limit headers. +5. Synthesise a clean, production-shaped client library in the target language. +6. Write each generated file using the Write tool, into the current working directory. +7. Briefly summarise what you produced and any assumptions you made. + +Do NOT execute any code or make outbound HTTP calls. Do NOT install packages. +Keep generated code dependency-light and readable.""" + + +def build_user_prompt(request: ChatRequest, flows_path: str) -> str: + hints = LANGUAGE_HINTS.get(request.target, "").strip() + lines = [ + f"User request: {request.user_message}", + "", + f"Captured flows file: {flows_path}", + f"Number of flows: {len(request.flows)}", + f"Target language: {request.target.value}", + "", + "Language guidelines:", + hints, + ] + if request.history: + lines.extend([ + "", + "Recent conversation:", + *[f"- {item['role']}: {item['content']}" for item in request.history[-6:]], + ]) + return "\n".join(lines) diff --git a/backend/rae_agent/protocol.py b/backend/rae_agent/protocol.py new file mode 100644 index 0000000..5e5c463 --- /dev/null +++ b/backend/rae_agent/protocol.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class TargetLanguage(str, Enum): + PYTHON = "python" + TYPESCRIPT = "typescript" + GO = "go" + + +class ProtocolError(Exception): + pass + + +_SAFE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._\-]+$") + + +def sanitize_session_id(raw: str, fallback: str) -> str: + if not raw: + return fallback + candidate = raw.strip() + if not candidate or candidate in {".", ".."}: + return fallback + if "/" in candidate or "\\" in candidate or "\x00" in candidate: + return fallback + if not _SAFE_ID_PATTERN.fullmatch(candidate): + return fallback + return candidate[:128] + + +def _optional_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ProtocolError(f"expected float, got {value!r}") from exc + + +@dataclass +class FlowSummary: + id: str + scheme: str + method: str + url: str + request_headers: list[tuple[str, str]] + request_body: str | None + response_status: int | None + response_headers: list[tuple[str, str]] + response_body: str | None + started_at: float + finished_at: float | None + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "FlowSummary": + try: + return cls( + id=str(payload["id"]), + scheme=str(payload["scheme"]), + method=str(payload["method"]), + url=str(payload["url"]), + request_headers=[(str(k), str(v)) for k, v in payload.get("requestHeaders", [])], + request_body=payload.get("requestBody"), + response_status=payload.get("responseStatus"), + response_headers=[(str(k), str(v)) for k, v in payload.get("responseHeaders", [])], + response_body=payload.get("responseBody"), + started_at=float(payload.get("startedAt", 0.0)), + finished_at=_optional_float(payload.get("finishedAt")), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ProtocolError(f"invalid flow payload: {exc}") from exc + + +@dataclass +class ChatRequest: + id: str + user_message: str + target: TargetLanguage + flows: list[FlowSummary] = field(default_factory=list) + history: list[dict[str, str]] = field(default_factory=list) + # The Claude Agent SDK session id from a previous turn. When present we + # pass it as ClaudeAgentOptions.resume so the SDK can re-attach to its + # own persisted state instead of replaying our local history. + claude_session_id: str | None = None + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "ChatRequest": + if payload.get("type") != "chat": + raise ProtocolError("expected type=chat") + try: + target_value = str(payload.get("target", "python")).lower() + target = TargetLanguage(target_value) + except ValueError as exc: + raise ProtocolError(f"unsupported target language: {payload.get('target')!r}") from exc + flows = [FlowSummary.from_payload(f) for f in payload.get("flows", [])] + history = [ + {"role": str(item.get("role", "user")), "content": str(item.get("content", ""))} + for item in payload.get("history", []) + ] + raw_sid = payload.get("claudeSessionId") or payload.get("claude_session_id") + claude_session_id = str(raw_sid) if isinstance(raw_sid, str) and raw_sid else None + return cls( + id=str(payload.get("id", "")), + user_message=str(payload.get("message", "")), + target=target, + flows=flows, + history=history, + claude_session_id=claude_session_id, + ) + + +@dataclass +class AgentEvent: + type: str + payload: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + return {"type": self.type, **self.payload} + + @classmethod + def assistant_text(cls, chat_id: str, text: str) -> "AgentEvent": + return cls(type="assistant_text", payload={"id": chat_id, "text": text}) + + @classmethod + def session_started(cls, chat_id: str, claude_session_id: str) -> "AgentEvent": + return cls( + type="session_started", + payload={"id": chat_id, "claudeSessionId": claude_session_id}, + ) + + @classmethod + def assistant_text_chunk(cls, chat_id: str, text: str) -> "AgentEvent": + return cls(type="assistant_text_chunk", payload={"id": chat_id, "text": text}) + + @classmethod + def tool_use(cls, chat_id: str, name: str, tool_input: dict[str, Any]) -> "AgentEvent": + return cls(type="tool_use", payload={"id": chat_id, "name": name, "input": tool_input}) + + @classmethod + def tool_result(cls, chat_id: str, name: str, output: str, is_error: bool) -> "AgentEvent": + return cls( + type="tool_result", + payload={"id": chat_id, "name": name, "output": output, "is_error": is_error}, + ) + + @classmethod + def file_written(cls, chat_id: str, path: str) -> "AgentEvent": + return cls(type="file_written", payload={"id": chat_id, "path": path}) + + @classmethod + def complete(cls, chat_id: str, workdir: str, files: list[str]) -> "AgentEvent": + return cls( + type="complete", + payload={"id": chat_id, "workdir": workdir, "files": files}, + ) + + @classmethod + def error(cls, chat_id: str | None, message: str) -> "AgentEvent": + payload: dict[str, Any] = {"message": message} + if chat_id is not None: + payload["id"] = chat_id + return cls(type="error", payload=payload) diff --git a/backend/rae_agent/server.py b/backend/rae_agent/server.py new file mode 100644 index 0000000..c6b6908 --- /dev/null +++ b/backend/rae_agent/server.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sys +import tempfile +from pathlib import Path + +import websockets + +from rae_agent.protocol import AgentEvent, ChatRequest, ProtocolError +from rae_agent.session import run_chat + +logger = logging.getLogger("rae_agent") + + +async def handle_connection(websocket, base_dir: Path) -> None: + try: + async for raw in websocket: + await _process(websocket, raw, base_dir) + except websockets.ConnectionClosed: + return + + +async def _process(websocket, raw: str | bytes, base_dir: Path) -> None: + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + await websocket.send(json.dumps(AgentEvent.error(None, f"invalid JSON: {exc}").to_dict())) + return + + try: + request = ChatRequest.from_payload(payload) + except ProtocolError as exc: + await websocket.send(json.dumps(AgentEvent.error(None, str(exc)).to_dict())) + return + + try: + async for event in run_chat(request, base_dir): + await websocket.send(json.dumps(event.to_dict())) + except ValueError as exc: + await websocket.send(json.dumps(AgentEvent.error(request.id, str(exc)).to_dict())) + except Exception as exc: + logger.exception("agent run failed") + await websocket.send(json.dumps(AgentEvent.error(request.id, str(exc)).to_dict())) + + +async def serve(host: str, port: int, base_dir: Path) -> None: + async def handler(websocket): + await handle_connection(websocket, base_dir) + + async with websockets.serve(handler, host, port, max_size=64 * 1024 * 1024) as server: + sockets = list(server.sockets or []) + bound_port = sockets[0].getsockname()[1] if sockets else port + print(f"RAE_AGENT_LISTENING:{bound_port}", flush=True) + await asyncio.Future() + + +def resolve_base_dir() -> Path: + raw = os.environ.get("RAE_AGENT_WORKDIR") + if raw: + return Path(raw) + return Path(tempfile.gettempdir()) / "rae-agent-sessions" + + +def main() -> None: + logging.basicConfig(level=os.environ.get("RAE_AGENT_LOG", "INFO")) + + host = os.environ.get("RAE_AGENT_HOST", "127.0.0.1") + port = int(os.environ.get("RAE_AGENT_PORT", "0")) + base_dir = resolve_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + + try: + asyncio.run(serve(host, port, base_dir)) + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py new file mode 100644 index 0000000..4f2e46c --- /dev/null +++ b/backend/rae_agent/session.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, + query, +) + +# `StreamEvent` is defined in claude_agent_sdk.types but not re-exported from +# the package's public namespace (at least up to 0.1.48). Import it directly +# from the submodule, and degrade gracefully to an unmatchable sentinel class +# if the SDK ever drops it — we just lose streaming, not the whole sidecar. +_STREAMING_ENABLED = True +try: + from claude_agent_sdk.types import StreamEvent +except ImportError: # pragma: no cover - SDK shape changed + _STREAMING_ENABLED = False + + class StreamEvent: # type: ignore[no-redef] + """Stub used only for isinstance checks when the SDK doesn't expose + StreamEvent. Nothing will match it, so streaming silently degrades — + but we then fall back to emitting whole TextBlocks from + AssistantMessage so the user still sees the assistant's reply.""" + pass + +from rae_agent.prompts import SYSTEM_PROMPT, build_user_prompt +from rae_agent.protocol import AgentEvent, ChatRequest, sanitize_session_id + + +@dataclass +class SessionDirs: + root: Path + flows_dir: Path + output_dir: Path + + @classmethod + def make(cls, chat_id: str, base: Path) -> "SessionDirs": + base_resolved = base.resolve() + root = (base_resolved / chat_id).resolve() + if base_resolved not in root.parents and root != base_resolved: + raise ValueError(f"session id escapes base directory: {chat_id!r}") + flows_dir = root / "flows" + output_dir = root / "out" + for directory in (root, flows_dir, output_dir): + directory.mkdir(parents=True, exist_ok=True) + return cls(root=root, flows_dir=flows_dir, output_dir=output_dir) + + +def _serialize_flow(flow) -> dict[str, Any]: + return { + "id": flow.id, + "scheme": flow.scheme, + "method": flow.method, + "url": flow.url, + "request_headers": flow.request_headers, + "request_body": flow.request_body, + "response_status": flow.response_status, + "response_headers": flow.response_headers, + "response_body": flow.response_body, + "started_at": flow.started_at, + "finished_at": flow.finished_at, + } + + +async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentEvent]: + fallback_id = uuid.uuid4().hex + chat_id = sanitize_session_id(request.id, fallback_id) + dirs = SessionDirs.make(chat_id, base_dir) + flows_path = dirs.flows_dir / "flows.json" + flows_payload = [_serialize_flow(flow) for flow in request.flows] + flows_path.write_text(json.dumps(flows_payload, indent=2)) + + prompt = build_user_prompt(request, str(flows_path)) + + options = ClaudeAgentOptions( + model=os.environ.get("RAE_AGENT_MODEL", "claude-opus-4-7"), + system_prompt=SYSTEM_PROMPT, + cwd=str(dirs.output_dir), + allowed_tools=["Read", "Write", "Edit"], + permission_mode="bypassPermissions", + include_partial_messages=True, + # When the client passes a Claude session id from a previous turn, + # `resume` makes the SDK re-attach to its own persisted state for + # that session. We don't need to re-send the conversation history + # ourselves — the SDK already has it. + resume=request.claude_session_id, + ) + + pending_writes: dict[str, str] = {} + captured_session_id: str | None = None + + try: + async for sdk_message in query(prompt=prompt, options=options): + # Capture the SDK-assigned session id from the first message + # that carries one, and surface it to the client so it can + # persist + resume on subsequent turns. + if captured_session_id is None: + sid = getattr(sdk_message, "session_id", None) + if isinstance(sid, str) and sid: + captured_session_id = sid + yield AgentEvent.session_started(chat_id, sid) + async for event in _translate(chat_id, sdk_message, pending_writes): + yield event + except asyncio.CancelledError: + raise + except Exception as exc: + yield AgentEvent.error(chat_id, f"{type(exc).__name__}: {exc}") + return + + files = sorted( + str(path.relative_to(dirs.output_dir)) + for path in dirs.output_dir.rglob("*") + if path.is_file() + ) + yield AgentEvent.complete(chat_id, str(dirs.output_dir), files) + + +async def _translate( + chat_id: str, + message: Any, + pending_writes: dict[str, str], +) -> AsyncIterator[AgentEvent]: + # With include_partial_messages=True the SDK emits a StreamEvent for each + # raw Anthropic API stream chunk. We forward `text_delta` chunks as + # assistant_text_chunk events so the macOS UI can render the response + # incrementally instead of waiting for the final AssistantMessage. + if isinstance(message, StreamEvent): + raw = message.event or {} + if raw.get("type") == "content_block_delta": + delta = raw.get("delta") or {} + if delta.get("type") == "text_delta": + text = delta.get("text") or "" + if text: + yield AgentEvent.assistant_text_chunk(chat_id, text) + return + + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + # When real streaming is on, the text already arrived via + # StreamEvent chunks — skip to avoid duplicating the body. + # When the SDK doesn't expose StreamEvent we fall back to + # emitting the whole TextBlock so the user actually sees + # the assistant's reply. + if _STREAMING_ENABLED: + continue + if block.text: + yield AgentEvent.assistant_text(chat_id, block.text) + elif isinstance(block, ToolUseBlock): + tool_input = dict(block.input or {}) + yield AgentEvent.tool_use(chat_id, block.name, tool_input) + if block.name == "Write": + path = tool_input.get("file_path") or tool_input.get("path") + if isinstance(path, str): + pending_writes[block.id] = path + elif isinstance(message, UserMessage): + for block in message.content: + if isinstance(block, ToolResultBlock): + output = block.content if isinstance(block.content, str) else json.dumps(block.content) + is_error = bool(block.is_error) + yield AgentEvent.tool_result( + chat_id, + name="", + output=output[:4000], + is_error=is_error, + ) + pending_path = pending_writes.pop(block.tool_use_id, None) + if pending_path is not None and not is_error: + yield AgentEvent.file_written(chat_id, pending_path) + elif isinstance(message, ResultMessage): + return diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_protocol.py b/backend/tests/test_protocol.py new file mode 100644 index 0000000..5991bce --- /dev/null +++ b/backend/tests/test_protocol.py @@ -0,0 +1,185 @@ +import math + +import pytest + +from rae_agent.protocol import ( + AgentEvent, + ChatRequest, + FlowSummary, + ProtocolError, + TargetLanguage, + sanitize_session_id, +) + + +def make_flow_payload(**overrides): + base = { + "id": "flow-1", + "scheme": "https", + "method": "GET", + "url": "https://api.example.com/users", + "requestHeaders": [["User-Agent", "rae"]], + "responseStatus": 200, + "responseHeaders": [["Content-Type", "application/json"]], + "startedAt": 1700000000.0, + } + base.update(overrides) + return base + + +def test_chat_request_round_trip(): + payload = { + "type": "chat", + "id": "abc", + "message": "Build me a Python client", + "target": "python", + "flows": [make_flow_payload()], + "history": [{"role": "user", "content": "Hi"}], + } + request = ChatRequest.from_payload(payload) + assert request.id == "abc" + assert request.target is TargetLanguage.PYTHON + assert len(request.flows) == 1 + assert request.flows[0].method == "GET" + assert request.history[0] == {"role": "user", "content": "Hi"} + + +def test_chat_request_default_target(): + payload = {"type": "chat", "message": "hello"} + request = ChatRequest.from_payload(payload) + assert request.target is TargetLanguage.PYTHON + + +def test_chat_request_rejects_unknown_target(): + with pytest.raises(ProtocolError): + ChatRequest.from_payload({"type": "chat", "target": "rust"}) + + +def test_chat_request_rejects_non_chat_type(): + with pytest.raises(ProtocolError): + ChatRequest.from_payload({"type": "ping"}) + + +def test_chat_request_accepts_all_languages(): + for value in ("python", "typescript", "go"): + request = ChatRequest.from_payload({"type": "chat", "target": value}) + assert request.target.value == value + + +def test_chat_request_coerces_history_items_to_strings(): + payload = { + "type": "chat", + "history": [{"role": None, "content": 42}], + } + request = ChatRequest.from_payload(payload) + assert request.history == [{"role": "None", "content": "42"}] + + +def test_flow_summary_rejects_missing_required_fields(): + with pytest.raises(ProtocolError): + FlowSummary.from_payload({"scheme": "https"}) + + +def test_flow_summary_rejects_non_numeric_finished_at(): + payload = make_flow_payload(finishedAt="later") + with pytest.raises(ProtocolError): + FlowSummary.from_payload(payload) + + +def test_flow_summary_accepts_int_finished_at(): + payload = make_flow_payload(finishedAt=1700001234) + flow = FlowSummary.from_payload(payload) + assert math.isclose(flow.finished_at, 1700001234.0) + + +def test_flow_summary_finished_at_none_is_passthrough(): + payload = make_flow_payload() + flow = FlowSummary.from_payload(payload) + assert flow.finished_at is None + + +def test_flow_summary_coerces_headers_to_strings(): + payload = make_flow_payload(requestHeaders=[["X-Count", 5]]) + flow = FlowSummary.from_payload(payload) + assert flow.request_headers == [("X-Count", "5")] + + +def test_assistant_text_event_serialization(): + event = AgentEvent.assistant_text("abc", "hello") + assert event.to_dict() == {"type": "assistant_text", "id": "abc", "text": "hello"} + + +def test_tool_use_event_serialization(): + event = AgentEvent.tool_use("abc", "Write", {"file_path": "client.py"}) + assert event.to_dict() == { + "type": "tool_use", + "id": "abc", + "name": "Write", + "input": {"file_path": "client.py"}, + } + + +def test_tool_result_event_serialization(): + event = AgentEvent.tool_result("abc", "Write", "ok", False) + assert event.to_dict() == { + "type": "tool_result", + "id": "abc", + "name": "Write", + "output": "ok", + "is_error": False, + } + + +def test_file_written_event_serialization(): + event = AgentEvent.file_written("abc", "/tmp/x.py") + assert event.to_dict() == {"type": "file_written", "id": "abc", "path": "/tmp/x.py"} + + +def test_error_event_serialization_without_id(): + event = AgentEvent.error(None, "oops") + assert event.to_dict() == {"type": "error", "message": "oops"} + + +def test_error_event_serialization_with_id(): + event = AgentEvent.error("abc", "oops") + assert event.to_dict() == {"type": "error", "message": "oops", "id": "abc"} + + +def test_complete_event_serialization(): + event = AgentEvent.complete("abc", "/tmp/out", ["client.py", "models.py"]) + payload = event.to_dict() + assert payload["type"] == "complete" + assert payload["workdir"] == "/tmp/out" + assert payload["files"] == ["client.py", "models.py"] + + +class TestSanitizeSessionId: + def test_returns_input_when_safe(self): + assert sanitize_session_id("abc-123_v2.5", "fb") == "abc-123_v2.5" + + def test_falls_back_when_empty(self): + assert sanitize_session_id("", "fallback") == "fallback" + + def test_falls_back_for_dot_segments(self): + assert sanitize_session_id(".", "fb") == "fb" + assert sanitize_session_id("..", "fb") == "fb" + + def test_rejects_path_traversal_backslash(self): + assert sanitize_session_id("..\\evil", "fb") == "fb" + + def test_rejects_path_traversal_forward_slash(self): + assert sanitize_session_id("../evil", "fb") == "fb" + + def test_rejects_absolute_path(self): + assert sanitize_session_id("/etc/passwd", "fb") == "fb" + + def test_rejects_null_byte(self): + assert sanitize_session_id("ok\x00", "fb") == "fb" + + def test_rejects_special_characters(self): + assert sanitize_session_id("ok;ls", "fb") == "fb" + assert sanitize_session_id("ok space", "fb") == "fb" + + def test_truncates_long_input(self): + result = sanitize_session_id("a" * 500, "fb") + assert result == "a" * 128 diff --git a/backend/tests/test_server_resolution.py b/backend/tests/test_server_resolution.py new file mode 100644 index 0000000..c6941a2 --- /dev/null +++ b/backend/tests/test_server_resolution.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import pytest + +from rae_agent.server import resolve_base_dir + + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch): + monkeypatch.delenv("RAE_AGENT_WORKDIR", raising=False) + yield + + +def test_resolve_base_dir_uses_env_when_set(monkeypatch): + monkeypatch.setenv("RAE_AGENT_WORKDIR", "/custom/path") + assert resolve_base_dir() == Path("/custom/path") + + +def test_resolve_base_dir_does_not_append_when_env_set(monkeypatch): + monkeypatch.setenv("RAE_AGENT_WORKDIR", "/foo/bar") + result = resolve_base_dir() + assert str(result) == "/foo/bar" + assert "rae-agent-sessions" not in str(result) + + +def test_resolve_base_dir_falls_back_to_tmpdir(): + result = resolve_base_dir() + assert result.name == "rae-agent-sessions" + assert str(result).startswith(tempfile.gettempdir()) diff --git a/backend/tests/test_session_dirs.py b/backend/tests/test_session_dirs.py new file mode 100644 index 0000000..729d55b --- /dev/null +++ b/backend/tests/test_session_dirs.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from rae_agent.session import SessionDirs + + +def test_make_creates_directories(tmp_path: Path): + dirs = SessionDirs.make("abc-123", tmp_path) + assert dirs.root.exists() + assert dirs.flows_dir.exists() + assert dirs.output_dir.exists() + assert dirs.root == (tmp_path / "abc-123").resolve() + + +def test_make_is_idempotent(tmp_path: Path): + SessionDirs.make("chat1", tmp_path) + dirs = SessionDirs.make("chat1", tmp_path) + assert dirs.root.exists() + + +def test_make_rejects_path_traversal(tmp_path: Path): + with pytest.raises(ValueError): + SessionDirs.make("../escape", tmp_path) + + +def test_make_rejects_absolute_path(tmp_path: Path): + with pytest.raises(ValueError): + SessionDirs.make("/tmp/elsewhere", tmp_path) + + +def test_make_keeps_session_inside_base(tmp_path: Path): + dirs = SessionDirs.make("nested-deep", tmp_path) + assert tmp_path.resolve() in dirs.root.parents diff --git a/macos/Package.resolved b/macos/Package.resolved new file mode 100644 index 0000000..92bb146 --- /dev/null +++ b/macos/Package.resolved @@ -0,0 +1,114 @@ +{ + "originHash" : "ed5071e11155cb3e0a72fefe0bc3210d9c4257508d41f0557b76e5310c4c0b42", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui.git", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + } + ], + "version" : 3 +} diff --git a/macos/Package.swift b/macos/Package.swift index c207d70..0fb7384 100644 --- a/macos/Package.swift +++ b/macos/Package.swift @@ -15,6 +15,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.7.0"), .package(url: "https://github.com/groue/GRDB.swift.git", from: "6.29.0"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.0"), ], targets: [ .target( @@ -41,6 +42,7 @@ let package = Package( dependencies: [ "ReverseAPIProxy", .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), ] ), .testTarget( diff --git a/macos/Sources/ReverseAPI/Agent/AgentClient.swift b/macos/Sources/ReverseAPI/Agent/AgentClient.swift new file mode 100644 index 0000000..f972c16 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentClient.swift @@ -0,0 +1,77 @@ +import Foundation + +actor AgentClient { + enum ClientError: Error { + case notConnected + case encodingFailed + } + + private var task: URLSessionWebSocketTask? + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func connect(port: Int) async throws { + if let existing = task, isLive(existing) { return } + if task != nil { disconnect() } + let url = URL(string: "ws://127.0.0.1:\(port)")! + let webSocketTask = session.webSocketTask(with: url) + webSocketTask.resume() + self.task = webSocketTask + } + + func disconnect() { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } + + func send(_ request: AgentChatRequest) async throws { + guard let task else { throw ClientError.notConnected } + let data = try JSONEncoder().encode(request) + guard let json = String(data: data, encoding: .utf8) else { + throw ClientError.encodingFailed + } + try await task.send(.string(json)) + } + + func events() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let receiveTask = Task { + do { + while true { + try Task.checkCancellation() + guard let task = self.task else { + continuation.finish() + return + } + let message = try await task.receive() + let data: Data + switch message { + case .data(let raw): + data = raw + case .string(let string): + data = Data(string.utf8) + @unknown default: + continue + } + let event = try AgentEventDecoder.decode(data) + continuation.yield(event) + } + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + receiveTask.cancel() + } + } + } + + private nonisolated func isLive(_ task: URLSessionWebSocketTask) -> Bool { + task.state == .running + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift new file mode 100644 index 0000000..2185e29 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift @@ -0,0 +1,188 @@ +import Foundation +import ReverseAPIProxy + +enum AgentTargetLanguage: String, CaseIterable, Identifiable, Sendable, Codable { + case python + case typescript + case go + + var id: String { rawValue } + + var displayName: String { + switch self { + case .python: return "Python" + case .typescript: return "TypeScript" + case .go: return "Go" + } + } +} + +struct AgentFlowPayload: Encodable { + static let defaultMaxBodyBytes = 64 * 1024 + + let id: String + let scheme: String + let method: String + let url: String + let requestHeaders: [[String]] + let requestBody: String? + let responseStatus: Int? + let responseHeaders: [[String]] + let responseBody: String? + let startedAt: Double + let finishedAt: Double? + + init(_ flow: CapturedFlow, maxBodyBytes: Int = AgentFlowPayload.defaultMaxBodyBytes) { + self.id = flow.id.uuidString + self.scheme = flow.scheme.rawValue + self.method = flow.method + self.url = flow.url + self.requestHeaders = flow.requestHeaders.map { [$0.name, $0.value] } + self.requestBody = AgentFlowPayload.encodedBody(flow.requestBody, limit: maxBodyBytes) + self.responseStatus = flow.responseStatus + self.responseHeaders = flow.responseHeaders.map { [$0.name, $0.value] } + self.responseBody = AgentFlowPayload.encodedBody(flow.responseBody, limit: maxBodyBytes) + self.startedAt = flow.startedAt.timeIntervalSince1970 + self.finishedAt = flow.finishedAt?.timeIntervalSince1970 + } + + static func encodedBody(_ data: Data, limit: Int) -> String? { + guard !data.isEmpty else { return nil } + if data.count > limit { + // Cut on a UTF-8 boundary, not at the raw byte limit — slicing + // mid-codepoint makes `String(data:)` fail, which used to send + // perfectly readable text bodies through the binary fallback. + // Step backward up to 3 bytes (max UTF-8 continuation length) + // to find a valid prefix; bail to the binary path only if + // nothing readable survives. + let suffix = "\n…" + for offset in 0...min(3, limit) { + let candidateEnd = limit - offset + let head = data.prefix(candidateEnd) + if let text = String(data: head, encoding: .utf8) { + return text + suffix + } + } + return "" + } + if let text = String(data: data, encoding: .utf8) { return text } + return "" + } +} + +struct AgentHistoryItem: Codable { + let role: String + let content: String +} + +struct AgentChatRequest: Encodable { + let type = "chat" + let id: String + let message: String + let target: String + let flows: [AgentFlowPayload] + let history: [AgentHistoryItem] + /// Claude Agent SDK session id from a previous turn. When present, the + /// backend passes it as `ClaudeAgentOptions.resume` so the SDK can + /// re-attach to its own persisted state for that conversation. + let claudeSessionId: String? +} + +enum AgentEvent: Sendable, Identifiable, Codable { + case userText(eventID: UUID, text: String) + case assistantText(chatID: String, eventID: UUID, text: String) + case assistantTextChunk(chatID: String, eventID: UUID, text: String) + case toolUse(chatID: String, eventID: UUID, name: String, inputJSON: String) + case toolResult(chatID: String, eventID: UUID, output: String, isError: Bool) + case fileWritten(chatID: String, eventID: UUID, path: String) + case complete(chatID: String, eventID: UUID, workdir: String, files: [String]) + case error(chatID: String?, eventID: UUID, message: String) + /// Backend hands us the Claude Agent SDK session id on the first turn + /// so we can pass it back as `resume` on subsequent turns and let the + /// SDK rehydrate the conversation history itself. + case sessionStarted(chatID: String, eventID: UUID, claudeSessionID: String) + + var id: UUID { + switch self { + case .userText(let id, _): return id + case .assistantText(_, let id, _): return id + case .assistantTextChunk(_, let id, _): return id + case .toolUse(_, let id, _, _): return id + case .toolResult(_, let id, _, _): return id + case .fileWritten(_, let id, _): return id + case .complete(_, let id, _, _): return id + case .error(_, let id, _): return id + case .sessionStarted(_, let id, _): return id + } + } +} + +enum AgentEventDecoder { + static func decode(_ data: Data) throws -> AgentEvent { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = object["type"] as? String else { + throw DecodeError.invalidPayload + } + let chatID = object["id"] as? String + let eventID = UUID() + switch type { + case "assistant_text": + return .assistantText( + chatID: chatID ?? "", + eventID: eventID, + text: object["text"] as? String ?? "" + ) + case "assistant_text_chunk": + return .assistantTextChunk( + chatID: chatID ?? "", + eventID: eventID, + text: object["text"] as? String ?? "" + ) + case "tool_use": + let name = object["name"] as? String ?? "" + let inputObject = object["input"] as? [String: Any] ?? [:] + let inputJSON = (try? JSONSerialization.data(withJSONObject: inputObject, options: [.prettyPrinted])).flatMap { String(data: $0, encoding: .utf8) } ?? "" + return .toolUse(chatID: chatID ?? "", eventID: eventID, name: name, inputJSON: inputJSON) + case "tool_result": + return .toolResult( + chatID: chatID ?? "", + eventID: eventID, + output: object["output"] as? String ?? "", + isError: object["is_error"] as? Bool ?? false + ) + case "file_written": + return .fileWritten( + chatID: chatID ?? "", + eventID: eventID, + path: object["path"] as? String ?? "" + ) + case "complete": + let files = (object["files"] as? [String]) ?? [] + return .complete( + chatID: chatID ?? "", + eventID: eventID, + workdir: object["workdir"] as? String ?? "", + files: files + ) + case "error": + return .error( + chatID: chatID, + eventID: eventID, + message: object["message"] as? String ?? "unknown error" + ) + case "session_started": + return .sessionStarted( + chatID: chatID ?? "", + eventID: eventID, + claudeSessionID: object["claudeSessionId"] as? String ?? "" + ) + default: + throw DecodeError.unknownType(type) + } + } + + enum DecodeError: Error { + case invalidPayload + case unknownType(String) + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentRuntime.swift b/macos/Sources/ReverseAPI/Agent/AgentRuntime.swift new file mode 100644 index 0000000..4806342 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentRuntime.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Resolves where to find the Python interpreter + `rae_agent` package +/// at launch time. There are three supported modes: +/// +/// 1. **Bundled (production)** — the `.app` ships a self-contained +/// Python runtime at `Contents/Resources/agent-runtime/`. The interpreter +/// there has `rae-agent` and its dependencies installed in its +/// site-packages. The user installs the app and never touches Python. +/// +/// 2. **Dev (`swift run`)** — the executable lives in +/// `/macos/.build/.../rae`. We walk up to find the repo root, +/// use `/.venv/bin/python3`, and rely on `rae-agent` being +/// installed in that venv (either editable via `uv pip install -e backend/` +/// or a regular install). +/// +/// 3. **Fallback** — `/usr/bin/env python3` with no PYTHONPATH. Will only +/// work if the user has installed `rae-agent` globally. Mostly useful +/// so the error message is helpful, since the sidecar will fail with a +/// clear `ModuleNotFoundError`. +struct AgentRuntime { + var executablePath: String + var arguments: [String] + var pythonPath: [String] + var origin: Origin + + enum Origin: String { + case bundled + case dev + case fallback + } + + /// Returns the best runtime for the current process. Caller should pass + /// the SwiftPM-style executable URL via `Bundle.main` so prod and dev + /// resolve correctly. + static func resolve( + bundle: Bundle = .main, + executableArgv0: String = CommandLine.arguments[0] + ) -> AgentRuntime { + if let bundled = bundledRuntime(in: bundle) { + return bundled + } + if let dev = devRuntime(argv0: executableArgv0) { + return dev + } + return AgentRuntime( + executablePath: "/usr/bin/env", + arguments: ["python3", "-m", "rae_agent.server"], + pythonPath: [], + origin: .fallback + ) + } + + private static func bundledRuntime(in bundle: Bundle) -> AgentRuntime? { + guard let resources = bundle.resourceURL else { return nil } + let python = resources.appendingPathComponent("agent-runtime/bin/python3") + guard FileManager.default.isExecutableFile(atPath: python.path) else { return nil } + return AgentRuntime( + executablePath: python.path, + arguments: ["-m", "rae_agent.server"], + pythonPath: [], + origin: .bundled + ) + } + + private static func devRuntime(argv0: String) -> AgentRuntime? { + guard let repoRoot = findRepoRoot(startingFrom: argv0) else { return nil } + let venvPython = repoRoot.appendingPathComponent(".venv/bin/python3") + guard FileManager.default.isExecutableFile(atPath: venvPython.path) else { return nil } + let backend = repoRoot.appendingPathComponent("backend").path + return AgentRuntime( + executablePath: venvPython.path, + arguments: ["-m", "rae_agent.server"], + pythonPath: [backend], + origin: .dev + ) + } + + /// Walks up from the executable looking for `backend/rae_agent/__init__.py`. + /// Capped at 10 levels so we never wander out of the repo on weird paths. + private static func findRepoRoot(startingFrom argv0: String) -> URL? { + let fm = FileManager.default + var dir = URL(fileURLWithPath: argv0).deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("backend/rae_agent/__init__.py") + if fm.fileExists(atPath: marker.path) { + return dir + } + let parent = dir.deletingLastPathComponent() + if parent == dir { break } + dir = parent + } + return nil + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift new file mode 100644 index 0000000..d084979 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -0,0 +1,298 @@ +import Foundation +import Observation +import ReverseAPIProxy + +@MainActor +@Observable +final class AgentSession { + enum Status: Equatable { + case idle + case launching + case ready + case streaming + case failed + } + + /// What the agent panel renders: the list of past sessions, or the + /// timeline of the active session. + enum Mode: Equatable { + case list + case session + } + + private(set) var status: Status = .idle + private(set) var events: [AgentEvent] = [] + private(set) var lastError: String? + private(set) var lastWorkdir: String? + private(set) var generatedFiles: [String] = [] + private(set) var history: [AgentHistoryItem] = [] + + var input: String = "" + var target: AgentTargetLanguage = .python + var mode: Mode = .list + + let store: AgentSessionStore + + private let sidecar = AgentSidecar() + private let client = AgentClient() + private var receiverTask: Task? + private(set) var sessionID = UUID().uuidString + private var sessionCreatedAt = Date() + private var sessionTitle: String? + /// SDK-assigned session id, captured on the first turn. Passed back as + /// `resume` on subsequent sends so Claude rehydrates the conversation + /// without us replaying `history` on every message. + private var claudeSessionID: String? + private let workdir: URL + private let launchSpec: AgentSidecar.LaunchSpec + + init(workdir: URL, launchSpec: AgentSidecar.LaunchSpec? = nil) { + self.workdir = workdir + self.launchSpec = launchSpec ?? .python3(workdir: workdir) + self.store = AgentSessionStore(rootDirectory: workdir) + } + + // MARK: - Session lifecycle (list / new / load) + + /// Reset state and switch into a fresh, unsaved session. The session is + /// only persisted to disk once the user sends their first message. + func startNewSession() { + events.removeAll() + history.removeAll() + generatedFiles = [] + lastWorkdir = nil + lastError = nil + sessionID = UUID().uuidString + sessionCreatedAt = Date() + sessionTitle = nil + claudeSessionID = nil + if status == .failed { status = .idle } + mode = .session + } + + /// Load a session from disk and switch the panel to its timeline. + func openSession(id: String) async { + guard let record = await store.load(id: id) else { return } + events = record.events + history = record.history + generatedFiles = record.generatedFiles + lastWorkdir = record.lastWorkdir + target = record.target + sessionID = record.id + sessionCreatedAt = record.createdAt + sessionTitle = record.title + claudeSessionID = record.claudeSessionID + lastError = nil + if status == .failed { status = .idle } + mode = .session + } + + /// Drop back to the sessions list without clearing in-memory state. + func backToList() { + mode = .list + } + + /// Delete a session permanently (from disk + the store's index). If the + /// currently open session is the one being deleted, also reset memory. + func deleteSession(id: String) async { + if sessionID == id { + startNewSession() + mode = .list + } + await store.delete(id: id) + } + + // MARK: - Existing API + + func ensureRunning() async { + switch status { + case .ready, .streaming, .launching: + return + case .idle, .failed: + break + } + status = .launching + do { + let port = try await sidecar.launch(launchSpec) + try await client.connect(port: port) + startReceiver() + status = .ready + lastError = nil + } catch { + await sidecar.terminate() + await client.disconnect() + lastError = "Agent sidecar failed to start: \(error)" + status = .failed + } + } + + func send(flows: [CapturedFlow]) async { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + await ensureRunning() + guard status == .ready || status == .streaming else { return } + let userHistoryItem = AgentHistoryItem(role: "user", content: trimmed) + // Once the SDK has assigned us a session id, lean on `resume` and + // skip shipping our own history — the SDK reattaches to its + // persisted conversation state instead. + let historyToSend: [AgentHistoryItem] = claudeSessionID == nil ? history : [] + let request = AgentChatRequest( + id: sessionID, + message: trimmed, + target: target.rawValue, + flows: flows.map { AgentFlowPayload($0) }, + history: historyToSend, + claudeSessionId: claudeSessionID + ) + history.append(userHistoryItem) + events.append(.userText(eventID: UUID(), text: trimmed)) + // Auto-derive a title from the first user prompt so the sessions + // list shows something meaningful instead of a UUID. + if sessionTitle == nil { + sessionTitle = Self.deriveTitle(from: trimmed) + } + await persist() + do { + input = "" + status = .streaming + try await client.send(request) + } catch { + lastError = "Failed to send chat: \(error)" + status = .failed + } + } + + /// Clear the active conversation in memory. Doesn't touch disk — the + /// previous session record stays on disk until the user explicitly + /// deletes it from the list. + func clear() { + events.removeAll() + history.removeAll() + generatedFiles = [] + lastWorkdir = nil + lastError = nil + sessionID = UUID().uuidString + sessionCreatedAt = Date() + sessionTitle = nil + claudeSessionID = nil + if status == .failed { status = .idle } + } + + func shutdown() async { + receiverTask?.cancel() + receiverTask = nil + await client.disconnect() + await sidecar.terminate() + status = .idle + } + + private func startReceiver() { + receiverTask?.cancel() + receiverTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + let stream = await self.client.events() + for try await event in stream { + self.handle(event) + } + } catch { + await self.client.disconnect() + await self.sidecar.terminate() + self.lastError = "Agent stream error: \(error)" + self.status = .failed + } + } + } + + private func handle(_ event: AgentEvent) { + switch event { + case .assistantTextChunk(let chatID, _, let chunk): + if let lastIndex = events.indices.last, + case .assistantText(let c, let id, let existing) = events[lastIndex], + c == chatID { + events[lastIndex] = .assistantText(chatID: c, eventID: id, text: existing + chunk) + } else { + events.append(.assistantText(chatID: chatID, eventID: UUID(), text: chunk)) + } + case .assistantText(_, _, let text): + events.append(event) + history.append(.init(role: "assistant", content: text)) + case .complete(_, _, let workdir, let files): + events.append(event) + lastWorkdir = workdir + generatedFiles = files + status = .ready + recordStreamedAssistantTextIntoHistory() + case .error(_, _, let message): + events.append(event) + lastError = message + status = .failed + case .sessionStarted(_, _, let claudeID): + // Don't surface as a timeline row; just remember the id so the + // next send can pass it as `resume`. + if !claudeID.isEmpty { claudeSessionID = claudeID } + case .userText, .toolUse, .toolResult, .fileWritten: + events.append(event) + } + Task { await persist() } + } + + /// Walk back from the timeline tail only as far as the most recent + /// `userText` event — that's where the current turn started. If the + /// turn ended without an assistant reply (tool-only turn, error, etc.) + /// we do nothing instead of re-committing a previous turn's reply, which + /// was the bug in the un-scoped version. + private func recordStreamedAssistantTextIntoHistory() { + var turnStart = events.startIndex + for (index, event) in events.enumerated().reversed() { + if case .userText = event { + turnStart = index + 1 + break + } + } + guard turnStart <= events.endIndex else { return } + for event in events[turnStart...].reversed() { + if case .assistantText(_, _, let text) = event, !text.isEmpty { + let alreadyRecorded = history.last?.role == "assistant" + && history.last?.content == text + if !alreadyRecorded { + history.append(.init(role: "assistant", content: text)) + } + return + } + } + } + + // MARK: - Persistence + + /// Snapshot the current in-memory session and write it to disk. Cheap + /// (the whole record is just a handful of fields plus the events array) + /// so we can call this after every event without buffering. + private func persist() async { + // Don't bother saving an empty, never-used session — it would just + // clutter the list with no-op entries on every app launch. + guard !events.isEmpty || !history.isEmpty else { return } + let record = AgentSessionRecord( + id: sessionID, + title: sessionTitle ?? Self.fallbackTitle, + createdAt: sessionCreatedAt, + lastModifiedAt: Date(), + target: target, + events: events, + history: history, + lastWorkdir: lastWorkdir, + generatedFiles: generatedFiles, + claudeSessionID: claudeSessionID + ) + await store.save(record) + } + + private static let fallbackTitle = "Untitled session" + + private static func deriveTitle(from prompt: String) -> String { + let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + let collapsed = trimmed.replacingOccurrences(of: "\n", with: " ") + if collapsed.count <= 60 { return collapsed } + return String(collapsed.prefix(57)) + "…" + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSessionRecord.swift b/macos/Sources/ReverseAPI/Agent/AgentSessionRecord.swift new file mode 100644 index 0000000..c27dacb --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSessionRecord.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Persistent on-disk representation of one agent conversation. Saved as +/// `//session.json` next to the `flows/` + +/// `out/` directories the Python sidecar already writes into for the +/// same chat id, so everything for a given conversation lives in one +/// folder. +struct AgentSessionRecord: Codable { + var id: String + var title: String + var createdAt: Date + var lastModifiedAt: Date + var target: AgentTargetLanguage + var events: [AgentEvent] + var history: [AgentHistoryItem] + var lastWorkdir: String? + var generatedFiles: [String] + /// Claude Agent SDK session id captured on the first turn. When set, + /// subsequent sends pass it as `resume` so the SDK rehydrates the + /// conversation context on its end — we don't need to ship our + /// `history` array back over the wire. + var claudeSessionID: String? +} + +/// Lightweight summary shown in the sessions list — avoids decoding the +/// full event timeline when we're just rendering a row. +struct AgentSessionSummary: Identifiable, Hashable, Sendable { + let id: String + let title: String + let createdAt: Date + let lastModifiedAt: Date + let messageCount: Int +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSessionStore.swift b/macos/Sources/ReverseAPI/Agent/AgentSessionStore.swift new file mode 100644 index 0000000..34b8f8b --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSessionStore.swift @@ -0,0 +1,105 @@ +import Foundation +import Observation + +/// Owns the on-disk index of agent sessions: lists them, loads, saves, +/// deletes. Lives on the main actor since the UI binds directly to +/// `sessions`. Writes happen on a detached priority-utility task so the +/// main thread isn't blocked. +@MainActor +@Observable +final class AgentSessionStore { + private(set) var sessions: [AgentSessionSummary] = [] + private let rootDirectory: URL + + init(rootDirectory: URL) { + self.rootDirectory = rootDirectory + try? FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + Task { await reload() } + } + + func reload() async { + let root = rootDirectory + let summaries: [AgentSessionSummary] = await Task.detached(priority: .utility) { + Self.scan(root: root) + }.value + // Most recently touched first + sessions = summaries.sorted { $0.lastModifiedAt > $1.lastModifiedAt } + } + + func save(_ record: AgentSessionRecord) async { + let root = rootDirectory + await Task.detached(priority: .utility) { + let dir = root.appendingPathComponent(record.id, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("session.json") + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(record) else { return } + try? data.write(to: url, options: .atomic) + }.value + await reload() + } + + func load(id: String) async -> AgentSessionRecord? { + let root = rootDirectory + return await Task.detached(priority: .userInitiated) { + let url = root + .appendingPathComponent(id, isDirectory: true) + .appendingPathComponent("session.json") + guard let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(AgentSessionRecord.self, from: data) + }.value + } + + func delete(id: String) async { + let root = rootDirectory + await Task.detached(priority: .userInitiated) { + let dir = root.appendingPathComponent(id, isDirectory: true) + try? FileManager.default.removeItem(at: dir) + }.value + await reload() + } + + // MARK: - Internal scan + + nonisolated private static func scan(root: URL) -> [AgentSessionSummary] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var summaries: [AgentSessionSummary] = [] + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + for entry in entries { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } + let url = entry.appendingPathComponent("session.json") + guard let data = try? Data(contentsOf: url), + let record = try? decoder.decode(AgentSessionRecord.self, from: data) + else { continue } + // Count only user/assistant turns for the message count badge — + // tool plumbing makes the raw event count misleading. + let messageCount = record.events.reduce(0) { acc, event in + switch event { + case .userText, .assistantText: return acc + 1 + default: return acc + } + } + summaries.append(AgentSessionSummary( + id: record.id, + title: record.title, + createdAt: record.createdAt, + lastModifiedAt: record.lastModifiedAt, + messageCount: messageCount + )) + } + return summaries + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift new file mode 100644 index 0000000..c66cc84 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -0,0 +1,209 @@ +import Foundation + +actor AgentSidecar { + struct LaunchSpec: Sendable { + var executablePath: String + var arguments: [String] + var workdir: URL + var pythonPath: [String] + var origin: AgentRuntime.Origin + + public init( + executablePath: String, + arguments: [String], + workdir: URL, + pythonPath: [String] = [], + origin: AgentRuntime.Origin = .fallback + ) { + self.executablePath = executablePath + self.arguments = arguments + self.workdir = workdir + self.pythonPath = pythonPath + self.origin = origin + } + + public static func python3(workdir: URL) -> LaunchSpec { + let runtime = AgentRuntime.resolve() + return LaunchSpec( + executablePath: runtime.executablePath, + arguments: runtime.arguments, + workdir: workdir, + pythonPath: runtime.pythonPath, + origin: runtime.origin + ) + } + + public static func executable(at path: String, workdir: URL) -> LaunchSpec { + LaunchSpec(executablePath: path, arguments: ["-m", "rae_agent.server"], workdir: workdir) + } + } + + enum SidecarError: Error, CustomStringConvertible { + case alreadyRunning + case failedToStart(String) + case timedOut + case processDied(Int32) + case stderrSnapshot(String) + + var description: String { + switch self { + case .alreadyRunning: return "sidecar already running" + case .failedToStart(let msg): return "failed to start: \(msg)" + case .timedOut: return "sidecar did not announce its port in time" + case .processDied(let code): return "sidecar exited with status \(code)" + case .stderrSnapshot(let snapshot): return snapshot + } + } + } + + private(set) var process: Process? + private(set) var port: Int? + + func launch(_ spec: LaunchSpec, timeout: Duration = .seconds(15)) async throws -> Int { + if let port, let process, process.isRunning { return port } + if port != nil || process != nil { + terminate() + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: spec.executablePath) + process.arguments = spec.arguments + var environment = ProcessInfo.processInfo.environment + environment["RAE_AGENT_HOST"] = "127.0.0.1" + environment["RAE_AGENT_PORT"] = "0" + environment["RAE_AGENT_WORKDIR"] = spec.workdir.path + environment["PYTHONUNBUFFERED"] = "1" + if !spec.pythonPath.isEmpty { + let existing = environment["PYTHONPATH"] + let combined = ([existing].compactMap { $0?.isEmpty == false ? $0 : nil } + spec.pythonPath) + .joined(separator: ":") + environment["PYTHONPATH"] = combined + } + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + throw SidecarError.failedToStart("could not exec \(spec.executablePath): \(error.localizedDescription)") + } + self.process = process + + do { + let bound = try await waitForBoundPort(stdout: stdout, stderr: stderr, deadline: timeout, process: process) + self.port = bound + return bound + } catch { + if process.isRunning { + process.terminate() + } + self.process = nil + self.port = nil + throw error + } + } + + func terminate() { + guard let process else { return } + if process.isRunning { + process.terminate() + } + self.process = nil + self.port = nil + } + + private func waitForBoundPort(stdout: Pipe, stderr: Pipe, deadline: Duration, process: Process) async throws -> Int { + let stdoutHandle = stdout.fileHandleForReading + let stderrHandle = stderr.fileHandleForReading + let buffer = AsyncStreamBuffer() + let errBuffer = AsyncStreamBuffer() + stdoutHandle.readabilityHandler = { handle in + let chunk = handle.availableData + if !chunk.isEmpty { buffer.append(chunk) } + } + stderrHandle.readabilityHandler = { handle in + let chunk = handle.availableData + if !chunk.isEmpty { errBuffer.append(chunk) } + } + defer { + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + } + + let deadlineDate = ContinuousClock.now.advanced(by: deadline) + while ContinuousClock.now < deadlineDate { + if let line = buffer.takeLine(prefix: "RAE_AGENT_LISTENING:") { + let portString = line.dropFirst("RAE_AGENT_LISTENING:".count) + guard let port = Int(portString) else { + throw SidecarError.failedToStart("unexpected line: \(line)") + } + // The sidecar can announce its port and crash one tick later + // (e.g. an exception fires while `serve()` enters its first + // accept loop). Give the runloop a beat, then re-check + // liveness before returning the port — otherwise the caller + // happily tries to connect a websocket to a dead process. + try await Task.sleep(for: .milliseconds(20)) + if !process.isRunning { + let stderrDump = errBuffer.snapshot().trimmingCharacters(in: .whitespacesAndNewlines) + if !stderrDump.isEmpty { + throw SidecarError.stderrSnapshot(stderrDump) + } + throw SidecarError.processDied(process.terminationStatus) + } + return port + } + if !process.isRunning { + let stderrDump = errBuffer.snapshot().trimmingCharacters(in: .whitespacesAndNewlines) + if !stderrDump.isEmpty { + throw SidecarError.stderrSnapshot(stderrDump) + } + throw SidecarError.processDied(process.terminationStatus) + } + try await Task.sleep(for: .milliseconds(50)) + } + throw SidecarError.timedOut + } +} + +private final class AsyncStreamBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data) { + lock.lock() + defer { lock.unlock() } + data.append(chunk) + } + + /// Only matches lines that are *complete*, i.e. terminated by a newline. + /// Without this guard a fragmented stdout read can hand back a partial + /// `RAE_AGENT_LISTENING:5` and we'd connect to port 5 instead of the + /// real port once it finishes arriving. + func takeLine(prefix: String) -> String? { + lock.lock() + defer { lock.unlock() } + guard let text = String(data: data, encoding: .utf8) else { return nil } + let endsWithNewline = text.hasSuffix("\n") + let segments = text.split(separator: "\n", omittingEmptySubsequences: false) + for (index, substring) in segments.enumerated() { + let line = String(substring) + guard line.hasPrefix(prefix) else { continue } + // The final segment may be a partial line (the reader has bytes + // but the trailing \n hasn't arrived yet). Treat it as complete + // only when `text` itself ends with \n. + let isComplete = (index != segments.count - 1) || endsWithNewline + if isComplete { return line } + } + return nil + } + + func snapshot() -> String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index cfbcc08..526de81 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -1,6 +1,14 @@ import Foundation import Observation import ReverseAPIProxy +import Security + +/// Identifiable wrapper for the file the user is currently inspecting, used +/// with SwiftUI's `.sheet(item:)` so toggling between files re-renders. +struct AgentFileRef: Identifiable, Hashable { + let url: URL + var id: URL { url } +} @MainActor @Observable @@ -19,13 +27,32 @@ final class AppState { private(set) var lastError: String? var selectedFlowID: UUID? + /// Flows the user has explicitly checked to share with the agent on the + /// next send. When empty, the agent receives the filtered view instead. + var agentSelection: Set = [] + /// When set, ContentView shows AgentFileViewer for this path. + var viewingFile: AgentFileRef? var filter = TrafficFilter() var captureMode: CaptureMode = .device + func viewFile(at path: String) { + viewingFile = AgentFileRef(url: URL(fileURLWithPath: path)) + } + + func deleteFlows(_ ids: Set) { + guard !ids.isEmpty else { return } + if let selected = selectedFlowID, ids.contains(selected) { + selectedFlowID = nil + } + agentSelection.subtract(ids) + Task { await store.delete(ids: ids) } + } + let store: FlowStore let engine: ProxyEngine let installer: CertificateTrustInstaller let systemProxy: SystemProxyController + let agent: AgentSession let port: Int let caDER: Data @@ -48,10 +75,14 @@ final class AppState { let databaseURL = caStore.directory.appendingPathComponent("flows.sqlite") let store = try FlowStore(databaseURL: databaseURL) + let agentWorkdir = caStore.directory.appendingPathComponent("agent-sessions", isDirectory: true) + try FileManager.default.createDirectory(at: agentWorkdir, withIntermediateDirectories: true) + self.store = store self.engine = engine self.installer = CertificateTrustInstaller() self.systemProxy = SystemProxyController() + self.agent = AgentSession(workdir: agentWorkdir) self.port = port self.caDER = Data(try root.derBytes()) self.caPEM = try root.pem() @@ -76,6 +107,9 @@ final class AppState { defer { isWorking = false } do { + if mode == .device { + try await ensureCATrustInstalled() + } try await engine.start() if mode == .device { @@ -126,18 +160,30 @@ final class AppState { } } + private func ensureCATrustInstalled() async throws { + if installer.isInstalled(derBytes: caDER) { + caTrustInstalled = true + return + } + + let installer = self.installer + let der = self.caDER + try await Task.detached(priority: .userInitiated) { + try installer.install(derBytes: der) + }.value + caTrustInstalled = installer.isInstalled(derBytes: caDER) + if !caTrustInstalled { + throw CertificateTrustError.trustFailed(errSecAuthFailed) + } + } + func installCATrust() async { guard !isWorking else { return } isWorking = true defer { isWorking = false } do { - let installer = self.installer - let der = self.caDER - try await Task.detached(priority: .userInitiated) { - try installer.install(derBytes: der) - }.value - caTrustInstalled = true + try await ensureCATrustInstalled() lastError = nil } catch { lastError = "Failed to install CA trust: \(error)" @@ -196,6 +242,10 @@ final class AppState { do { try await store.clear() selectedFlowID = nil + // Stale UUIDs in agentSelection would leave the agent + // panel in "N selected" mode with nothing to actually + // send — wipe alongside the flows. + agentSelection.removeAll() lastError = nil } catch { lastError = "Failed to clear flows: \(error)" diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index ce596c8..3e742aa 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -5,13 +5,12 @@ import SwiftUI struct ReverseAPIApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var session = AppSession.live() - @AppStorage("rae.sidebar.visible") private var isSidebarVisible = true var body: some Scene { Window("rae", id: "main") { switch session { case .ready(let state): - ContentView(isSidebarVisible: $isSidebarVisible) + ContentView() .environment(state) .onAppear { AppLifecycle.shared.state = state @@ -19,10 +18,22 @@ struct ReverseAPIApp: App { .onDisappear { Task { await state.shutdownForWindowClose() } } + .background(WindowAccessor { window in + window.title = "" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isOpaque = true + window.backgroundColor = NSColor(Theme.appBackground) + }) .frame( + // Bumped so the traffic card can always fit + // table+inspector side by side (its inner HSplitView + // needs ~700pt) without compressing past its + // rounded border into a glitchy state. Old 980pt + // window minimum was below that threshold. minWidth: 1100, maxWidth: .infinity, - minHeight: 700, + minHeight: 640, maxHeight: .infinity, alignment: .topLeading ) @@ -35,12 +46,6 @@ struct ReverseAPIApp: App { .windowToolbarStyle(.unifiedCompact) .commands { CommandGroup(replacing: .newItem) {} - CommandGroup(after: .toolbar) { - Button(isSidebarVisible ? "Hide Sidebar" : "Show Sidebar") { - isSidebarVisible.toggle() - } - .keyboardShortcut("b", modifiers: .command) - } } } } @@ -58,18 +63,20 @@ final class AppLifecycle { } final class AppDelegate: NSObject, NSApplicationDelegate { - private var keyMonitor: Any? private var isTerminating = false func applicationDidFinishLaunching(_ notification: Notification) { - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && - modifiers == .command - guard togglesSidebar else { return event } - NotificationCenter.default.post(name: .toggleRaeSidebar, object: nil) - return nil - } + // `swift run` launches a bare SwiftPM executable with no .app bundle + // and no Info.plist, so macOS doesn't treat it as a regular GUI app — + // the window never reliably becomes key and AppKit text fields can't + // become first responder, which is why typing into the search palette + // and agent composer silently fails. Explicitly switching to .regular + // activation policy and activating in front of other apps gives the + // process a proper foreground app status. No-op when the binary is + // launched from a real .app bundle. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + NSApp.appearance = NSAppearance(named: .darkAqua) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -91,20 +98,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func applicationWillTerminate(_ notification: Notification) { - if let keyMonitor { - NSEvent.removeMonitor(keyMonitor) - self.keyMonitor = nil - } if !isTerminating { AppLifecycle.shared.restoreProxyBeforeExit() } } } -extension Notification.Name { - static let toggleRaeSidebar = Notification.Name("rae.toggleSidebar") -} - enum AppSession { case ready(AppState) case failed(Error) @@ -119,6 +118,50 @@ enum AppSession { } } +/// Grabs the hosting NSWindow once it's attached so we can tweak title visibility, +/// titlebar transparency, and the window background — none of which SwiftUI's +/// Scene API exposes. Uses `viewDidMoveToWindow` so the window is guaranteed +/// to exist when `configure` runs (unlike DispatchQueue.main.async hacks). +private struct WindowAccessor: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = WindowReadingView() + view.onWindow = configure + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let view = nsView as? WindowReadingView { + view.onWindow = configure + } + } + + final class WindowReadingView: NSView { + var onWindow: ((NSWindow) -> Void)? + private var configured = false + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window, !configured else { return } + configured = true + // Defer to next runloop tick — running AppKit window mutations during + // the SwiftUI hosting view's first layout can throw inside Core + // Animation's commit phase on macOS 14+. + DispatchQueue.main.async { [weak self] in + self?.onWindow?(window) + } + } + + // Never intercept mouse / keyboard events: this view exists solely to + // bridge SwiftUI to its hosting NSWindow, not to participate in the + // responder or hit-testing chain. Returning nil lets clicks fall + // through to the SwiftUI content below. + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override var acceptsFirstResponder: Bool { false } + } +} + struct BootFailureView: View { let error: Error diff --git a/macos/Sources/ReverseAPI/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift index 5fe579e..946126e 100644 --- a/macos/Sources/ReverseAPI/Storage/FlowStore.swift +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -55,6 +55,35 @@ public final class FlowStore { flows.first(where: { $0.id == id }) } + /// Remove the given flows from both the in-memory list and the database. + /// Safe to call with empty/unknown ids. Bails out without touching the + /// in-memory list if the database delete fails so the two sides don't + /// drift apart silently — caller can retry. + public func delete(ids: Set) async { + guard !ids.isEmpty else { return } + let snapshot = generation.bump() + let database = self.database + let stringIDs = ids.map { $0.uuidString } + do { + try await Task.detached(priority: .userInitiated) { + try await database.write { db in + _ = try PersistedFlow + .filter(stringIDs.contains(PersistedFlow.Columns.id)) + .deleteAll(db) + } + }.value + } catch { + logger.error("failed to delete \(ids.count) flow(s): \(error)") + return + } + guard generation.value == snapshot else { return } + let removed = flows.filter { ids.contains($0.id) } + flows.removeAll { ids.contains($0.id) } + for flow in removed { + updateFilterOptions(removing: flow) + } + } + private func handle(_ event: FlowEvent) { switch event { case .started(let flow): diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift new file mode 100644 index 0000000..c684391 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -0,0 +1,702 @@ +import SwiftUI +import AppKit +import ReverseAPIProxy + +struct AgentPanel: View { + @Environment(AppState.self) private var state + + var body: some View { + // No explicit background — both modes inherit Theme.surface from + // the enclosing Card, which keeps the agent and traffic cards + // visually matched. Previous `.background(Theme.appBackground)` + // override made the agent card read darker than the traffic + // card. + switch state.agent.mode { + case .list: + SessionsListView() + case .session: + ActiveSessionView() + } + } +} + +// MARK: - Active session + +private struct ActiveSessionView: View { + @Environment(AppState.self) private var state + + var body: some View { + @Bindable var agent = state.agent + VStack(spacing: 0) { + SessionHeader(target: $agent.target) + AgentTimeline( + events: agent.events, + flowCount: flowsToSend.count, + generatedFiles: agent.generatedFiles, + workdir: agent.lastWorkdir, + error: agent.lastError, + status: agent.status + ) + AgentComposer(input: $agent.input, status: agent.status, onSend: send) + } + } + + private var flowsToSend: [CapturedFlow] { + if !state.agentSelection.isEmpty { + return state.store.flows.filter { state.agentSelection.contains($0.id) } + } + return Array(state.store.flows.filter { state.filter.matches($0) }.prefix(100)) + } + + private func send() { + let flows = flowsToSend + Task { await state.agent.send(flows: flows) } + } +} + +// MARK: - Session header (minimal: back, language picker) + +private struct SessionHeader: View { + @Environment(AppState.self) private var state + @Binding var target: AgentTargetLanguage + + var body: some View { + HStack(spacing: 8) { + Button { + state.agent.backToList() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + } + .buttonStyle(.plain) + .help("Back to sessions") + Spacer() + LanguageMenu(target: $target) + } + .padding(.horizontal, 14) + .frame(height: 44) + } +} + +private struct LanguageMenu: View { + @Binding var target: AgentTargetLanguage + + var body: some View { + Menu { + ForEach(AgentTargetLanguage.allCases) { lang in + Button(lang.displayName) { target = lang } + } + } label: { + HStack(spacing: 4) { + Text(target.displayName) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Theme.border, lineWidth: 1) + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} + +// MARK: - Timeline + +private struct AgentTimeline: View { + let events: [AgentEvent] + let flowCount: Int + let generatedFiles: [String] + let workdir: String? + let error: String? + let status: AgentSession.Status + + var body: some View { + if events.isEmpty && error == nil && generatedFiles.isEmpty && status != .streaming { + EmptyAgentState() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.surface) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + ForEach(events) { event in + AgentEventRow(event: event) + .id(event.id) + } + if status == .streaming { + ThinkingRow() + } + if let error { + ErrorRow(message: error) + } + if !generatedFiles.isEmpty, let workdir { + GeneratedFilesRow(workdir: workdir, files: generatedFiles) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 18) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.surface) + .onChange(of: events.count) { _, _ in + if let last = events.last { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + } +} + +private struct EmptyAgentState: View { + var body: some View { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 42, weight: .light)) + .foregroundStyle(Theme.textTertiary) + } +} + +// MARK: - Event rows + +private struct AgentEventRow: View { + let event: AgentEvent + + var body: some View { + switch event { + case .userText(_, let text): + UserMessageRow(text: text) + case .assistantText(_, _, let text): + AssistantRow(text: text) + case .assistantTextChunk, .sessionStarted: + // Chunks are folded into the active assistantText event by + // AgentSession; sessionStarted is metadata only — neither is + // rendered as a standalone timeline row. + EmptyView() + case .toolUse(_, _, let name, let inputJSON): + ToolUseRow(name: name, inputJSON: inputJSON) + case .toolResult(_, _, let output, let isError): + ToolResultRow(output: output, isError: isError) + case .fileWritten(_, _, let path): + FileWrittenRow(path: path) + case .complete(_, _, _, let files): + // Only surface a completion badge when the agent actually wrote + // files. For plain Q&A the "Finished · 0 files" pill was noise. + if !files.isEmpty { + CompleteRow(fileCount: files.count) + } else { + EmptyView() + } + case .error(_, _, let message): + ErrorRow(message: message) + } + } +} + +private struct UserMessageRow: View { + let text: String + + var body: some View { + HStack { + Spacer(minLength: 40) + Text(text) + .font(.body) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 10)) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct AssistantRow: View { + let text: String + + var body: some View { + MarkdownView(text: text) + } +} + +private struct ToolUseRow: View { + let name: String + let inputJSON: String + @State private var isExpanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + if !inputJSON.isEmpty { isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + Image(systemName: iconName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 16) + Text(name) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + if let summary = inlineSummary { + Text(summary) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 4) + if !inputJSON.isEmpty { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded, !inputJSON.isEmpty { + Divider().overlay(Theme.border) + Text(inputJSON) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) + } + } + + private var iconName: String { + switch name { + case "Read": return "doc.text" + case "Write": return "square.and.pencil" + case "Edit": return "pencil" + case "Bash": return "terminal" + case "Glob", "Grep": return "magnifyingglass" + default: return "wrench.adjustable" + } + } + + /// Pull the most relevant argument out of the tool input JSON so we can + /// show "Read · flows.json" instead of just the tool name on its own row. + private var inlineSummary: String? { + guard !inputJSON.isEmpty, + let data = inputJSON.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + let candidates = ["file_path", "path", "command", "pattern", "url", "query"] + for key in candidates { + if let value = obj[key] as? String, !value.isEmpty { + if key.hasSuffix("path") { + return "· " + (value as NSString).lastPathComponent + } + return "· " + value + } + } + return nil + } +} + +private struct ToolResultRow: View { + let output: String + let isError: Bool + @State private var isExpanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 8) { + Image(systemName: isError ? "exclamationmark.octagon" : "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isError ? Theme.danger : Theme.success) + .frame(width: 16) + Text(headline) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + Divider().overlay(Theme.border) + Text(output.isEmpty ? "(empty)" : output) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(isError ? Theme.danger : Theme.textSecondary) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.leading, 24) // align under ToolUseRow's content + } + + private var headline: String { + if isError { return "Error" } + let firstLine = output.split(separator: "\n", omittingEmptySubsequences: true).first.map(String.init) ?? "" + if firstLine.isEmpty { return "Done" } + let trimmed = firstLine.trimmingCharacters(in: .whitespaces) + return trimmed.count > 80 ? String(trimmed.prefix(80)) + "…" : trimmed + } +} + +private struct FileWrittenRow: View { + @Environment(AppState.self) private var state + let path: String + + var body: some View { + Button { + state.viewFile(at: path) + } label: { + HStack(spacing: 8) { + Image(systemName: "doc.text.fill") + .foregroundStyle(Theme.success) + .font(.caption) + Text(URL(fileURLWithPath: path).lastPathComponent) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7).stroke(Theme.border, lineWidth: 1) + } + .help("View file contents") + .contextMenu { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + } + } +} + +private struct CompleteRow: View { + let fileCount: Int + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(Theme.success) + .font(.callout) + Text("Wrote \(fileCount) file\(fileCount == 1 ? "" : "s")") + .font(.callout) + .foregroundStyle(Theme.textPrimary) + Spacer() + } + } +} + +private struct ErrorRow: View { + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.danger) + .font(.callout) + Text(message) + .font(.callout) + .foregroundStyle(Theme.danger) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.danger.opacity(0.25), lineWidth: 1) + } + } +} + +private struct ThinkingRow: View { + @State private var phase: Int = 0 + private let timer = Timer.publish(every: 0.45, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 6) { + ForEach(0..<3) { i in + Circle() + .fill(Theme.textSecondary) + .frame(width: 5, height: 5) + .opacity(phase == i ? 1 : 0.3) + } + } + .onReceive(timer) { _ in + phase = (phase + 1) % 3 + } + } +} + +private struct GeneratedFilesRow: View { + @Environment(AppState.self) private var state + let workdir: String + let files: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("GENERATED FILES") + .font(.system(size: 10, weight: .semibold, design: .default)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.8) + Spacer() + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: workdir)) + } label: { + Text("Open folder") + .font(.caption) + .foregroundStyle(Theme.accent) + } + .buttonStyle(.plain) + } + VStack(alignment: .leading, spacing: 4) { + ForEach(files, id: \.self) { file in + let fullPath = (workdir as NSString).appendingPathComponent(file) + Button { + state.viewFile(at: fullPath) + } label: { + HStack(spacing: 8) { + Image(systemName: "doc") + .foregroundStyle(Theme.textTertiary) + .font(.caption) + Text(file) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 6)) + .help("View file contents") + } + } + } + .padding(.top, 4) + } +} + +// MARK: - Composer + +private struct AgentComposer: View { + @Binding var input: String + let status: AgentSession.Status + let onSend: () -> Void + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + ZStack(alignment: .topLeading) { + if input.isEmpty { + Text("Ask the agent…") + .font(.system(size: 13)) + .foregroundStyle(Theme.textTertiary) + .padding(.leading, 5) + .padding(.top, 4) + .allowsHitTesting(false) + } + NativeMultilineTextField(text: $input, onSubmit: { + if canSend { onSend() } + }) + .frame(minHeight: 22, maxHeight: 120) + } + + Button(action: { if canSend { onSend() } }) { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(canSend ? Theme.appBackground : Theme.textTertiary) + .frame(width: 28, height: 28) + .background( + canSend ? Theme.textPrimary : Theme.elevated, + in: Circle() + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .help("Send") + } + .padding(10) + .background(Theme.input, in: RoundedRectangle(cornerRadius: 12)) + .padding(12) + .background(Theme.surface) + } + + private var canSend: Bool { + guard !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + return status != .launching + } +} + +/// Multi-line NSTextView wrapped for SwiftUI. Avoids the broken SwiftUI +/// TextField(axis: .vertical) + .focused() + ZStack overlay combination, +/// which on macOS 14 fails to receive key events in certain hosting +/// configurations. Returns Enter to submit, Shift+Enter to insert a newline. +private struct NativeMultilineTextField: NSViewRepresentable { + @Binding var text: String + let onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let textView = NSTextView() + textView.delegate = context.coordinator + textView.isRichText = false + textView.isEditable = true + textView.isSelectable = true + textView.allowsUndo = true + + // Word-wrap configuration. By default NSTextView inside NSScrollView + // assumes a horizontally-growable text container — typed text spills + // to the right forever instead of wrapping. These properties pin the + // text container to the scroll view's width so lines break naturally. + let unbounded = CGFloat.greatestFiniteMagnitude + textView.minSize = NSSize(width: 0, height: 0) + textView.maxSize = NSSize(width: unbounded, height: unbounded) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + if let container = textView.textContainer { + container.widthTracksTextView = true + container.containerSize = NSSize(width: 0, height: unbounded) + container.lineFragmentPadding = 0 + } + + // Force every color path explicitly. NSTextView renders typed + // characters using `typingAttributes`, NOT `textColor` alone, and + // when `textColor` resolves through `.labelColor` against an + // unflushed appearance the result is sometimes the same near-black + // as the composer background — invisible text. Hard-code white + // so the text is always legible against `Theme.input`. + let font = NSFont.systemFont(ofSize: 13) + let typingColor = NSColor.white + textView.font = font + textView.textColor = typingColor + textView.insertionPointColor = typingColor + textView.typingAttributes = [ + .foregroundColor: typingColor, + .font: font, + ] + textView.selectedTextAttributes = [ + .backgroundColor: NSColor.selectedTextBackgroundColor, + .foregroundColor: typingColor, + ] + textView.drawsBackground = false + textView.backgroundColor = .clear + textView.textContainerInset = NSSize(width: 1, height: 4) + textView.appearance = NSAppearance(named: .darkAqua) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextCompletionEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.isAutomaticDataDetectionEnabled = false + + let scroll = NSScrollView() + scroll.documentView = textView + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.hasHorizontalScroller = false + scroll.autohidesScrollers = true + scroll.borderType = .noBorder + scroll.appearance = NSAppearance(named: .darkAqua) + + context.coordinator.textView = textView + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let textView = scroll.documentView as? NSTextView else { return } + context.coordinator.parent = self + if textView.string != text { + textView.string = text + // Setting `.string` strips attributes; re-apply our font/color + // to the whole range so the existing text stays visible too. + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 13), + ] + textView.textStorage?.setAttributes( + attributes, + range: NSRange(location: 0, length: textView.string.utf16.count) + ) + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: NativeMultilineTextField + weak var textView: NSTextView? + + init(_ parent: NativeMultilineTextField) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shift { + textView.insertNewlineIgnoringFieldEditor(nil) + } else { + parent.onSubmit() + } + return true + } + return false + } + } +} diff --git a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift b/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift deleted file mode 100644 index f235ee3..0000000 --- a/macos/Sources/ReverseAPI/UI/CaptureToolbar.swift +++ /dev/null @@ -1,431 +0,0 @@ -import SwiftUI -import AppKit -import ReverseAPIProxy -import UniformTypeIdentifiers - -struct CaptureToolbar: View { - @Environment(AppState.self) private var state - @Binding var isSidebarVisible: Bool - - var body: some View { - @Bindable var bindable = state - - return VStack(alignment: .leading, spacing: 20) { - brandHeader - - VStack(alignment: .leading, spacing: 10) { - SidebarSectionLabel("Capture") - captureButton - CaptureModePicker(selection: $bindable.captureMode) - .disabled(state.isCapturing || state.isWorking) - } - - VStack(alignment: .leading, spacing: 8) { - SidebarSectionLabel("Readiness") - SidebarStatusRow( - title: state.isCapturing ? "Proxy running" : "Proxy stopped", - detail: "127.0.0.1:\(state.port)", - systemImage: "record.circle", - tint: state.isCapturing ? .green : .secondary - ) - SidebarStatusRow( - title: state.systemProxyEnabled ? "Device routed" : "Device not routed", - detail: state.captureMode == .device ? "This Mac is automatic" : "Manual clients only", - systemImage: "network", - tint: state.systemProxyEnabled ? .green : .orange - ) - SidebarStatusRow( - title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", - detail: state.caTrustInstalled ? "HTTPS ready" : "HTTPS may fail", - systemImage: "seal", - tint: state.caTrustInstalled ? .green : .orange - ) - } - - VStack(alignment: .leading, spacing: 8) { - SidebarSectionLabel("Actions") - trustButton - systemProxyButton - exportButton - clearButton - } - - if let error = state.lastError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - .lineLimit(4) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) - } - - Spacer() - - VStack(alignment: .leading, spacing: 6) { - Text("\(state.store.flows.count)") - .font(.system(.title, design: .rounded).weight(.semibold)) - .contentTransition(.numericText()) - Text("captured flows") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10)) - } - .padding(18) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - LinearGradient( - colors: [ - Color(nsColor: .controlBackgroundColor), - Color(nsColor: .underPageBackgroundColor).opacity(0.82), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - } - - private var brandHeader: some View { - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(Color.accentColor.opacity(0.16)) - Image(systemName: "waveform.path.ecg") - .foregroundStyle(Color.accentColor) - .font(.system(size: 17, weight: .semibold)) - } - .frame(width: 36, height: 36) - - VStack(alignment: .leading, spacing: 1) { - Text("rae") - .font(.system(.title3, design: .rounded).weight(.semibold)) - Text(statusLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - Spacer() - - Button { - isSidebarVisible = false - } label: { - Image(systemName: "sidebar.left") - .font(.system(size: 15, weight: .medium)) - .frame(width: 28, height: 28) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("Hide sidebar") - } - } - - private func exportHAR() { - let panel = NSSavePanel() - panel.allowedContentTypes = [UTType(filenameExtension: "har") ?? .json, .json] - panel.nameFieldStringValue = "rae-\(Self.exportTimestamp()).har" - panel.canCreateDirectories = true - let response = panel.runModal() - guard response == .OK, let url = panel.url else { return } - let snapshot = state.store.flows - Task { - do { - try await Task.detached(priority: .userInitiated) { - let data = try HARExporter.export(snapshot) - try data.write(to: url, options: .atomic) - }.value - } catch { - await MainActor.run { - _ = NSAlert(error: error).runModal() - } - } - } - } - - private static func exportTimestamp() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd-HHmmss" - return formatter.string(from: Date()) - } - - private var captureButton: some View { - Button { - Task { await state.toggleCapture() } - } label: { - HStack(spacing: 12) { - ZStack { - Circle() - .fill(captureButtonForeground.opacity(0.16)) - Image(systemName: captureIcon) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(captureButtonForeground) - } - .frame(width: 42, height: 42) - - VStack(alignment: .leading, spacing: 2) { - Text(captureTitle) - .font(.headline.weight(.semibold)) - Text(captureSubtitle) - .font(.caption) - .foregroundStyle(captureButtonForeground.opacity(0.78)) - .lineLimit(2) - } - - Spacer() - - Image(systemName: state.isCapturing ? "stop.fill" : "arrow.right") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(captureButtonForeground.opacity(0.85)) - } - .padding(14) - .frame(maxWidth: .infinity, minHeight: 82) - .background(captureButtonBackground, in: RoundedRectangle(cornerRadius: 12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(captureButtonForeground.opacity(0.26), lineWidth: 1) - } - } - .buttonStyle(.plain) - .disabled(state.isWorking) - .help(state.captureMode == .device - ? "Start proxy capture and route macOS HTTP/HTTPS traffic through it" - : "Start proxy capture without changing macOS network settings") - .keyboardShortcut("r", modifiers: [.command]) - } - - private var trustButton: some View { - Button { - Task { - if state.caTrustInstalled { - await state.uninstallCATrust() - } else { - await state.installCATrust() - } - } - } label: { - SidebarActionLabel( - title: state.caTrustInstalled ? "Remove CA trust" : "Trust CA", - systemImage: state.caTrustInstalled ? "checkmark.seal.fill" : "seal" - ) - } - .buttonStyle(.plain) - .disabled(state.isWorking) - .help(state.caTrustInstalled - ? "Remove the rae root certificate from the current user's trust store" - : "Trust the rae root certificate so HTTPS requests can be inspected") - } - - private var systemProxyButton: some View { - Button { - Task { - if state.systemProxyEnabled { - await state.disableSystemProxy() - } else { - await state.enableSystemProxy() - } - } - } label: { - SidebarActionLabel( - title: state.systemProxyEnabled ? "Unroute device" : "Route device", - systemImage: state.systemProxyEnabled ? "network.badge.shield.half.filled" : "network" - ) - } - .buttonStyle(.plain) - .disabled(state.isWorking || (state.isCapturing && state.captureMode == .device)) - .help("Toggle macOS HTTP/HTTPS proxy for active network services") - } - - private var exportButton: some View { - Button { - exportHAR() - } label: { - SidebarActionLabel(title: "Export HAR", systemImage: "square.and.arrow.up") - } - .buttonStyle(.plain) - .disabled(state.store.flows.isEmpty || state.isWorking) - .help("Export all captured flows to a .har file") - .keyboardShortcut("e", modifiers: [.command, .shift]) - } - - private var clearButton: some View { - Button { - state.clearFlows() - } label: { - SidebarActionLabel(title: "Clear traffic", systemImage: "trash") - } - .buttonStyle(.plain) - .disabled(state.store.flows.isEmpty || state.isWorking) - .help("Remove captured flows from the list and local database") - .keyboardShortcut("k", modifiers: [.command]) - } - - private var captureTitle: String { - if state.isWorking { return "Working" } - if state.isCapturing { return "Stop capture" } - return "Start capture" - } - - private var captureIcon: String { - if state.isWorking { return "hourglass" } - return state.isCapturing ? "stop.circle.fill" : "record.circle" - } - - private var captureSubtitle: String { - if state.isWorking { return "Applying changes" } - if state.isCapturing, state.systemProxyEnabled { return "Capturing traffic from this Mac" } - if state.isCapturing { return "Listening on 127.0.0.1:\(state.port)" } - return state.captureMode == .device - ? "Routes this Mac through rae automatically" - : "Only records apps configured to use the proxy" - } - - private var captureButtonForeground: Color { - if state.isCapturing { return .red } - return .accentColor - } - - private var captureButtonBackground: LinearGradient { - LinearGradient( - colors: [ - captureButtonForeground.opacity(0.18), - captureButtonForeground.opacity(0.08), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - - private var statusLine: String { - if state.isCapturing, state.systemProxyEnabled { return "Recording this Mac" } - if state.isCapturing { return "Manual proxy active" } - return "Ready to capture" - } -} - -private struct SidebarSectionLabel: View { - let title: String - - init(_ title: String) { - self.title = title - } - - var body: some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } -} - -private struct CaptureModePicker: View { - @Binding var selection: AppState.CaptureMode - - var body: some View { - HStack(spacing: 8) { - CaptureModeButton( - title: "This Mac", - detail: "Route device traffic", - systemImage: "desktopcomputer", - isSelected: selection == .device - ) { - selection = .device - } - CaptureModeButton( - title: "Manual", - detail: "Use proxy address", - systemImage: "point.topleft.down.curvedto.point.bottomright.up", - isSelected: selection == .manual - ) { - selection = .manual - } - } - } -} - -private struct CaptureModeButton: View { - let title: String - let detail: String - let systemImage: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: systemImage) - .font(.system(size: 14, weight: .semibold)) - Spacer() - if isSelected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 13, weight: .semibold)) - } - } - Text(title) - .font(.callout.weight(.semibold)) - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - .padding(10) - .frame(maxWidth: .infinity, minHeight: 94, alignment: .topLeading) - .foregroundStyle(isSelected ? Color.primary : Color.secondary) - .background(isSelected ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 10)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.accentColor.opacity(0.55) : Color.primary.opacity(0.05), lineWidth: 1) - } - } - .buttonStyle(.plain) - } -} - -private struct SidebarStatusRow: View { - let title: String - let detail: String - let systemImage: String - let tint: Color - - var body: some View { - HStack(spacing: 10) { - Image(systemName: systemImage) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(tint) - .frame(width: 22) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.callout.weight(.medium)) - .lineLimit(1) - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - } - .padding(10) - .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9)) - } -} - -private struct SidebarActionLabel: View { - let title: String - let systemImage: String - - var body: some View { - HStack(spacing: 10) { - Image(systemName: systemImage) - .frame(width: 22) - .foregroundStyle(.secondary) - Text(title) - .font(.callout) - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) - } -} diff --git a/macos/Sources/ReverseAPI/UI/CommandPalette.swift b/macos/Sources/ReverseAPI/UI/CommandPalette.swift new file mode 100644 index 0000000..78a30f3 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/CommandPalette.swift @@ -0,0 +1,544 @@ +import SwiftUI +import AppKit +import ReverseAPIProxy + +struct CommandPalette: View { + @Environment(AppState.self) private var state + @Binding var isPresented: Bool + + @State private var query: String = "" + @State private var highlightedIndex: Int = 0 + @Namespace private var highlightNamespace + + var body: some View { + VStack(spacing: 0) { + queryHeader + HStack(spacing: 0) { + resultsList + if let flow = highlightedFlow { + previewDivider + PreviewPane(flow: flow) + } + } + .frame(maxHeight: .infinity) + } + .frame(width: 760, height: 480) + .background { + ZStack { + VisualEffect(material: .underWindowBackground, blendingMode: .behindWindow) + Theme.surface.opacity(0.86) + topHighlight + } + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + .shadow(color: .black.opacity(0.55), radius: 40, x: 0, y: 18) + .shadow(color: .black.opacity(0.35), radius: 6, x: 0, y: 3) + .onChange(of: query) { _, _ in + highlightedIndex = 0 + } + .onKeyPress(.upArrow) { + moveHighlight(-1) + return .handled + } + .onKeyPress(.downArrow) { + moveHighlight(1) + return .handled + } + .onKeyPress(.escape) { + dismiss() + return .handled + } + } + + // MARK: - Subviews + + private var topHighlight: some View { + VStack(spacing: 0) { + LinearGradient( + colors: [Color.white.opacity(0.05), Color.white.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 90) + Spacer() + } + } + + private var queryHeader: some View { + HStack(spacing: 14) { + Image(systemName: "magnifyingglass") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + + NativeSearchField( + text: $query, + placeholder: "Search traffic", + onSubmit: { select() } + ) + .frame(maxWidth: .infinity) + .frame(height: 28) + + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textTertiary) + .font(.system(size: 14)) + } + .buttonStyle(.plain) + } + + CountChip(count: results.count) + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + .background(alignment: .bottom) { + Rectangle().fill(Color.white.opacity(0.06)).frame(height: 1) + } + } + + private var resultsList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if results.isEmpty { + emptyState + } else { + ForEach(Array(results.enumerated()), id: \.element.id) { index, flow in + ResultRow( + flow: flow, + isHighlighted: index == highlightedIndex, + namespace: highlightNamespace, + onSelect: { pick(flow) } + ) + .id(flow.id) + .onHover { hovering in + if hovering { highlightedIndex = index } + } + } + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + } + .frame(width: highlightedFlow != nil ? 380 : 760) + .onChange(of: highlightedIndex) { _, new in + guard new < results.count else { return } + withAnimation(.easeOut(duration: 0.12)) { + proxy.scrollTo(results[new].id, anchor: .center) + } + } + } + } + + private var previewDivider: some View { + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(width: 1) + .frame(maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: query.isEmpty ? "tray" : "questionmark.circle") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(Theme.textTertiary) + VStack(spacing: 4) { + Text(query.isEmpty ? "Start typing to search" : "No matching traffic") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + Text(query.isEmpty + ? "Search by host, path, method, or URL" + : "Try a different query") + .font(.callout) + .foregroundStyle(Theme.textSecondary) + } + } + .frame(maxWidth: .infinity, minHeight: 240) + } + + // MARK: - Data + + private var results: [CapturedFlow] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let all = state.store.flows.reversed() + guard !trimmed.isEmpty else { + return Array(all.prefix(50)) + } + let needle = trimmed.lowercased() + return all.filter { flow in + flow.host.lowercased().contains(needle) || + flow.path.lowercased().contains(needle) || + flow.method.lowercased().contains(needle) || + flow.url.lowercased().contains(needle) + } + } + + private var highlightedFlow: CapturedFlow? { + guard highlightedIndex < results.count else { return nil } + return results[highlightedIndex] + } + + // MARK: - Actions + + private func moveHighlight(_ delta: Int) { + let count = results.count + guard count > 0 else { return } + withAnimation(.spring(response: 0.22, dampingFraction: 0.86)) { + highlightedIndex = (highlightedIndex + delta + count) % count + } + } + + private func select() { + guard let flow = highlightedFlow else { return } + pick(flow) + } + + private func pick(_ flow: CapturedFlow) { + state.selectedFlowID = flow.id + dismiss() + } + + private func dismiss() { + isPresented = false + } +} + +// MARK: - Result row + +private struct ResultRow: View { + let flow: CapturedFlow + let isHighlighted: Bool + let namespace: Namespace.ID + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + ZStack(alignment: .leading) { + if isHighlighted { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.white.opacity(0.07)) + .matchedGeometryEffect(id: "highlight", in: namespace) + } + HStack(spacing: 12) { + methodTag + statusTag + VStack(alignment: .leading, spacing: 2) { + Text(flow.host) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Text(flow.path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 8) + if isHighlighted { + Image(systemName: "return") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + } + .buttonStyle(.plain) + } + + private var methodTag: some View { + Text(flow.method) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(methodColor) + .frame(width: 54, alignment: .leading) + } + + @ViewBuilder + private var statusTag: some View { + if let status = flow.responseStatus { + Text("\(status)") + .font(.system(.caption, design: .monospaced).weight(.medium)) + .foregroundStyle(statusColor(status)) + .frame(width: 32, alignment: .leading) + } else if flow.error != nil { + Text("ERR") + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.danger) + .frame(width: 32, alignment: .leading) + } else { + Text("…") + .foregroundStyle(Theme.textTertiary) + .frame(width: 32, alignment: .leading) + } + } + + private var methodColor: Color { PaletteColors.method(flow.method) } + private func statusColor(_ status: Int) -> Color { PaletteColors.status(status) } +} + +// MARK: - Preview pane + +private struct PreviewPane: View { + let flow: CapturedFlow + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + detailGrid + if let cType = headerValue("content-type") { + metaRow(label: "Content-Type", value: cType) + } + if let cEnc = headerValue("content-encoding") { + metaRow(label: "Encoding", value: cEnc) + } + if let error = flow.error { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.danger) + .font(.caption) + Text(error) + .font(.caption) + .foregroundStyle(Theme.danger) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 379) + .background(Color.white.opacity(0.015)) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(flow.method) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + .foregroundStyle(methodColor) + if let status = flow.responseStatus { + Text("\(status)") + .font(.system(.callout, design: .monospaced).weight(.semibold)) + .foregroundStyle(statusColor(status)) + } + Spacer() + } + Text(flow.host) + .font(.headline) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Text(flow.path) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(3) + .truncationMode(.middle) + } + } + + private var detailGrid: some View { + VStack(alignment: .leading, spacing: 6) { + metaRow(label: "Type", value: TrafficFilter.resourceKind(for: flow).rawValue) + metaRow(label: "Size", value: sizeText) + metaRow(label: "Duration", value: durationText) + metaRow(label: "Time", value: flow.startedAt.formatted(date: .omitted, time: .standard)) + } + } + + private func metaRow(label: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.6) + .frame(width: 70, alignment: .leading) + Text(value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(2) + .truncationMode(.middle) + } + } + + private var sizeText: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(flow.responseBody.count)) + } + + private var durationText: String { + guard let finished = flow.finishedAt else { return "pending" } + let interval = finished.timeIntervalSince(flow.startedAt) + if interval < 1 { return String(format: "%.0f ms", interval * 1000) } + return String(format: "%.2f s", interval) + } + + private func headerValue(_ name: String) -> String? { + flow.responseHeaders.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value + } + + private var methodColor: Color { PaletteColors.method(flow.method) } + private func statusColor(_ status: Int) -> Color { PaletteColors.status(status) } +} + +// MARK: - Shared HTTP color helpers (avoid drift between palette views) + +private enum PaletteColors { + static func method(_ method: String) -> Color { + switch method { + case "GET": return Theme.methodGet + case "POST": return Theme.methodPost + case "PUT", "PATCH": return Theme.methodPut + case "DELETE": return Theme.methodDelete + case "CONNECT": return Theme.methodConnect + default: return Theme.textSecondary + } + } + + static func status(_ status: Int) -> Color { + switch status { + case 200..<300: return Theme.success + case 300..<400: return Theme.methodGet + case 400..<500: return Theme.methodPut + case 500..<600: return Theme.danger + default: return Theme.textSecondary + } + } +} + +// MARK: - Count chip + +private struct CountChip: View { + let count: Int + + var body: some View { + Text("\(count)") + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textTertiary) + .monospacedDigit() + .contentTransition(.numericText()) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.white.opacity(0.05), in: Capsule()) + } +} + +// MARK: - Native text field with guaranteed auto-focus + +/// AppKit NSTextField wrapped for SwiftUI. Used instead of SwiftUI's +/// TextField + @FocusState because the latter doesn't reliably receive +/// keystrokes when the palette is presented as an overlay on macOS 14 — +/// the responder chain ends up with the wrong first responder and typing +/// goes nowhere. This wrapper grabs first-responder status explicitly the +/// moment the field is attached to the window. +private struct NativeSearchField: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.delegate = context.coordinator + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + field.font = .systemFont(ofSize: 18, weight: .regular) + // Hard-code white instead of semantic colors. The .labelColor / + // .tertiaryLabelColor resolution against a partially-loaded dark + // appearance can land on values that match the panel background. + field.textColor = .white + field.placeholderAttributedString = NSAttributedString( + string: placeholder, + attributes: [ + .font: NSFont.systemFont(ofSize: 18, weight: .regular), + .foregroundColor: NSColor.white.withAlphaComponent(0.35), + ] + ) + field.stringValue = text + field.appearance = NSAppearance(named: .darkAqua) + field.cell?.usesSingleLineMode = true + field.cell?.wraps = false + field.cell?.isScrollable = true + + // Take first responder once the view has a window, and configure the + // shared field editor so the caret and selection are visible too. + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + if let editor = field.currentEditor() as? NSTextView { + editor.insertionPointColor = .white + editor.typingAttributes = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 18, weight: .regular), + ] + editor.selectedTextAttributes = [ + .backgroundColor: NSColor.selectedTextBackgroundColor, + .foregroundColor: NSColor.white, + ] + } + } + + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + context.coordinator.parent = self + if field.stringValue != text { + field.stringValue = text + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: NativeSearchField + + init(_ parent: NativeSearchField) { + self.parent = parent + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.onSubmit() + return true + } + return false + } + } +} + +// MARK: - Visual effect + +struct VisualEffect: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + view.isEmphasized = true + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.material = material + view.blendingMode = blendingMode + } +} diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 713e06c..6a14982 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -1,67 +1,447 @@ import SwiftUI import ReverseAPIProxy import AppKit +import UniformTypeIdentifiers struct ContentView: View { @Environment(AppState.self) private var state - @Binding var isSidebarVisible: Bool + @State private var isPaletteVisible: Bool = false + @State private var trafficWidth: CGFloat = 720 + @State private var dragStartWidth: CGFloat? var body: some View { - HStack(spacing: 0) { - if isSidebarVisible { - CaptureToolbar(isSidebarVisible: $isSidebarVisible) - .frame(width: 312) - Divider() - } else { - CollapsedSidebarRail(isSidebarVisible: $isSidebarVisible) - Divider() + ZStack { + VStack(spacing: 0) { + ActionBar(onOpenPalette: { isPaletteVisible = true }) + GeometryReader { geo in + let layout = SplitLayout( + totalWidth: geo.size.width, + trafficWidth: trafficWidth, + hasInspector: state.selectedFlowID != nil + ) + HStack(spacing: 0) { + Card { + HSplitView { + TrafficListView() + .frame(minWidth: 320, maxHeight: .infinity) + if state.selectedFlowID != nil { + InspectorView() + .frame(minWidth: 340, idealWidth: 480, maxHeight: .infinity) + } + } + } + .frame(width: layout.trafficWidth) + DragHandle(width: SplitLayout.handleWidth) { delta in + // First gesture tick: snapshot the current width so + // we don't accumulate the same offset every frame. + if dragStartWidth == nil { + dragStartWidth = layout.trafficWidth + } + trafficWidth = (dragStartWidth ?? 0) + delta + } onEnded: { + dragStartWidth = nil + // Pin to the clamped value so the next gesture + // starts from where the user actually let go. + trafficWidth = layout.trafficWidth + } + Card { + AgentPanel() + } + .frame(width: layout.agentWidth) + } + .padding(12) + .onChange(of: state.selectedFlowID) { _, newID in + // When the inspector opens, the traffic card's + // effective minimum jumps from 380 → 720. Re-clamp + // the user's last manual width through SplitLayout + // so the card auto-grows instead of clipping past + // its rounded border. + let next = SplitLayout( + totalWidth: geo.size.width, + trafficWidth: trafficWidth, + hasInspector: newID != nil + ) + if trafficWidth != next.trafficWidth { + withAnimation(.easeOut(duration: 0.2)) { + trafficWidth = next.trafficWidth + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - HSplitView { - TrafficListView() - .frame(minWidth: 600, maxHeight: .infinity) - InspectorView() - .frame(minWidth: 460, maxHeight: .infinity) + // Dim layer (fades alone) + if isPaletteVisible { + Theme.overlay + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { isPaletteVisible = false } + .transition(.opacity) + .zIndex(9) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Palette panel (scales + fades) + if isPaletteVisible { + CommandPalette(isPresented: $isPaletteVisible) + .transition( + .scale(scale: 0.94, anchor: .center) + .combined(with: .opacity) + .combined(with: .offset(y: -8)) + ) + .zIndex(10) + } + } + .animation(.spring(response: 0.32, dampingFraction: 0.84), value: isPaletteVisible) + .sheet(item: Binding( + get: { state.viewingFile }, + set: { state.viewingFile = $0 } + )) { ref in + AgentFileViewer(url: ref.url, isPresented: Binding( + get: { state.viewingFile != nil }, + set: { if !$0 { state.viewingFile = nil } } + )) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Theme.appBackground) + .preferredColorScheme(.dark) + .toolbar { toolbarContent } .task { await state.recoverStaleSystemProxyOnLaunch() } - .onReceive(NotificationCenter.default.publisher(for: .toggleRaeSidebar)) { _ in - isSidebarVisible.toggle() - } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in state.restoreProxyBeforeExit() } } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + ActionsMenu() + CaptureButton() + } + } +} + +// MARK: - Card + +/// Inset card with rounded corners + subtle border. Used for the traffic +/// and agent containers so they read as discrete panels against the app +/// background rather than blending into a single wall of UI. +struct Card: View { + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + } + } +} + +// MARK: - Outer split between traffic + agent cards + +/// Resolves the actual widths of the two cards from the current geometry, +/// the user-driven trafficWidth state, and whether the inspector is open. +/// Pulls the math out of ContentView.body so layout decisions live next to +/// the constants they depend on. +private struct SplitLayout { + static let handleWidth: CGFloat = 12 + static let outerPadding: CGFloat = 12 + static let trafficMinNoInspector: CGFloat = 380 + static let trafficMinWithInspector: CGFloat = 720 + static let agentMin: CGFloat = 340 + + let trafficWidth: CGFloat + let agentWidth: CGFloat + + init(totalWidth: CGFloat, trafficWidth proposed: CGFloat, hasInspector: Bool) { + let usable = max(0, totalWidth - 2 * Self.outerPadding - Self.handleWidth) + let trafficMin = hasInspector ? Self.trafficMinWithInspector : Self.trafficMinNoInspector + // Ceiling so the agent card never gets squashed below its own + // minimum, even when the user drags the handle hard against the + // right edge of the window. + let trafficMax = max(trafficMin, usable - Self.agentMin) + let clamped = min(max(proposed, trafficMin), trafficMax) + self.trafficWidth = clamped + self.agentWidth = max(Self.agentMin, usable - clamped) + } +} + +/// Transparent vertical strip between the two cards. The body itself is +/// invisible — we just need a hit-target for the drag gesture. NSCursor +/// flips to resizeLeftRight when the pointer hovers, so the affordance is +/// the cursor rather than a visible bar. +private struct DragHandle: View { + let width: CGFloat + let onDrag: (CGFloat) -> Void + let onEnded: () -> Void + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(width: width) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture() + .onChanged { value in onDrag(value.translation.width) } + .onEnded { _ in onEnded() } + ) + } +} + +// MARK: - Thin divider + +struct ThinDivider: View { + var body: some View { + Rectangle() + .fill(Theme.border) + .frame(height: 1) + } +} + +// MARK: - Capture button (icon-only, neutral) + +private struct CaptureButton: View { + @Environment(AppState.self) private var state + + var body: some View { + Button { + Task { await state.toggleCapture() } + } label: { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 22, height: 22) + } + .buttonStyle(.borderless) + .help(state.isCapturing ? "Stop capture" : "Start capture") + .disabled(state.isWorking) + } + + private var icon: String { + if state.isWorking { return "hourglass" } + return state.isCapturing ? "stop.circle.fill" : "record.circle" + } + + private var tint: Color { + if state.isCapturing { return Theme.danger } + return Theme.textSecondary + } +} + +// MARK: - Actions menu + +private struct ActionsMenu: View { + @Environment(AppState.self) private var state + + var body: some View { + Menu { + Section { + Picker("Mode", selection: Binding( + get: { state.captureMode }, + set: { state.captureMode = $0 } + )) { + ForEach(AppState.CaptureMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .disabled(state.isCapturing || state.isWorking) + } + + Section { + if state.caTrustInstalled { + Button("Remove CA trust") { + Task { await state.uninstallCATrust() } + } + } else { + Button("Trust CA") { + Task { await state.installCATrust() } + } + } + if state.systemProxyEnabled { + Button("Stop routing this Mac") { + Task { await state.disableSystemProxy() } + } + .disabled(state.isCapturing && state.captureMode == .device) + } else { + Button("Route this Mac through rae") { + Task { await state.enableSystemProxy() } + } + .disabled(state.isCapturing && state.captureMode == .device) + } + } + + Section { + Button("Export HAR…") { exportHAR() } + .disabled(state.store.flows.isEmpty) + Button("Clear traffic") { + state.clearFlows() + } + .disabled(state.store.flows.isEmpty) + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .help("More actions") + } + + private func exportHAR() { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "har") ?? .json, .json] + panel.nameFieldStringValue = "rae-\(Self.timestamp()).har" + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + let snapshot = state.store.flows + Task { + do { + try await Task.detached(priority: .userInitiated) { + let data = try HARExporter.export(snapshot) + try data.write(to: url, options: .atomic) + }.value + } catch { + await MainActor.run { _ = NSAlert(error: error).runModal() } + } + } + } + + private static func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter.string(from: Date()) + } } -private struct CollapsedSidebarRail: View { - @Binding var isSidebarVisible: Bool +// MARK: - Action bar (status + search) + +private struct ActionBar: View { + @Environment(AppState.self) private var state + let onOpenPalette: () -> Void var body: some View { - VStack(spacing: 14) { - Button { - isSidebarVisible = true - } label: { - Image(systemName: "sidebar.left") - .font(.system(size: 16, weight: .medium)) - .frame(width: 30, height: 30) + VStack(spacing: 0) { + if let error = state.lastError { + ErrorBanner(message: error) + } + HStack(spacing: 14) { + CaptureStateChip() + CATrustChip() + Spacer() + SearchButton(action: onOpenPalette) } - .buttonStyle(.plain) - .help("Show sidebar") + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .background(Theme.appBackground) + } +} - Image(systemName: "waveform.path.ecg") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Color.accentColor) +private struct ErrorBanner: View { + let message: String + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Theme.warn) + Text(message) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .lineLimit(3) Spacer() } - .padding(.top, 14) - .frame(minWidth: 48, idealWidth: 48, maxWidth: 48, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Theme.warn.opacity(0.12)) + .overlay(alignment: .bottom) { + Rectangle().fill(Theme.warn.opacity(0.25)).frame(height: 1) + } + } +} + +// MARK: - Inline status chips (live in the ActionBar) + +private struct CaptureStateChip: View { + @Environment(AppState.self) private var state + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 6, height: 6) + Text(label) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .monospacedDigit() + } + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Theme.input, in: Capsule()) + .help("Capture state · 127.0.0.1:\(state.port)") + } + + private var dotColor: Color { + if state.isWorking { return .yellow } + if state.isCapturing { return Theme.success } + return Theme.textTertiary + } + + private var label: String { + if state.isWorking { return "working…" } + if state.isCapturing { return "recording · \(state.port)" } + return "idle · \(state.port)" + } +} + +private struct CATrustChip: View { + @Environment(AppState.self) private var state + + var body: some View { + HStack(spacing: 6) { + Image(systemName: state.caTrustInstalled ? "checkmark.seal.fill" : "seal") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(state.caTrustInstalled ? Theme.success : Theme.textTertiary) + Text(state.caTrustInstalled ? "CA trusted" : "CA") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Theme.input, in: Capsule()) + .help(state.caTrustInstalled + ? "Root CA installed — HTTPS can be inspected" + : "Root CA not trusted — HTTPS will fail") + } +} + +// MARK: - Search button (opens command palette) + +private struct SearchButton: View { + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isHovering ? PillStyle.activeBackground : PillStyle.hoverBackground, in: Capsule()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help("Search captured traffic") } } diff --git a/macos/Sources/ReverseAPI/UI/Controls.swift b/macos/Sources/ReverseAPI/UI/Controls.swift new file mode 100644 index 0000000..7587856 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/Controls.swift @@ -0,0 +1,85 @@ +import SwiftUI +import AppKit + +/// Native `NSSegmentedControl` wrapped for SwiftUI with `.capsule` segment style. +/// Used by ModeToggle and InspectorView tabs so all segmented pickers share the +/// same visual + interaction behavior (focus ring, accessibility, tracking). +struct NSSegmented: NSViewRepresentable { + let labels: [String] + @Binding var selection: Int + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSSegmentedControl { + let control = NSSegmentedControl( + labels: labels, + trackingMode: .selectOne, + target: context.coordinator, + action: #selector(Coordinator.changed(_:)) + ) + control.segmentStyle = .capsule + control.controlSize = .small + control.font = .systemFont(ofSize: 11, weight: .medium) + control.selectedSegment = selection + control.appearance = NSAppearance(named: .darkAqua) + control.setContentHuggingPriority(.required, for: .horizontal) + control.setContentCompressionResistancePriority(.required, for: .horizontal) + return control + } + + func updateNSView(_ control: NSSegmentedControl, context: Context) { + context.coordinator.parent = self + if control.segmentCount != labels.count { + control.segmentCount = labels.count + } + for (i, label) in labels.enumerated() { + if control.label(forSegment: i) != label { + control.setLabel(label, forSegment: i) + } + } + if control.selectedSegment != selection { + control.selectedSegment = selection + } + } + + final class Coordinator: NSObject { + var parent: NSSegmented + init(_ parent: NSSegmented) { self.parent = parent } + + @objc func changed(_ sender: NSSegmentedControl) { + parent.selection = sender.selectedSegment + } + } +} + +/// Visual primitives shared by all pill-shaped controls (Chip, SearchButton…). +/// Keeps hover and active states consistent across the app. +enum PillStyle { + static let shape = Capsule() + static let hoverBackground = Color.white.opacity(0.04) + static let activeBackground = Color.white.opacity(0.08) + static let activeBorder = Color.white.opacity(0.12) +} + +/// Single-state pill (used for SearchButton, filter chips, etc.). +struct PillBackground: ViewModifier { + let isActive: Bool + let isHovering: Bool + + func body(content: Content) -> some View { + content + .background(background, in: Capsule()) + } + + private var background: Color { + if isActive { return PillStyle.activeBackground } + if isHovering { return PillStyle.hoverBackground } + return Color.clear + } +} + +extension View { + func pillBackground(isActive: Bool, isHovering: Bool) -> some View { + modifier(PillBackground(isActive: isActive, isHovering: isHovering)) + } +} diff --git a/macos/Sources/ReverseAPI/UI/FileViewer.swift b/macos/Sources/ReverseAPI/UI/FileViewer.swift new file mode 100644 index 0000000..d4a401f --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/FileViewer.swift @@ -0,0 +1,237 @@ +import SwiftUI +import AppKit + +/// Sheet that pops over the window when the user taps a file the agent +/// wrote. Reads the file lazily, shows it monospaced with line numbers +/// against `Theme.appBackground`, and adapts the chrome (language label, +/// byte count) so it reads a bit like a diff viewer / GitHub blob preview. +struct AgentFileViewer: View { + let url: URL + @Binding var isPresented: Bool + + @State private var content: String = "" + @State private var loadError: String? + @State private var isLoading: Bool = true + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(Theme.border) + body(content) + } + .frame(minWidth: 720, idealWidth: 880, minHeight: 480, idealHeight: 640) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.border, lineWidth: 1) + } + .task(id: url) { await load() } + } + + // MARK: - Subviews + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: iconForExtension) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + VStack(alignment: .leading, spacing: 2) { + Text(url.lastPathComponent) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + if let metadata { + Text(metadata) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + Spacer() + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .help("Reveal in Finder") + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(content, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .help("Copy contents") + .disabled(content.isEmpty) + + Button { + isPresented = false + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .help("Close") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + @ViewBuilder + private func body(_ content: String) -> some View { + if isLoading { + VStack { + ProgressView() + .controlSize(.small) + .tint(Theme.textSecondary) + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else if let loadError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(Theme.danger) + Text("Couldn't open file") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + Text(loadError) + .font(.callout) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } else { + ScrollView([.vertical, .horizontal]) { + HStack(alignment: .top, spacing: 0) { + LineGutter(lineCount: lineCount(content)) + Text(content) + .font(.system(size: 12.5, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .lineSpacing(2) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Theme.appBackground) + } + } + + // MARK: - Loading + + private func load() async { + isLoading = true + loadError = nil + let url = self.url + do { + let text = try await Task.detached(priority: .userInitiated) { () -> String in + let data = try Data(contentsOf: url) + if let utf8 = String(data: data, encoding: .utf8) { return utf8 } + return "" + }.value + content = text + } catch { + loadError = error.localizedDescription + } + isLoading = false + } + + private func lineCount(_ text: String) -> Int { + if text.isEmpty { return 1 } + var count = 0 + for char in text where char == "\n" { count += 1 } + if text.last != "\n" { count += 1 } + return count + } + + // MARK: - Metadata + + private var metadata: String? { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + let bytes = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + let language = languageLabel + if let language { return "\(language) · \(bytes)" } + return bytes + } + + private var languageLabel: String? { + let ext = url.pathExtension.lowercased() + switch ext { + case "py": return "Python" + case "ts", "tsx": return "TypeScript" + case "js", "mjs": return "JavaScript" + case "go": return "Go" + case "swift": return "Swift" + case "json": return "JSON" + case "md", "markdown": return "Markdown" + case "html", "htm": return "HTML" + case "css": return "CSS" + case "yaml", "yml": return "YAML" + case "toml": return "TOML" + case "sh", "bash", "zsh": return "Shell" + case "rb": return "Ruby" + case "rs": return "Rust" + case "java": return "Java" + case "kt": return "Kotlin" + case "": return nil + default: return ext.uppercased() + } + } + + private var iconForExtension: String { + let ext = url.pathExtension.lowercased() + switch ext { + case "py", "ts", "tsx", "js", "mjs", "go", "swift", "rs", "rb", "java", "kt": + return "curlybraces" + case "json", "yaml", "yml", "toml": + return "doc.text" + case "md", "markdown": + return "doc.richtext" + case "html", "htm", "css": + return "globe" + case "sh", "bash", "zsh": + return "terminal" + default: + return "doc" + } + } +} + +private struct LineGutter: View { + let lineCount: Int + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + ForEach(1...max(1, lineCount), id: \.self) { line in + Text("\(line)") + .font(.system(size: 11.5, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .lineSpacing(2) + .frame(minWidth: 32, alignment: .trailing) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + .frame(maxHeight: .infinity, alignment: .top) + .background(Theme.surface) + .overlay(alignment: .trailing) { + Rectangle().fill(Theme.border).frame(width: 1) + } + } +} diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index 4b24f1a..7b25907 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import WebKit import ReverseAPIProxy struct InspectorView: View { @@ -9,108 +10,136 @@ struct InspectorView: View { if let id = state.selectedFlowID, let flow = state.store.flow(id: id) { FlowInspector(flow: flow) } else { - VStack(spacing: 16) { - ZStack { - RoundedRectangle(cornerRadius: 18) - .fill(Color.primary.opacity(0.055)) - .frame(width: 70, height: 70) - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: 30, weight: .medium)) - .foregroundStyle(.secondary) - } + EmptyInspector() + } + } +} - VStack(spacing: 6) { - Text("No request selected") - .font(.title3.weight(.semibold)) - Text("Pick a row from traffic to inspect headers, timing, and body data.") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 330) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) +private struct EmptyInspector: View { + var body: some View { + VStack(spacing: 10) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(Theme.textTertiary) + Text("No request selected") + .font(.headline) + .foregroundStyle(Theme.textPrimary) + Text("Pick a row from the traffic list.") + .font(.callout) + .foregroundStyle(Theme.textSecondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.surface) } } +enum InspectorTab: String, CaseIterable, Identifiable { + case overview = "Overview" + case request = "Request" + case response = "Response" + case preview = "Preview" + var id: String { rawValue } +} + private struct FlowInspector: View { let flow: CapturedFlow @State private var tab: InspectorTab = .request - - enum InspectorTab: String, CaseIterable, Identifiable { - case overview = "Overview" - case request = "Request" - case response = "Response" - var id: String { rawValue } - } + @Environment(AppState.self) private var state var body: some View { VStack(alignment: .leading, spacing: 0) { header - Picker("", selection: $tab) { - ForEach(InspectorTab.allCases) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 14) - .padding(.vertical, 10) - Divider() + ThinDivider() + tabBar + ThinDivider() ScrollView { content .frame(maxWidth: .infinity, alignment: .leading) .padding(14) } } - .background(Color(nsColor: .controlBackgroundColor)) + .background(Theme.surface) + .onChange(of: flow.id) { _, _ in + // Reset tab when switching selection, fallback if current tab no longer available + if !availableTabs.contains(tab) { + tab = .request + } + } + } + + private var tabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + NSSegmented( + labels: availableTabs.map { $0.rawValue }, + selection: Binding( + get: { availableTabs.firstIndex(of: tab) ?? 0 }, + set: { tab = availableTabs[$0] } + ) + ) + .fixedSize() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + + private var availableTabs: [InspectorTab] { + var tabs: [InspectorTab] = [.overview, .request, .response] + if previewKind != nil { tabs.append(.preview) } + return tabs } private var header: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { Text(flow.method) .font(.system(.callout, design: .monospaced).weight(.semibold)) .foregroundStyle(methodColor) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(methodColor.opacity(0.14), in: RoundedRectangle(cornerRadius: 5)) if let status = flow.responseStatus { Text("\(status)") .font(.system(.callout, design: .monospaced).weight(.semibold)) .foregroundStyle(statusColor(status)) } - Text(TrafficFilter.resourceKind(for: flow).rawValue) - .font(.system(.caption, design: .monospaced).weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 5)) - Spacer() + Spacer(minLength: 4) if let finishedAt = flow.finishedAt { Text(formatDuration(flow.startedAt, finishedAt)) - .foregroundStyle(.secondary) - .font(.callout) + .foregroundStyle(Theme.textTertiary) + .font(.caption) + .monospacedDigit() } copyMenu + Button { + state.selectedFlowID = nil + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + } + .buttonStyle(.plain) + .help("Close inspector") } - VStack(alignment: .leading, spacing: 2) { + + VStack(alignment: .leading, spacing: 1) { Text(flow.host) - .font(.headline) + .font(.callout) + .foregroundStyle(Theme.textPrimary) .lineLimit(1) .truncationMode(.middle) - Text(flow.url) + Text(flow.path) .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) .textSelection(.enabled) .lineLimit(2) .truncationMode(.middle) } + if let error = flow.error { Label(error, systemImage: "exclamationmark.octagon.fill") - .foregroundStyle(.red) - .font(.callout) + .foregroundStyle(Theme.danger) + .font(.caption) } } .padding(.horizontal, 14) @@ -120,22 +149,17 @@ private struct FlowInspector: View { private var copyMenu: some View { Menu { - Button("Copy request", systemImage: "arrow.up.doc") { - copyToPasteboard(requestCopyText) - } - Button("Copy response", systemImage: "arrow.down.doc") { - copyToPasteboard(responseCopyText) - } + Button("Copy request") { copyToPasteboard(requestCopyText) } + Button("Copy response") { copyToPasteboard(responseCopyText) } Divider() - Button("Copy URL", systemImage: "link") { - copyToPasteboard(flow.url) - } + Button("Copy URL") { copyToPasteboard(flow.url) } } label: { - Label("Copy", systemImage: "doc.on.doc") + Image(systemName: "doc.on.doc") + .foregroundStyle(Theme.textSecondary) } .menuStyle(.borderlessButton) .fixedSize() - .help("Copy this request or response") + .help("Copy") } @ViewBuilder @@ -144,63 +168,89 @@ private struct FlowInspector: View { case .overview: overview case .request: - VStack(alignment: .leading, spacing: 12) { - HeadersSection(title: "Request headers", headers: flow.requestHeaders) - BodySection(title: "Request body", bodyData: flow.requestBody, headers: flow.requestHeaders) + VStack(alignment: .leading, spacing: 18) { + HeadersSection(title: "Headers", headers: flow.requestHeaders) + BodySection(title: "Body", bodyData: flow.requestBody, headers: flow.requestHeaders) } case .response: - VStack(alignment: .leading, spacing: 12) { - HeadersSection(title: "Response headers", headers: flow.responseHeaders) - BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) + VStack(alignment: .leading, spacing: 18) { + HeadersSection(title: "Headers", headers: flow.responseHeaders) + BodySection(title: "Body", bodyData: flow.responseBody, headers: flow.responseHeaders) + } + case .preview: + if let kind = previewKind { + PreviewPaneContent( + data: flow.responseBody, + kind: kind, + contentType: responseHeader("content-type") + ) + } else { + Text("Nothing to preview") + .foregroundStyle(Theme.textTertiary) + .font(.callout) } } } private var overview: some View { - VStack(alignment: .leading, spacing: 12) { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 10)], spacing: 10) { - MetricCard(title: "Status", value: flow.responseStatus.map(String.init) ?? "Pending") - MetricCard(title: "Duration", value: durationValue) - MetricCard(title: "Request", value: byteString(flow.requestBody.count)) - MetricCard(title: "Response", value: byteString(flow.responseBody.count)) - } - - DetailPanel(title: "Request") { + VStack(alignment: .leading, spacing: 18) { + Section(label: "Request") { row("Scheme", flow.scheme.rawValue) row("Host", "\(flow.host):\(flow.port)") - row("Path", flow.path) row("Method", flow.method) + row("Path", flow.path) } - DetailPanel(title: "Timing") { - row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) - row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "Pending") - } - - DetailPanel(title: "Response") { - row("Status", flow.responseStatus.map(String.init) ?? "Pending") + Section(label: "Response") { + row("Status", flow.responseStatus.map(String.init) ?? "pending") row("Type", TrafficFilter.resourceKind(for: flow).rawValue) - row("Content-Type", headerValue("content-type", in: flow.responseHeaders) ?? "None") - row("Content-Encoding", headerValue("content-encoding", in: flow.responseHeaders) ?? "None") - row("Body", byteString(flow.responseBody.count)) + row("Content-Type", responseHeader("content-type") ?? "—") + row("Encoding", responseHeader("content-encoding") ?? "—") + row("Size", byteString(flow.responseBody.count)) } - BodySection(title: "Response body", bodyData: flow.responseBody, headers: flow.responseHeaders) + Section(label: "Timing") { + row("Started", flow.startedAt.formatted(date: .abbreviated, time: .standard)) + row("Finished", flow.finishedAt?.formatted(date: .abbreviated, time: .standard) ?? "pending") + if let finished = flow.finishedAt { + row("Duration", formatDuration(flow.startedAt, finished)) + } + } } } private func row(_ key: String, _ value: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(key) - .frame(width: 120, alignment: .leading) - .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + .foregroundStyle(Theme.textTertiary) + .font(.caption) Text(value) .textSelection(.enabled) .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(2) + .truncationMode(.middle) } } + // MARK: - Preview availability + + private var previewKind: PreviewKind? { + guard let ct = responseHeader("content-type")?.lowercased() else { return nil } + if ct.hasPrefix("image/") && NSImage(data: flow.responseBody) != nil { return .image } + if ct.contains("html") { return .html } + if ct.contains("pdf") { return .pdf } + return nil + } + + private func responseHeader(_ name: String) -> String? { + flow.responseHeaders.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value + } + + // MARK: - Helpers + private func formatDuration(_ start: Date, _ end: Date) -> String { let interval = end.timeIntervalSince(start) if interval < 1 { return String(format: "%.0f ms", interval * 1000) } @@ -209,29 +259,24 @@ private struct FlowInspector: View { private var methodColor: Color { switch flow.method { - case "GET": return .blue - case "POST": return .green - case "PUT", "PATCH": return .orange - case "DELETE": return .red - default: return .secondary + case "GET": return Theme.methodGet + case "POST": return Theme.methodPost + case "PUT", "PATCH": return Theme.methodPut + case "DELETE": return Theme.methodDelete + default: return Theme.textSecondary } } private func statusColor(_ status: Int) -> Color { switch status { - case 200..<300: return .green - case 300..<400: return .blue - case 400..<500: return .orange - case 500..<600: return .red - default: return .secondary + case 200..<300: return Theme.success + case 300..<400: return Theme.methodGet + case 400..<500: return Theme.methodPut + case 500..<600: return Theme.danger + default: return Theme.textSecondary } } - private var durationValue: String { - guard let finishedAt = flow.finishedAt else { return "Pending" } - return formatDuration(flow.startedAt, finishedAt) - } - private func byteString(_ count: Int) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file @@ -263,10 +308,17 @@ private struct FlowInspector: View { } private func copyableBody(_ data: Data, headers: [HTTPHeader]) -> String { - if let pretty = JSONFormatter.prettyPrintJSON(data, contentType: contentType(in: headers)) { + let ct = headers.first(where: { $0.name.caseInsensitiveCompare("content-type") == .orderedSame })?.value + if let pretty = JSONFormatter.prettyPrintJSON(data, contentType: ct) { return pretty } - if let text = String(data: data, encoding: .utf8), looksLikeText(headers) { + // If the bytes decode as UTF-8, prefer the text dump regardless of + // the content-type advertising "text/*". A lot of API responses + // (JSON without a Content-Type, application/xml, application/csp-report, + // application/x-www-form-urlencoded, etc.) are perfectly readable + // strings — gating on `text` in the MIME type sent them all into + // the base64 fallback. + if let text = String(data: data, encoding: .utf8) { return text } return """ @@ -276,72 +328,256 @@ private struct FlowInspector: View { """ } - private func contentType(in headers: [HTTPHeader]) -> String? { - headerValue("content-type", in: headers) + private func copyToPasteboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) } +} - private func headerValue(_ name: String, in headers: [HTTPHeader]) -> String? { - headers.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value +// MARK: - Preview kinds + +enum PreviewKind { + case image + case html + case pdf +} + +private struct PreviewPaneContent: View { + let data: Data + let kind: PreviewKind + let contentType: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + previewBadge + content + } } - private func looksLikeText(_ headers: [HTTPHeader]) -> Bool { - guard let ct = contentType(in: headers)?.lowercased() else { return false } - return ct.contains("text") || - ct.contains("json") || - ct.contains("xml") || - ct.contains("javascript") || - ct.contains("html") || - ct.contains("event-stream") || - ct.contains("x-www-form-urlencoded") || - ct.contains("graphql") || - ct.contains("csv") + private var previewBadge: some View { + HStack(spacing: 6) { + Image(systemName: badgeIcon) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + Text(badgeLabel) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + Spacer() + } } - private func copyToPasteboard(_ text: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + @ViewBuilder + private var content: some View { + switch kind { + case .image: + ImagePreview(data: data) + case .html: + // Pass a nil baseURL so WKWebView treats the captured response + // as a standalone document — otherwise it resolves relative + // `` / `` / `