From d85da951e821693b7780bbf36045db9427f636d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 04:53:17 +0000 Subject: [PATCH 01/45] M5: Python agent sidecar + SwiftUI agent panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/ — new Python package (rae-agent) - pyproject.toml with websockets + claude-agent-sdk deps - protocol.py: typed ChatRequest / FlowSummary / AgentEvent dataclasses with payload validation - prompts.py: system prompt + per-language guidelines (Python, TypeScript, Go) - session.py: per-chat workdir, persists selected flows to JSON, drives claude_agent_sdk.query, translates Assistant / Tool / Result blocks into AgentEvent stream - server.py: websockets server, RAE_AGENT_LISTENING: handshake - pytest suite for the protocol layer macos/Sources/ReverseAPI/Agent/ - AgentSidecar: launches the Python backend, parses bound port from stdout, manages lifecycle - AgentClient: actor wrapping URLSessionWebSocketTask, AsyncStream of decoded AgentEvents - AgentProtocol: AgentEvent / AgentFlowPayload / AgentChatRequest + AgentEventDecoder - AgentSession: @MainActor @Observable that coordinates sidecar + client, exposes status / events / history / generated files macos/Sources/ReverseAPI/UI/AgentPanel.swift - Header with status dot + target language picker + clear - Timeline of assistant text / tool use / tool result / file written / errors / completion card - Generated files card with reveal-in-Finder - Composer with cmd+return shortcut ContentView now wires AgentPanel as a third HSplitView column; AppState owns the AgentSession with a workdir under Application Support. --- backend/pyproject.toml | 23 ++ backend/rae_agent/__init__.py | 15 + backend/rae_agent/prompts.py | 65 ++++ backend/rae_agent/protocol.py | 122 +++++++ backend/rae_agent/server.py | 76 ++++ backend/rae_agent/session.py | 117 ++++++ backend/tests/__init__.py | 0 backend/tests/test_protocol.py | 80 +++++ .../ReverseAPI/Agent/AgentClient.swift | 67 ++++ .../ReverseAPI/Agent/AgentProtocol.swift | 144 ++++++++ .../ReverseAPI/Agent/AgentSession.swift | 130 +++++++ .../ReverseAPI/Agent/AgentSidecar.swift | 72 ++++ macos/Sources/ReverseAPI/App/AppState.swift | 5 + macos/Sources/ReverseAPI/UI/AgentPanel.swift | 332 ++++++++++++++++++ macos/Sources/ReverseAPI/UI/ContentView.swift | 6 +- 15 files changed, 1252 insertions(+), 2 deletions(-) create mode 100644 backend/pyproject.toml create mode 100644 backend/rae_agent/__init__.py create mode 100644 backend/rae_agent/prompts.py create mode 100644 backend/rae_agent/protocol.py create mode 100644 backend/rae_agent/server.py create mode 100644 backend/rae_agent/session.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_protocol.py create mode 100644 macos/Sources/ReverseAPI/Agent/AgentClient.swift create mode 100644 macos/Sources/ReverseAPI/Agent/AgentProtocol.swift create mode 100644 macos/Sources/ReverseAPI/Agent/AgentSession.swift create mode 100644 macos/Sources/ReverseAPI/Agent/AgentSidecar.swift create mode 100644 macos/Sources/ReverseAPI/UI/AgentPanel.swift 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..1fd00af --- /dev/null +++ b/backend/rae_agent/__init__.py @@ -0,0 +1,15 @@ +from rae_agent.protocol import ( + AgentEvent, + ChatRequest, + FlowSummary, + ProtocolError, + TargetLanguage, +) + +__all__ = [ + "AgentEvent", + "ChatRequest", + "FlowSummary", + "ProtocolError", + "TargetLanguage", +] 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..1b79756 --- /dev/null +++ b/backend/rae_agent/protocol.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +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 + + +@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=[(k, v) for k, v in payload.get("requestHeaders", [])], + request_body=payload.get("requestBody"), + response_status=payload.get("responseStatus"), + response_headers=[(k, v) for k, v in payload.get("responseHeaders", [])], + response_body=payload.get("responseBody"), + started_at=float(payload.get("startedAt", 0.0)), + finished_at=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) + + @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", []) + ] + return cls( + id=str(payload.get("id", "")), + user_message=str(payload.get("message", "")), + target=target, + flows=flows, + history=history, + ) + + +@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 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..f1c359c --- /dev/null +++ b/backend/rae_agent/server.py @@ -0,0 +1,76 @@ +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 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 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 = Path( + os.environ.get("RAE_AGENT_WORKDIR", tempfile.gettempdir()) + ) / "rae-agent-sessions" + 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..9ab78f3 --- /dev/null +++ b/backend/rae_agent/session.py @@ -0,0 +1,117 @@ +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, +) + +from rae_agent.prompts import SYSTEM_PROMPT, build_user_prompt +from rae_agent.protocol import AgentEvent, ChatRequest + + +@dataclass +class SessionDirs: + root: Path + flows_dir: Path + output_dir: Path + + @classmethod + def make(cls, chat_id: str, base: Path) -> "SessionDirs": + root = base / chat_id + 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]: + chat_id = request.id or str(uuid.uuid4()) + 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="acceptEdits", + ) + + try: + async for sdk_message in query(prompt=prompt, options=options): + async for event in _translate(chat_id, sdk_message): + 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) -> AsyncIterator[AgentEvent]: + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + if block.text: + yield AgentEvent.assistant_text(chat_id, block.text) + elif isinstance(block, ToolUseBlock): + yield AgentEvent.tool_use(chat_id, block.name, dict(block.input or {})) + if block.name == "Write" and isinstance(block.input, dict): + path = block.input.get("file_path") or block.input.get("path") + if isinstance(path, str): + yield AgentEvent.file_written(chat_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) + yield AgentEvent.tool_result( + chat_id, + name="", + output=output[:4000], + is_error=bool(block.is_error), + ) + 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..148bd65 --- /dev/null +++ b/backend/tests/test_protocol.py @@ -0,0 +1,80 @@ +import pytest + +from rae_agent.protocol import ( + AgentEvent, + ChatRequest, + FlowSummary, + ProtocolError, + TargetLanguage, +) + + +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_flow_summary_rejects_missing_required_fields(): + with pytest.raises(ProtocolError): + FlowSummary.from_payload({"scheme": "https"}) + + +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_error_event_serialization_without_id(): + event = AgentEvent.error(None, "oops") + assert event.to_dict() == {"type": "error", "message": "oops"} + + +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"] diff --git a/macos/Sources/ReverseAPI/Agent/AgentClient.swift b/macos/Sources/ReverseAPI/Agent/AgentClient.swift new file mode 100644 index 0000000..5fb5619 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentClient.swift @@ -0,0 +1,67 @@ +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 task != nil { return } + 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 { + let stream = AsyncThrowingStream { continuation in + Task { + do { + while true { + guard let task = await 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 { + continuation.finish(throwing: error) + } + } + } + return stream + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift new file mode 100644 index 0000000..4b60845 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift @@ -0,0 +1,144 @@ +import Foundation +import ReverseAPIProxy + +enum AgentTargetLanguage: String, CaseIterable, Identifiable, Sendable { + 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 { + 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) { + 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, headers: flow.requestHeaders) + self.responseStatus = flow.responseStatus + self.responseHeaders = flow.responseHeaders.map { [$0.name, $0.value] } + self.responseBody = AgentFlowPayload.encodedBody(flow.responseBody, headers: flow.responseHeaders) + self.startedAt = flow.startedAt.timeIntervalSince1970 + self.finishedAt = flow.finishedAt?.timeIntervalSince1970 + } + + private static func encodedBody(_ data: Data, headers: [HTTPHeader]) -> String? { + guard !data.isEmpty else { return nil } + if let text = String(data: data, encoding: .utf8) { return text } + return "" + } +} + +struct AgentHistoryItem: Encodable { + 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] +} + +enum AgentEvent: Sendable, Identifiable { + case assistantText(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) + + var id: UUID { + switch self { + case .assistantText(_, 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 + } + } +} + +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 "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" + ) + default: + throw DecodeError.unknownType(type) + } + } + + enum DecodeError: Error { + case invalidPayload + case unknownType(String) + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift new file mode 100644 index 0000000..24dea60 --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -0,0 +1,130 @@ +import Foundation +import Observation +import ReverseAPIProxy + +@MainActor +@Observable +final class AgentSession { + enum Status: Equatable { + case idle + case launching + case ready + case streaming + case failed + } + + 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 + + private let sidecar = AgentSidecar() + private let client = AgentClient() + private var receiverTask: Task? + private let workdir: URL + private let pythonExecutable: String + + init(workdir: URL, pythonExecutable: String = "/usr/bin/env python3") { + self.workdir = workdir + self.pythonExecutable = pythonExecutable + } + + func ensureRunning() async { + switch status { + case .ready, .streaming, .launching: + return + case .idle, .failed: + break + } + status = .launching + do { + let spec = AgentSidecar.LaunchSpec(pythonExecutable: pythonExecutable, workdir: workdir) + let port = try await sidecar.launch(spec) + try await client.connect(port: port) + startReceiver() + status = .ready + lastError = nil + } catch { + 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 } + history.append(.init(role: "user", content: trimmed)) + let request = AgentChatRequest( + id: UUID().uuidString, + message: trimmed, + target: target.rawValue, + flows: flows.map(AgentFlowPayload.init), + history: history + ) + do { + input = "" + status = .streaming + try await client.send(request) + } catch { + lastError = "Failed to send chat: \(error)" + status = .failed + } + } + + func clear() { + events.removeAll() + history.removeAll() + generatedFiles = [] + lastWorkdir = nil + lastError = nil + if status == .failed { status = .ready } + } + + 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 { + self.lastError = "Agent stream error: \(error)" + self.status = .failed + } + } + } + + private func handle(_ event: AgentEvent) { + events.append(event) + switch event { + case .assistantText(_, _, let text): + history.append(.init(role: "assistant", content: text)) + case .complete(_, _, let workdir, let files): + lastWorkdir = workdir + generatedFiles = files + status = .ready + case .error(_, _, let message): + lastError = message + status = .failed + case .toolUse, .toolResult, .fileWritten: + break + } + } +} diff --git a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift new file mode 100644 index 0000000..9c6db5a --- /dev/null +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -0,0 +1,72 @@ +import Foundation + +actor AgentSidecar { + struct LaunchSpec: Sendable { + var pythonExecutable: String + var workdir: URL + } + + enum SidecarError: Error { + case alreadyRunning + case failedToStart(String) + case timedOut + } + + private(set) var process: Process? + private(set) var port: Int? + + func launch(_ spec: LaunchSpec) async throws -> Int { + if let port { return port } + + let process = Process() + process.executableURL = URL(fileURLWithPath: spec.pythonExecutable) + process.arguments = ["-m", "rae_agent.server"] + 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" + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + self.process = process + + let bound = try await waitForBoundPort(stdout: stdout) + self.port = bound + return bound + } + + func terminate() { + process?.terminate() + process = nil + port = nil + } + + private func waitForBoundPort(stdout: Pipe) async throws -> Int { + let handle = stdout.fileHandleForReading + let deadline = Date().addingTimeInterval(15) + var buffer = Data() + while Date() < deadline { + let data = handle.availableData + if data.isEmpty { + try await Task.sleep(for: .milliseconds(100)) + continue + } + buffer.append(data) + if let text = String(data: buffer, encoding: .utf8), + let line = text.split(separator: "\n").first(where: { $0.hasPrefix("RAE_AGENT_LISTENING:") }) { + let portString = line.dropFirst("RAE_AGENT_LISTENING:".count) + guard let port = Int(portString) else { + throw SidecarError.failedToStart("unexpected line: \(line)") + } + return port + } + } + throw SidecarError.timedOut + } +} diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index cfbcc08..224047e 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -26,6 +26,7 @@ final class AppState { let engine: ProxyEngine let installer: CertificateTrustInstaller let systemProxy: SystemProxyController + let agent: AgentSession let port: Int let caDER: Data @@ -48,10 +49,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() diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift new file mode 100644 index 0000000..111508f --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -0,0 +1,332 @@ +import SwiftUI +import AppKit +import ReverseAPIProxy + +struct AgentPanel: View { + @Environment(AppState.self) private var state + + var body: some View { + @Bindable var agent = state.agent + VStack(spacing: 0) { + AgentHeader(target: $agent.target, status: agent.status, onClear: { agent.clear() }) + Divider() + AgentTimeline(events: agent.events, flowCount: flowsToSend.count, generatedFiles: agent.generatedFiles, workdir: agent.lastWorkdir, status: agent.status, error: agent.lastError) + Divider() + AgentComposer(input: $agent.input, status: agent.status, onSend: send) + } + .frame(minWidth: 360) + } + + private var flowsToSend: [CapturedFlow] { + Array(state.store.flows.filter { state.filter.matches($0) }.prefix(100)) + } + + private func send() { + let flows = flowsToSend + Task { await state.agent.send(flows: flows) } + } +} + +private struct AgentHeader: View { + @Binding var target: AgentTargetLanguage + let status: AgentSession.Status + let onClear: () -> Void + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 6) { + statusDot + Text(statusLabel) + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + Picker("", selection: $target) { + ForEach(AgentTargetLanguage.allCases) { lang in + Text(lang.displayName).tag(lang) + } + } + .pickerStyle(.menu) + .fixedSize() + Button(action: onClear) { + Image(systemName: "eraser.line.dashed") + } + .buttonStyle(.borderless) + .help("Clear conversation") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.bar) + } + + private var statusDot: some View { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + } + + private var statusColor: Color { + switch status { + case .idle: return .secondary + case .launching: return .yellow + case .ready: return .green + case .streaming: return .blue + case .failed: return .red + } + } + + private var statusLabel: String { + switch status { + case .idle: return "Agent: idle" + case .launching: return "Agent: starting" + case .ready: return "Agent: ready" + case .streaming: return "Agent: thinking" + case .failed: return "Agent: error" + } + } +} + +private struct AgentTimeline: View { + let events: [AgentEvent] + let flowCount: Int + let generatedFiles: [String] + let workdir: String? + let status: AgentSession.Status + let error: String? + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + introCard + ForEach(events) { event in + AgentEventRow(event: event) + .id(event.id) + } + if let error { + ErrorBubble(message: error) + } + if !generatedFiles.isEmpty, let workdir { + GeneratedFilesCard(workdir: workdir, files: generatedFiles) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: events.count) { _, _ in + if let last = events.last { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + + private var introCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Reverse engineer with the agent") + .font(.headline) + Text("The agent will see the filtered flows (currently \(flowCount)) and generate an API client. Files land in the session workdir.") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct AgentEventRow: View { + let event: AgentEvent + + var body: some View { + switch event { + case .assistantText(_, _, let text): + AssistantBubble(text: text) + case .toolUse(_, _, let name, let inputJSON): + ToolUseBubble(name: name, inputJSON: inputJSON) + case .toolResult(_, _, let output, let isError): + ToolResultBubble(output: output, isError: isError) + case .fileWritten(_, _, let path): + FileWrittenBubble(path: path) + case .complete(_, _, let workdir, let files): + CompleteBubble(workdir: workdir, fileCount: files.count) + case .error(_, _, let message): + ErrorBubble(message: message) + } + } +} + +private struct AssistantBubble: View { + let text: String + + var body: some View { + Text(text) + .font(.body) + .textSelection(.enabled) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct ToolUseBubble: View { + let name: String + let inputJSON: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver") + .foregroundStyle(.tint) + Text(name) + .font(.system(.callout, design: .monospaced).weight(.semibold)) + } + if !inputJSON.isEmpty { + Text(inputJSON) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(8) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct ToolResultBubble: View { + let output: String + let isError: Bool + + var body: some View { + Text(output) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(isError ? .red : .secondary) + .textSelection(.enabled) + .lineLimit(10) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background((isError ? Color.red : Color.gray).opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct FileWrittenBubble: View { + let path: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "doc.badge.plus") + .foregroundStyle(.green) + Text(URL(fileURLWithPath: path).lastPathComponent) + .font(.system(.callout, design: .monospaced)) + Spacer() + Button { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } label: { + Image(systemName: "arrow.up.right.square") + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct CompleteBubble: View { + let workdir: String + let fileCount: Int + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + Text("Agent finished — \(fileCount) file\(fileCount == 1 ? "" : "s") at \(URL(fileURLWithPath: workdir).lastPathComponent)") + .font(.callout) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct ErrorBubble: View { + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.octagon.fill") + .foregroundStyle(.red) + Text(message) + .font(.callout) + .textSelection(.enabled) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct GeneratedFilesCard: View { + let workdir: String + let files: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Generated files") + .font(.headline) + Spacer() + Button("Open in Finder") { + NSWorkspace.shared.open(URL(fileURLWithPath: workdir)) + } + .buttonStyle(.borderless) + } + ForEach(files, id: \.self) { file in + HStack { + Image(systemName: "doc") + .foregroundStyle(.secondary) + Text(file) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + Spacer() + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) + } +} + +private struct AgentComposer: View { + @Binding var input: String + let status: AgentSession.Status + let onSend: () -> Void + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Ask the agent…", text: $input, axis: .vertical) + .lineLimit(1...6) + .textFieldStyle(.roundedBorder) + .onSubmit(onSend) + Button(action: onSend) { + Image(systemName: "paperplane.fill") + .frame(width: 22, height: 22) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: [.command]) + .disabled(!canSend) + } + .padding(14) + .background(.bar) + } + + private var canSend: Bool { + guard !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + return status != .launching + } +} diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 713e06c..3f39643 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -19,9 +19,11 @@ struct ContentView: View { HSplitView { TrafficListView() - .frame(minWidth: 600, maxHeight: .infinity) - InspectorView() .frame(minWidth: 460, maxHeight: .infinity) + InspectorView() + .frame(minWidth: 420, maxHeight: .infinity) + AgentPanel() + .frame(minWidth: 340, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) } From ceaf3f239487fd4e68a3509f2189f290a708341c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 12:44:46 +0000 Subject: [PATCH 02/45] M5 review fixes + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes for PR #76 review comments (greptile, cubic, codex): backend/ - protocol.py: * FlowSummary now validates finishedAt as a float (raises ProtocolError instead of silently storing a non-numeric) * Coerce header pairs through str() for safety * New sanitize_session_id(raw, fallback) — restricts chat ids to [A-Za-z0-9._-], rejects "." / ".." / absolute paths / null bytes / 128+ chars, used by both server and session - server.py: * resolve_base_dir() helper; no longer appends "rae-agent-sessions" when RAE_AGENT_WORKDIR is set by the Swift sidecar, so sessions land at /agent-sessions//... instead of double nesting * ValueError from SessionDirs is surfaced as an AgentEvent.error - session.py: * SessionDirs.make resolves and verifies the session root stays inside base before creating directories (path traversal defense) * file_written event now fires from the ToolResult phase (not ToolUse), and only when the result is not an error — so the UI only badges files the SDK actually wrote * sanitize_session_id replaces raw chat ids before path joining macos/Sources/ReverseAPI/Agent/ - AgentSidecar: * LaunchSpec now carries (executablePath, arguments) explicitly, with factory `python3(workdir:)` that runs `/usr/bin/env` with `python3` as its first argument. Previously Process.executableURL was given the full "/usr/bin/env python3" string and failed to start. * waitForBoundPort uses readabilityHandler + an async sleep loop instead of blocking availableData reads, and respects the timeout * Process is terminated and cleared on port-discovery failure so failed launches do not leave orphans - AgentSession.clear: when status was .failed, drop to .idle (not .ready), so ensureRunning() can actually relaunch on the next send - AgentSession.ensureRunning: terminate sidecar + disconnect client on launch failure so the next attempt starts clean - AgentClient.connect: replaces a dead URLSessionWebSocketTask instead of returning early when one already exists - AgentFlowPayload.encodedBody: cap payloads at 64 KiB per body (configurable), with a "…" suffix, to avoid hitting websocket message-too-large on large captures Tests: - backend/tests/test_protocol.py extended to 27 cases (sanitize_session_id subgroup, finishedAt type validation, event serializations) - backend/tests/test_server_resolution.py: 3 tests on resolve_base_dir - backend/tests/test_session_dirs.py: 5 tests including path-traversal rejection - macos/Tests/ReverseAPITests/AgentProtocolTests.swift: 13 tests on AgentFlowPayload.encodedBody (empty / UTF-8 / truncation / binary) and AgentEventDecoder (all event types + invalid input) - Package.swift: ReverseAPITests testTarget --- backend/rae_agent/__init__.py | 2 + backend/rae_agent/protocol.py | 32 ++++- backend/rae_agent/server.py | 13 ++- backend/rae_agent/session.py | 35 ++++-- backend/tests/test_protocol.py | 105 +++++++++++++++++ backend/tests/test_server_resolution.py | 33 ++++++ backend/tests/test_session_dirs.py | 36 ++++++ .../ReverseAPI/Agent/AgentClient.swift | 10 +- .../ReverseAPI/Agent/AgentProtocol.swift | 18 ++- .../ReverseAPI/Agent/AgentSession.swift | 13 ++- .../ReverseAPI/Agent/AgentSidecar.swift | 99 ++++++++++++---- .../ReverseAPITests/AgentProtocolTests.swift | 109 ++++++++++++++++++ 12 files changed, 455 insertions(+), 50 deletions(-) create mode 100644 backend/tests/test_server_resolution.py create mode 100644 backend/tests/test_session_dirs.py create mode 100644 macos/Tests/ReverseAPITests/AgentProtocolTests.swift diff --git a/backend/rae_agent/__init__.py b/backend/rae_agent/__init__.py index 1fd00af..642609f 100644 --- a/backend/rae_agent/__init__.py +++ b/backend/rae_agent/__init__.py @@ -4,6 +4,7 @@ FlowSummary, ProtocolError, TargetLanguage, + sanitize_session_id, ) __all__ = [ @@ -12,4 +13,5 @@ "FlowSummary", "ProtocolError", "TargetLanguage", + "sanitize_session_id", ] diff --git a/backend/rae_agent/protocol.py b/backend/rae_agent/protocol.py index 1b79756..b86c73f 100644 --- a/backend/rae_agent/protocol.py +++ b/backend/rae_agent/protocol.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from enum import Enum from typing import Any @@ -15,6 +16,31 @@ 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 @@ -37,13 +63,13 @@ def from_payload(cls, payload: dict[str, Any]) -> "FlowSummary": scheme=str(payload["scheme"]), method=str(payload["method"]), url=str(payload["url"]), - request_headers=[(k, v) for k, v in payload.get("requestHeaders", [])], + 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=[(k, v) for k, v in payload.get("responseHeaders", [])], + 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=payload.get("finishedAt"), + finished_at=_optional_float(payload.get("finishedAt")), ) except (KeyError, TypeError, ValueError) as exc: raise ProtocolError(f"invalid flow payload: {exc}") from exc diff --git a/backend/rae_agent/server.py b/backend/rae_agent/server.py index f1c359c..c6b6908 100644 --- a/backend/rae_agent/server.py +++ b/backend/rae_agent/server.py @@ -40,6 +40,8 @@ async def _process(websocket, raw: str | bytes, base_dir: Path) -> None: 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())) @@ -56,14 +58,19 @@ async def handler(websocket): 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 = Path( - os.environ.get("RAE_AGENT_WORKDIR", tempfile.gettempdir()) - ) / "rae-agent-sessions" + base_dir = resolve_base_dir() base_dir.mkdir(parents=True, exist_ok=True) try: diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index 9ab78f3..97205a6 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -21,7 +21,7 @@ ) from rae_agent.prompts import SYSTEM_PROMPT, build_user_prompt -from rae_agent.protocol import AgentEvent, ChatRequest +from rae_agent.protocol import AgentEvent, ChatRequest, sanitize_session_id @dataclass @@ -32,7 +32,10 @@ class SessionDirs: @classmethod def make(cls, chat_id: str, base: Path) -> "SessionDirs": - root = base / chat_id + 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): @@ -57,7 +60,8 @@ def _serialize_flow(flow) -> dict[str, Any]: async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentEvent]: - chat_id = request.id or str(uuid.uuid4()) + 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] @@ -73,9 +77,11 @@ async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentE permission_mode="acceptEdits", ) + pending_writes: dict[str, str] = {} + try: async for sdk_message in query(prompt=prompt, options=options): - async for event in _translate(chat_id, sdk_message): + async for event in _translate(chat_id, sdk_message, pending_writes): yield event except asyncio.CancelledError: raise @@ -91,27 +97,36 @@ async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentE yield AgentEvent.complete(chat_id, str(dirs.output_dir), files) -async def _translate(chat_id: str, message: Any) -> AsyncIterator[AgentEvent]: +async def _translate( + chat_id: str, + message: Any, + pending_writes: dict[str, str], +) -> AsyncIterator[AgentEvent]: if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, TextBlock): if block.text: yield AgentEvent.assistant_text(chat_id, block.text) elif isinstance(block, ToolUseBlock): - yield AgentEvent.tool_use(chat_id, block.name, dict(block.input or {})) - if block.name == "Write" and isinstance(block.input, dict): - path = block.input.get("file_path") or block.input.get("path") + 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): - yield AgentEvent.file_written(chat_id, path) + 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=bool(block.is_error), + 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/test_protocol.py b/backend/tests/test_protocol.py index 148bd65..5991bce 100644 --- a/backend/tests/test_protocol.py +++ b/backend/tests/test_protocol.py @@ -1,3 +1,5 @@ +import math + import pytest from rae_agent.protocol import ( @@ -6,6 +8,7 @@ FlowSummary, ProtocolError, TargetLanguage, + sanitize_session_id, ) @@ -57,24 +60,126 @@ def test_chat_request_rejects_non_chat_type(): 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/Sources/ReverseAPI/Agent/AgentClient.swift b/macos/Sources/ReverseAPI/Agent/AgentClient.swift index 5fb5619..4fd62ac 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentClient.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentClient.swift @@ -14,7 +14,8 @@ actor AgentClient { } func connect(port: Int) async throws { - if task != nil { return } + 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() @@ -36,7 +37,7 @@ actor AgentClient { } func events() -> AsyncThrowingStream { - let stream = AsyncThrowingStream { continuation in + AsyncThrowingStream { continuation in Task { do { while true { @@ -62,6 +63,9 @@ actor AgentClient { } } } - return stream + } + + 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 index 4b60845..93a5e9e 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift @@ -18,6 +18,8 @@ enum AgentTargetLanguage: String, CaseIterable, Identifiable, Sendable { } struct AgentFlowPayload: Encodable { + static let defaultMaxBodyBytes = 64 * 1024 + let id: String let scheme: String let method: String @@ -30,22 +32,30 @@ struct AgentFlowPayload: Encodable { let startedAt: Double let finishedAt: Double? - init(_ flow: CapturedFlow) { + 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, headers: flow.requestHeaders) + 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, headers: flow.responseHeaders) + self.responseBody = AgentFlowPayload.encodedBody(flow.responseBody, limit: maxBodyBytes) self.startedAt = flow.startedAt.timeIntervalSince1970 self.finishedAt = flow.finishedAt?.timeIntervalSince1970 } - private static func encodedBody(_ data: Data, headers: [HTTPHeader]) -> String? { + static func encodedBody(_ data: Data, limit: Int) -> String? { guard !data.isEmpty else { return nil } + if data.count > limit { + let head = data.prefix(limit) + let suffix = "\n…" + if let text = String(data: head, encoding: .utf8) { + return text + suffix + } + return "" + } if let text = String(data: data, encoding: .utf8) { return text } return "" } diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift index 24dea60..089248a 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSession.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -27,11 +27,11 @@ final class AgentSession { private let client = AgentClient() private var receiverTask: Task? private let workdir: URL - private let pythonExecutable: String + private let launchSpec: AgentSidecar.LaunchSpec - init(workdir: URL, pythonExecutable: String = "/usr/bin/env python3") { + init(workdir: URL, launchSpec: AgentSidecar.LaunchSpec? = nil) { self.workdir = workdir - self.pythonExecutable = pythonExecutable + self.launchSpec = launchSpec ?? .python3(workdir: workdir) } func ensureRunning() async { @@ -43,13 +43,14 @@ final class AgentSession { } status = .launching do { - let spec = AgentSidecar.LaunchSpec(pythonExecutable: pythonExecutable, workdir: workdir) - let port = try await sidecar.launch(spec) + 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 } @@ -84,7 +85,7 @@ final class AgentSession { generatedFiles = [] lastWorkdir = nil lastError = nil - if status == .failed { status = .ready } + if status == .failed { status = .idle } } func shutdown() async { diff --git a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift index 9c6db5a..4eb3bba 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -2,25 +2,45 @@ import Foundation actor AgentSidecar { struct LaunchSpec: Sendable { - var pythonExecutable: String + var executablePath: String + var arguments: [String] var workdir: URL + + public init(executablePath: String, arguments: [String], workdir: URL) { + self.executablePath = executablePath + self.arguments = arguments + self.workdir = workdir + } + + public static func python3(workdir: URL) -> LaunchSpec { + LaunchSpec( + executablePath: "/usr/bin/env", + arguments: ["python3", "-m", "rae_agent.server"], + workdir: workdir + ) + } + + public static func executable(at path: String, workdir: URL) -> LaunchSpec { + LaunchSpec(executablePath: path, arguments: ["-m", "rae_agent.server"], workdir: workdir) + } } enum SidecarError: Error { case alreadyRunning case failedToStart(String) case timedOut + case processDied(Int32) } private(set) var process: Process? private(set) var port: Int? - func launch(_ spec: LaunchSpec) async throws -> Int { + func launch(_ spec: LaunchSpec, timeout: Duration = .seconds(15)) async throws -> Int { if let port { return port } let process = Process() - process.executableURL = URL(fileURLWithPath: spec.pythonExecutable) - process.arguments = ["-m", "rae_agent.server"] + 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" @@ -36,37 +56,74 @@ actor AgentSidecar { try process.run() self.process = process - let bound = try await waitForBoundPort(stdout: stdout) - self.port = bound - return bound + do { + let bound = try await waitForBoundPort(stdout: stdout, deadline: timeout, process: process) + self.port = bound + return bound + } catch { + if process.isRunning { + process.terminate() + } + self.process = nil + throw error + } } func terminate() { - process?.terminate() - process = nil - port = nil + guard let process else { return } + if process.isRunning { + process.terminate() + } + self.process = nil + self.port = nil } - private func waitForBoundPort(stdout: Pipe) async throws -> Int { + private func waitForBoundPort(stdout: Pipe, deadline: Duration, process: Process) async throws -> Int { let handle = stdout.fileHandleForReading - let deadline = Date().addingTimeInterval(15) - var buffer = Data() - while Date() < deadline { - let data = handle.availableData - if data.isEmpty { - try await Task.sleep(for: .milliseconds(100)) - continue + let buffer = AsyncStreamBuffer() + handle.readabilityHandler = { fileHandle in + let chunk = fileHandle.availableData + if !chunk.isEmpty { + buffer.append(chunk) } - buffer.append(data) - if let text = String(data: buffer, encoding: .utf8), - let line = text.split(separator: "\n").first(where: { $0.hasPrefix("RAE_AGENT_LISTENING:") }) { + } + defer { handle.readabilityHandler = nil } + + let deadlineDate = ContinuousClock.now.advanced(by: deadline) + while ContinuousClock.now < deadlineDate { + if !process.isRunning { + throw SidecarError.processDied(process.terminationStatus) + } + 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)") } return port } + 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) + } + + func takeLine(prefix: String) -> String? { + lock.lock() + defer { lock.unlock() } + guard let text = String(data: data, encoding: .utf8) else { return nil } + for line in text.split(separator: "\n") { + if line.hasPrefix(prefix) { return String(line) } + } + return nil + } +} diff --git a/macos/Tests/ReverseAPITests/AgentProtocolTests.swift b/macos/Tests/ReverseAPITests/AgentProtocolTests.swift new file mode 100644 index 0000000..963514c --- /dev/null +++ b/macos/Tests/ReverseAPITests/AgentProtocolTests.swift @@ -0,0 +1,109 @@ +import XCTest +import ReverseAPIProxy +@testable import ReverseAPI + +final class AgentFlowPayloadTests: XCTestCase { + func testEncodedBodyEmptyIsNil() { + XCTAssertNil(AgentFlowPayload.encodedBody(Data(), limit: 1000)) + } + + func testEncodedBodyReturnsUTF8WhenShortEnough() { + let data = Data("hello".utf8) + XCTAssertEqual(AgentFlowPayload.encodedBody(data, limit: 1000), "hello") + } + + func testEncodedBodyTruncatesLongerInput() { + let data = Data(repeating: 0x41, count: 1000) + let result = AgentFlowPayload.encodedBody(data, limit: 100) + XCTAssertNotNil(result) + XCTAssertTrue(result?.contains("…") == true) + } + + func testEncodedBodyHandlesBinaryUnderLimit() { + let data = Data([0xFF, 0xFE, 0xFD]) + let result = AgentFlowPayload.encodedBody(data, limit: 1000) + XCTAssertEqual(result, "") + } + + func testEncodedBodyHandlesBinaryOverLimit() { + let data = Data(repeating: 0xFF, count: 2000) + let result = AgentFlowPayload.encodedBody(data, limit: 100) + XCTAssertEqual(result, "") + } + + func testInitFromFlowProducesExpectedFields() { + var flow = CapturedFlow(scheme: .https, method: "GET", host: "api.x", port: 443, path: "/v1") + flow.responseStatus = 200 + flow.requestHeaders = [HTTPHeader("X", "1")] + let payload = AgentFlowPayload(flow) + XCTAssertEqual(payload.method, "GET") + XCTAssertEqual(payload.scheme, "https") + XCTAssertEqual(payload.responseStatus, 200) + XCTAssertEqual(payload.requestHeaders, [["X", "1"]]) + } +} + +final class AgentEventDecoderTests: XCTestCase { + func testDecodeAssistantText() throws { + let data = Data(""" + {"type":"assistant_text","id":"abc","text":"hello"} + """.utf8) + let event = try AgentEventDecoder.decode(data) + guard case .assistantText(let chatID, _, let text) = event else { + return XCTFail("wrong event") + } + XCTAssertEqual(chatID, "abc") + XCTAssertEqual(text, "hello") + } + + func testDecodeToolUse() throws { + let data = Data(""" + {"type":"tool_use","id":"abc","name":"Write","input":{"file_path":"x.py"}} + """.utf8) + let event = try AgentEventDecoder.decode(data) + guard case .toolUse(_, _, let name, let json) = event else { return XCTFail() } + XCTAssertEqual(name, "Write") + XCTAssertTrue(json.contains("file_path")) + } + + func testDecodeFileWritten() throws { + let data = Data(""" + {"type":"file_written","id":"abc","path":"/tmp/x.py"} + """.utf8) + let event = try AgentEventDecoder.decode(data) + guard case .fileWritten(_, _, let path) = event else { return XCTFail() } + XCTAssertEqual(path, "/tmp/x.py") + } + + func testDecodeError() throws { + let data = Data(""" + {"type":"error","message":"boom"} + """.utf8) + let event = try AgentEventDecoder.decode(data) + guard case .error(let chatID, _, let message) = event else { return XCTFail() } + XCTAssertNil(chatID) + XCTAssertEqual(message, "boom") + } + + func testDecodeComplete() throws { + let data = Data(""" + {"type":"complete","id":"abc","workdir":"/tmp/out","files":["a.py","b.py"]} + """.utf8) + let event = try AgentEventDecoder.decode(data) + guard case .complete(_, _, let workdir, let files) = event else { return XCTFail() } + XCTAssertEqual(workdir, "/tmp/out") + XCTAssertEqual(files, ["a.py", "b.py"]) + } + + func testDecodeRejectsUnknownType() { + let data = Data(""" + {"type":"mystery"} + """.utf8) + XCTAssertThrowsError(try AgentEventDecoder.decode(data)) + } + + func testDecodeRejectsInvalidJSON() { + let data = Data("not json".utf8) + XCTAssertThrowsError(try AgentEventDecoder.decode(data)) + } +} From 1d8eb1fb9ab8bd5331bb1f62b03d28aa5354b195 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 01:53:46 +0200 Subject: [PATCH 03/45] fix(macos): stabilize m5 agent sessions --- macos/Sources/ReverseAPI/Agent/AgentClient.swift | 10 ++++++++-- macos/Sources/ReverseAPI/Agent/AgentSession.swift | 14 ++++++++++---- macos/Sources/ReverseAPI/Agent/AgentSidecar.swift | 12 ++++++++---- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/macos/Sources/ReverseAPI/Agent/AgentClient.swift b/macos/Sources/ReverseAPI/Agent/AgentClient.swift index 4fd62ac..f972c16 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentClient.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentClient.swift @@ -38,10 +38,11 @@ actor AgentClient { func events() -> AsyncThrowingStream { AsyncThrowingStream { continuation in - Task { + let receiveTask = Task { do { while true { - guard let task = await self.task else { + try Task.checkCancellation() + guard let task = self.task else { continuation.finish() return } @@ -58,10 +59,15 @@ actor AgentClient { let event = try AgentEventDecoder.decode(data) continuation.yield(event) } + } catch is CancellationError { + continuation.finish() } catch { continuation.finish(throwing: error) } } + continuation.onTermination = { _ in + receiveTask.cancel() + } } } diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift index 089248a..5bf0594 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSession.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -26,6 +26,7 @@ final class AgentSession { private let sidecar = AgentSidecar() private let client = AgentClient() private var receiverTask: Task? + private var sessionID = UUID().uuidString private let workdir: URL private let launchSpec: AgentSidecar.LaunchSpec @@ -61,14 +62,16 @@ final class AgentSession { guard !trimmed.isEmpty else { return } await ensureRunning() guard status == .ready || status == .streaming else { return } - history.append(.init(role: "user", content: trimmed)) + let previousHistory = history + let userHistoryItem = AgentHistoryItem(role: "user", content: trimmed) let request = AgentChatRequest( - id: UUID().uuidString, + id: sessionID, message: trimmed, target: target.rawValue, - flows: flows.map(AgentFlowPayload.init), - history: history + flows: flows.map { AgentFlowPayload($0) }, + history: previousHistory ) + history.append(userHistoryItem) do { input = "" status = .streaming @@ -85,6 +88,7 @@ final class AgentSession { generatedFiles = [] lastWorkdir = nil lastError = nil + sessionID = UUID().uuidString if status == .failed { status = .idle } } @@ -106,6 +110,8 @@ final class AgentSession { self.handle(event) } } catch { + await self.client.disconnect() + await self.sidecar.terminate() self.lastError = "Agent stream error: \(error)" self.status = .failed } diff --git a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift index 4eb3bba..82ece77 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -36,7 +36,10 @@ actor AgentSidecar { private(set) var port: Int? func launch(_ spec: LaunchSpec, timeout: Duration = .seconds(15)) async throws -> Int { - if let port { return port } + if let port, let process, process.isRunning { return port } + if port != nil || process != nil { + terminate() + } let process = Process() process.executableURL = URL(fileURLWithPath: spec.executablePath) @@ -65,6 +68,7 @@ actor AgentSidecar { process.terminate() } self.process = nil + self.port = nil throw error } } @@ -91,9 +95,6 @@ actor AgentSidecar { let deadlineDate = ContinuousClock.now.advanced(by: deadline) while ContinuousClock.now < deadlineDate { - if !process.isRunning { - throw SidecarError.processDied(process.terminationStatus) - } if let line = buffer.takeLine(prefix: "RAE_AGENT_LISTENING:") { let portString = line.dropFirst("RAE_AGENT_LISTENING:".count) guard let port = Int(portString) else { @@ -101,6 +102,9 @@ actor AgentSidecar { } return port } + if !process.isRunning { + throw SidecarError.processDied(process.terminationStatus) + } try await Task.sleep(for: .milliseconds(50)) } throw SidecarError.timedOut From 8a6f72a7ece1debc4a79a10c838e8e5e17b4391a Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 02:00:00 +0200 Subject: [PATCH 04/45] fix(macos): trust ca before device capture --- macos/Sources/ReverseAPI/App/AppState.swift | 28 ++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index 224047e..76719a4 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -1,6 +1,7 @@ import Foundation import Observation import ReverseAPIProxy +import Security @MainActor @Observable @@ -81,6 +82,9 @@ final class AppState { defer { isWorking = false } do { + if mode == .device { + try await ensureCATrustInstalled() + } try await engine.start() if mode == .device { @@ -131,18 +135,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)" From 28638779c05268954cc8d8a5f1d58570ef2f1cb2 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:29:06 +0200 Subject: [PATCH 05/45] feat(macos): add theme palette, shared pill controls, markdown renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce three foundation modules consumed by the upcoming UI redesign: - Theme: near-black dark palette (#050506 → #16161A surfaces), method/ status colors, border tokens. Replaces ad-hoc Color(nsColor: .windowBackgroundColor) references scattered across views. - Controls: NSSegmentedControl wrapper (.capsule style) and PillStyle primitives + .pillBackground modifier so chips, search button, and segmented tabs share identical hover/active states. - Markdown: lightweight block parser for assistant output. Supports fenced code blocks, headings, bullet/ordered lists, and inline bold/italic/code via AttributedString. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/Controls.swift | 85 +++++++ macos/Sources/ReverseAPI/UI/Markdown.swift | 275 +++++++++++++++++++++ macos/Sources/ReverseAPI/UI/Theme.swift | 32 +++ 3 files changed, 392 insertions(+) create mode 100644 macos/Sources/ReverseAPI/UI/Controls.swift create mode 100644 macos/Sources/ReverseAPI/UI/Markdown.swift create mode 100644 macos/Sources/ReverseAPI/UI/Theme.swift 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/Markdown.swift b/macos/Sources/ReverseAPI/UI/Markdown.swift new file mode 100644 index 0000000..b4d9407 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/Markdown.swift @@ -0,0 +1,275 @@ +import SwiftUI + +/// Lightweight markdown renderer for assistant output. +/// Supports: fenced code blocks, headers, bullet/ordered lists, paragraphs. +/// Inline syntax (bold/italic/code/links) is handled by AttributedString. +struct MarkdownView: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + view(for: block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var blocks: [MarkdownBlock] { + MarkdownParser.parse(text) + } + + @ViewBuilder + private func view(for block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let raw): + Text(inline(raw)) + .font(headingFont(level: level)) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .padding(.top, level == 1 ? 4 : 2) + + case .paragraph(let raw): + Text(inline(raw)) + .font(.body) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + case .bullet(let items): + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 8) { + Text("•") + .foregroundStyle(Theme.textSecondary) + Text(inline(item)) + .font(.body) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + case .ordered(let items): + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .top, spacing: 8) { + Text("\(index + 1).") + .foregroundStyle(Theme.textSecondary) + .monospacedDigit() + Text(inline(item)) + .font(.body) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + case .codeBlock(let language, let code): + MarkdownCodeBlock(language: language, code: code) + } + } + + private func inline(_ raw: String) -> AttributedString { + if let attr = try? AttributedString(markdown: raw, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + return attr + } + return AttributedString(raw) + } + + private func headingFont(level: Int) -> Font { + switch level { + case 1: return .system(.title2, design: .default).weight(.semibold) + case 2: return .system(.title3, design: .default).weight(.semibold) + default: return .system(.headline, design: .default) + } + } +} + +private struct MarkdownCodeBlock: View { + let language: String? + let code: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let language, !language.isEmpty { + HStack { + Text(language) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .textCase(.lowercase) + Spacer() + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(code, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundStyle(Theme.textTertiary) + } + .buttonStyle(.plain) + .help("Copy") + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Theme.elevated) + } + ScrollView(.horizontal, showsIndicators: false) { + Text(code) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.appBackground) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) + } + } +} + +// MARK: - Parser + +enum MarkdownBlock: Equatable { + case heading(level: Int, text: String) + case paragraph(String) + case bullet([String]) + case ordered([String]) + case codeBlock(language: String?, code: String) +} + +enum MarkdownParser { + static func parse(_ text: String) -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + var lines = text.components(separatedBy: "\n")[...] + + var paragraphBuffer: [String] = [] + var bulletBuffer: [String] = [] + var orderedBuffer: [String] = [] + + func flushParagraph() { + if !paragraphBuffer.isEmpty { + blocks.append(.paragraph(paragraphBuffer.joined(separator: " "))) + paragraphBuffer.removeAll() + } + } + func flushBullets() { + if !bulletBuffer.isEmpty { + blocks.append(.bullet(bulletBuffer)) + bulletBuffer.removeAll() + } + } + func flushOrdered() { + if !orderedBuffer.isEmpty { + blocks.append(.ordered(orderedBuffer)) + orderedBuffer.removeAll() + } + } + func flushAll() { + flushParagraph() + flushBullets() + flushOrdered() + } + + while let line = lines.first { + lines = lines.dropFirst() + + // Fenced code block + if let fence = codeFence(line) { + flushAll() + var collected: [String] = [] + while let next = lines.first { + lines = lines.dropFirst() + if codeFence(next) != nil { break } + collected.append(next) + } + blocks.append(.codeBlock(language: fence, code: collected.joined(separator: "\n"))) + continue + } + + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Blank line ⇒ flush paragraph + if trimmed.isEmpty { + flushAll() + continue + } + + // Heading + if let (level, rest) = headingMatch(trimmed) { + flushAll() + blocks.append(.heading(level: level, text: rest)) + continue + } + + // Bullet + if let item = bulletMatch(trimmed) { + flushParagraph() + flushOrdered() + bulletBuffer.append(item) + continue + } + + // Ordered + if let item = orderedMatch(trimmed) { + flushParagraph() + flushBullets() + orderedBuffer.append(item) + continue + } + + // Paragraph + flushBullets() + flushOrdered() + paragraphBuffer.append(trimmed) + } + + flushAll() + return blocks + } + + private static func codeFence(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("```") else { return nil } + let after = trimmed.dropFirst(3) + let lang = after.trimmingCharacters(in: .whitespaces) + return lang.isEmpty ? "" : lang + } + + private static func headingMatch(_ line: String) -> (Int, String)? { + var level = 0 + var index = line.startIndex + while index < line.endIndex, line[index] == "#" { + level += 1 + index = line.index(after: index) + } + guard level > 0, level <= 3 else { return nil } + guard index < line.endIndex, line[index] == " " else { return nil } + let rest = String(line[line.index(after: index)...]) + return (level, rest) + } + + private static func bulletMatch(_ line: String) -> String? { + guard line.hasPrefix("- ") || line.hasPrefix("* ") else { return nil } + return String(line.dropFirst(2)) + } + + private static func orderedMatch(_ line: String) -> String? { + var index = line.startIndex + var hasDigits = false + while index < line.endIndex, line[index].isNumber { + hasDigits = true + index = line.index(after: index) + } + guard hasDigits, index < line.endIndex, line[index] == "." else { return nil } + index = line.index(after: index) + guard index < line.endIndex, line[index] == " " else { return nil } + return String(line[line.index(after: index)...]) + } +} diff --git a/macos/Sources/ReverseAPI/UI/Theme.swift b/macos/Sources/ReverseAPI/UI/Theme.swift new file mode 100644 index 0000000..0a1778f --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/Theme.swift @@ -0,0 +1,32 @@ +import SwiftUI + +enum Theme { + // Backgrounds (darkest → lightest) — near-black palette + static let appBackground = Color(red: 0.020, green: 0.020, blue: 0.024) // #050506 + static let surface = Color(red: 0.043, green: 0.043, blue: 0.051) // #0B0B0D + static let elevated = Color(red: 0.086, green: 0.086, blue: 0.102) // #16161A + static let input = Color(red: 0.059, green: 0.059, blue: 0.067) // #0F0F11 + static let overlay = Color.black.opacity(0.55) + + // Borders & dividers + static let border = Color.white.opacity(0.07) + static let borderStrong = Color.white.opacity(0.14) + + // Text + static let textPrimary = Color(red: 0.929, green: 0.929, blue: 0.937) // #EDEDEF + static let textSecondary = Color(red: 0.549, green: 0.557, blue: 0.580) // #8C8E94 + static let textTertiary = Color(red: 0.373, green: 0.380, blue: 0.404) // #5F6167 + + // Accent colors (matched against the dark canvas) + static let accent = Color(red: 0.231, green: 0.510, blue: 0.965) // #3B82F6 + static let warn = Color(red: 0.941, green: 0.549, blue: 0.227) // #F08C3A + static let success = Color(red: 0.298, green: 0.792, blue: 0.518) // #4CCA84 + static let danger = Color(red: 0.949, green: 0.357, blue: 0.357) // #F25B5B + + // Method palette (HTTP) + static let methodGet = Color(red: 0.392, green: 0.643, blue: 1.000) // #64A4FF + static let methodPost = Color(red: 0.388, green: 0.851, blue: 0.541) // #63D98A + static let methodPut = Color(red: 0.953, green: 0.722, blue: 0.282) // #F3B848 + static let methodDelete = Color(red: 0.949, green: 0.439, blue: 0.439) // #F27070 + static let methodConnect = Color(red: 0.682, green: 0.482, blue: 0.965) // #AE7BF6 +} From 88e3dc3750d62524f58b9c298f431af0f58801ed Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:29:31 +0200 Subject: [PATCH 06/45] feat(macos): redesign main window with near-black UI and command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the heavy 312pt sidebar + cluttered triple panel layout with a much cleaner shell: - Drop CaptureToolbar entirely. Capture/mode/CA/route/export/clear move to a borderless toolbar (record icon only, neutral tint, danger when active) plus a … menu. Window title is hidden via AppKit so no "rae" duplication. - ContentView: native NSWindow chrome via WindowAccessor — transparent titlebar + window.backgroundColor in sync with Theme.appBackground so the toolbar matches the rest of the canvas. Configure runs on the next runloop tick to avoid CATransaction commit-phase exceptions on macOS 14+. - ActionBar replaces the old sidebar sections: native NSSegmented capsule for Device/Manual, full-width scrollable filter chips with hover state, and a ⌘K SearchButton pill that opens the command palette. - TrafficListView: replace SwiftUI Table (which forces system accent selection blue) with a custom LazyVStack so selected rows use Theme.elevated instead of the system tint. Time/Method/Host/Path/ Status columns only — Size and Duration removed to reduce density. - AgentPanel always mounted with offset+width animation so toggling ⌘J slides the panel from the right without layout shift. - StatusBar pinned at the bottom with capture state, CA trust, and flow count. - CommandPalette (⌘K): two-pane layout — results list on the left, live preview pane on the right with method, status, content-type, size, duration. NSVisualEffectView glass background, scale+offset+ opacity transitions wired at the conditional root in ContentView so the panel actually animates (previous .transition() on inner VStack was ignored). Native SwiftUI TextField + @FocusState, .onKeyPress for up/down/escape (avoids the NSEvent monitor crash from earlier). Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/App/ReverseAPIApp.swift | 67 ++- .../ReverseAPI/UI/CaptureToolbar.swift | 431 -------------- .../ReverseAPI/UI/CommandPalette.swift | 516 +++++++++++++++++ macos/Sources/ReverseAPI/UI/ContentView.swift | 451 +++++++++++++-- .../ReverseAPI/UI/TrafficListView.swift | 541 +++++------------- 5 files changed, 1134 insertions(+), 872 deletions(-) delete mode 100644 macos/Sources/ReverseAPI/UI/CaptureToolbar.swift create mode 100644 macos/Sources/ReverseAPI/UI/CommandPalette.swift diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index ce596c8..169b92d 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -5,13 +5,13 @@ 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 + @AppStorage("rae.agent.visible") private var isAgentVisible = false var body: some Scene { Window("rae", id: "main") { switch session { case .ready(let state): - ContentView(isSidebarVisible: $isSidebarVisible) + ContentView(isAgentVisible: $isAgentVisible) .environment(state) .onAppear { AppLifecycle.shared.state = state @@ -19,10 +19,17 @@ 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( - minWidth: 1100, + minWidth: 980, maxWidth: .infinity, - minHeight: 700, + minHeight: 640, maxHeight: .infinity, alignment: .topLeading ) @@ -36,10 +43,10 @@ struct ReverseAPIApp: App { .commands { CommandGroup(replacing: .newItem) {} CommandGroup(after: .toolbar) { - Button(isSidebarVisible ? "Hide Sidebar" : "Show Sidebar") { - isSidebarVisible.toggle() + Button(isAgentVisible ? "Hide Agent" : "Show Agent") { + isAgentVisible.toggle() } - .keyboardShortcut("b", modifiers: .command) + .keyboardShortcut("j", modifiers: .command) } } } @@ -62,12 +69,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var isTerminating = false func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.appearance = NSAppearance(named: .darkAqua) keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let togglesSidebar = event.charactersIgnoringModifiers?.lowercased() == "b" && + let togglesAgent = event.charactersIgnoringModifiers?.lowercased() == "j" && modifiers == .command - guard togglesSidebar else { return event } - NotificationCenter.default.post(name: .toggleRaeSidebar, object: nil) + guard togglesAgent else { return event } + NotificationCenter.default.post(name: .toggleRaeAgent, object: nil) return nil } } @@ -102,7 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } extension Notification.Name { - static let toggleRaeSidebar = Notification.Name("rae.toggleSidebar") + static let toggleRaeAgent = Notification.Name("rae.toggleAgent") } enum AppSession { @@ -119,6 +127,43 @@ 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) + } + } + } +} + struct BootFailureView: View { let error: Error 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..13002e2 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/CommandPalette.swift @@ -0,0 +1,516 @@ +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 + @FocusState private var isInputFocused: Bool + @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 + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + isInputFocused = true + } + } + .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) + + TextField("", text: $query, prompt: Text("Search traffic").foregroundColor(Theme.textTertiary)) + .textFieldStyle(.plain) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(Theme.textPrimary) + .focused($isInputFocused) + .onSubmit { select() } + + 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) + } + + private var footer: some View { + HStack(spacing: 14) { + FooterHint(symbols: ["↑", "↓"], label: "navigate") + FooterHint(symbols: ["↵"], label: "open") + FooterHint(symbols: ["esc"], label: "close") + Spacer() + if let flow = highlightedFlow { + Text(flow.host) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(alignment: .top) { + Rectangle().fill(Color.white.opacity(0.06)).frame(height: 1) + } + } + + // 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 { + switch flow.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 + } + } + + private func statusColor(_ 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: - 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 { + switch flow.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 + } + } + + private func statusColor(_ 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: - Footer hint & count chip + +private struct FooterHint: View { + let symbols: [String] + let label: String + + var body: some View { + HStack(spacing: 6) { + HStack(spacing: 2) { + ForEach(symbols, id: \.self) { sym in + Text(sym) + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 4)) + } + } + Text(label) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } +} + +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: - 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 3f39643..0d094db 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -1,69 +1,446 @@ import SwiftUI import ReverseAPIProxy import AppKit +import UniformTypeIdentifiers struct ContentView: View { @Environment(AppState.self) private var state - @Binding var isSidebarVisible: Bool + @Binding var isAgentVisible: Bool + @State private var isPaletteVisible: Bool = false 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 }) + ThinDivider() + HStack(spacing: 0) { + HSplitView { + TrafficListView() + .frame(minWidth: 420, maxHeight: .infinity) + + if state.selectedFlowID != nil { + InspectorView() + .frame(minWidth: 360, idealWidth: 520, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + AgentPanel() + .frame(width: 380) + .offset(x: isAgentVisible ? 0 : 380) + .frame(width: isAgentVisible ? 380 : 0) + .clipped() + .allowsHitTesting(isAgentVisible) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + ThinDivider() + StatusBar() + } + + // Hidden button to capture ⌘K globally + Button("Search") { + isPaletteVisible.toggle() + } + .keyboardShortcut("k", modifiers: [.command]) + .opacity(0) + .frame(width: 0, height: 0) + + // Dim layer (fades alone) + if isPaletteVisible { + Theme.overlay + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { isPaletteVisible = false } + .transition(.opacity) + .zIndex(9) } - HSplitView { - TrafficListView() - .frame(minWidth: 460, maxHeight: .infinity) - InspectorView() - .frame(minWidth: 420, maxHeight: .infinity) - AgentPanel() - .frame(minWidth: 340, 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) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } + .animation(.spring(response: 0.32, dampingFraction: 0.84), value: isPaletteVisible) + .animation(.spring(response: 0.42, dampingFraction: 0.86), value: isAgentVisible) .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: .toggleRaeAgent)) { _ in + isAgentVisible.toggle() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in state.restoreProxyBeforeExit() } } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + Button { + isAgentVisible.toggle() + } label: { + Image(systemName: isAgentVisible ? "sparkles.rectangle.stack.fill" : "sparkles.rectangle.stack") + } + .help(isAgentVisible ? "Hide agent" : "Show agent") + .keyboardShortcut("j", modifiers: [.command]) + + ActionsMenu() + + CaptureButton() + } + } } -private struct CollapsedSidebarRail: View { - @Binding var isSidebarVisible: Bool +// MARK: - Thin divider +struct ThinDivider: View { 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) + 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 (⌘R)" : "Start capture (⌘R)") + .disabled(state.isWorking) + .keyboardShortcut("r", modifiers: [.command]) + } + + 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() } + .keyboardShortcut("e", modifiers: [.command, .shift]) + .disabled(state.store.flows.isEmpty) + Button("Clear traffic") { + state.clearFlows() + } + .keyboardShortcut("k", modifiers: [.command]) + .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()) + } +} + +// MARK: - Action bar (mode + chips + search) + +private struct ActionBar: View { + @Environment(AppState.self) private var state + let onOpenPalette: () -> Void + + var body: some View { + @Bindable var bindable = state + VStack(spacing: 0) { + if let error = state.lastError { + ErrorBanner(message: error) + } + HStack(spacing: 14) { + ModeToggle(selection: $bindable.captureMode) + .disabled(state.isCapturing || state.isWorking) + + ResourceKindStrip(selectedKinds: $bindable.filter.resourceKinds) + + SearchButton(action: onOpenPalette) + + if activeFilterCount > 0 { + Button { + bindable.filter = TrafficFilter() + } label: { + Label("\(activeFilterCount)", systemImage: "xmark.circle.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(Theme.textSecondary) + } + .buttonStyle(.borderless) + .help("Clear \(activeFilterCount) active filter(s)") + } } - .buttonStyle(.plain) - .help("Show sidebar") + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .background(Theme.appBackground) + } + + private var activeFilterCount: Int { + var count = 0 + if !state.filter.search.isEmpty { count += 1 } + if state.filter.onlyErrors { count += 1 } + count += state.filter.hosts.count + count += state.filter.methods.count + count += state.filter.statusBuckets.count + count += state.filter.resourceKinds.count + return count + } +} + +private struct ModeToggle: View { + @Binding var selection: AppState.CaptureMode + + var body: some View { + NSSegmented( + labels: AppState.CaptureMode.allCases.map { $0.rawValue }, + selection: Binding( + get: { AppState.CaptureMode.allCases.firstIndex(of: selection) ?? 0 }, + set: { selection = AppState.CaptureMode.allCases[$0] } + ) + ) + .fixedSize() + .help("Capture mode") + } +} + + +private struct ResourceKindStrip: View { + @Binding var selectedKinds: Set + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + Chip(title: "All", isSelected: selectedKinds.isEmpty) { + selectedKinds.removeAll() + } + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + Chip(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { + if selectedKinds.contains(kind) { + selectedKinds.remove(kind) + } else { + selectedKinds.insert(kind) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct Chip: View { + let title: String + let isSelected: Bool + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .pillBackground(isActive: isSelected, isHovering: isHovering) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } +} + +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(.horizontal, 14) + .padding(.vertical, 8) + .background(Theme.warn.opacity(0.12)) + .overlay(alignment: .bottom) { + Rectangle().fill(Theme.warn.opacity(0.25)).frame(height: 1) + } + } +} - Image(systemName: "waveform.path.ecg") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Color.accentColor) +// MARK: - Status bar (footer) + +private struct StatusBar: View { + @Environment(AppState.self) private var state + + var body: some View { + HStack(spacing: 12) { + StatusDot(color: state.isCapturing ? Theme.success : Theme.textTertiary) + Text(captureLabel) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + + Dot() + + Image(systemName: state.caTrustInstalled ? "checkmark.seal.fill" : "seal") + .foregroundStyle(state.caTrustInstalled ? Theme.success : Theme.textTertiary) + .font(.caption) + Text(state.caTrustInstalled ? "CA trusted" : "CA not trusted") + .font(.caption) + .foregroundStyle(Theme.textSecondary) Spacer() + + Text("\(state.store.flows.count) flows") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + .monospacedDigit() + .contentTransition(.numericText()) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Theme.surface) + } + + private var captureLabel: String { + if state.isWorking { return "working…" } + if state.isCapturing { return "recording · 127.0.0.1:\(state.port)" } + return "idle · 127.0.0.1:\(state.port)" + } +} + +private struct StatusDot: View { + let color: Color + var body: some View { + Circle() + .fill(color) + .frame(width: 7, height: 7) + } +} + +private struct Dot: View { + var body: some View { + Circle() + .fill(Theme.textTertiary.opacity(0.5)) + .frame(width: 3, height: 3) + } +} + +// 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) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + Text("⌘K") + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textTertiary) + } + .padding(.horizontal, 11) + .padding(.vertical, 5) + .background(isHovering ? PillStyle.activeBackground : PillStyle.hoverBackground, in: Capsule()) } - .padding(.top, 14) - .frame(minWidth: 48, idealWidth: 48, maxWidth: 48, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help("Search captured traffic (⌘K)") } } diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 202931e..cac3586 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -6,335 +6,148 @@ struct TrafficListView: View { @Environment(AppState.self) private var state var body: some View { - @Bindable var bindable = state - VStack(spacing: 0) { - FilterBar( - filter: $bindable.filter, - hostOptions: state.store.hostOptions, - methodOptions: state.store.methodOptions, - totalCount: state.store.flows.count, - visibleCount: filteredFlows.count - ) - Divider() - ZStack { - table + ZStack { + Theme.appBackground.ignoresSafeArea() + + VStack(spacing: 0) { + TrafficHeaderRow() + ThinDivider() if state.store.flows.isEmpty { EmptyTrafficState() } else if filteredFlows.isEmpty { EmptyFilterState() + } else { + TrafficRowList(flows: filteredFlows) } } } - .background(Color(nsColor: .controlBackgroundColor)) } private var filteredFlows: [CapturedFlow] { state.store.flows.filter { state.filter.matches($0) } } - - private var table: some View { - @Bindable var bindable = state - return Table(filteredFlows, selection: $bindable.selectedFlowID) { - TableColumn("Time") { flow in - Text(flow.startedAt, format: .dateTime.hour().minute().second()) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) - } - .width(min: 70, ideal: 80) - - TableColumn("Method") { flow in - MethodBadge(method: flow.method) - } - .width(min: 60, ideal: 70) - - TableColumn("Type") { flow in - ResourceKindBadge(kind: TrafficFilter.resourceKind(for: flow)) - } - .width(min: 74, ideal: 86) - - TableColumn("Host") { flow in - Text(flow.host) - .lineLimit(1) - .truncationMode(.middle) - } - .width(min: 120, ideal: 200) - - TableColumn("Path") { flow in - Text(flow.path) - .lineLimit(1) - .truncationMode(.middle) - .font(.system(.callout, design: .monospaced)) - } - .width(min: 160, ideal: 320) - - TableColumn("Status") { flow in - StatusBadge(status: flow.responseStatus, error: flow.error) - } - .width(min: 60, ideal: 70) - - TableColumn("Size") { flow in - Text(byteString(flow.responseBody.count)) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) - } - .width(min: 60, ideal: 80) - - TableColumn("Duration") { flow in - Text(durationString(flow)) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) - } - .width(min: 70, ideal: 80) - } - } - - private func byteString(_ count: Int) -> String { - Self.byteCountFormatter.string(fromByteCount: Int64(count)) - } - - private static let byteCountFormatter: ByteCountFormatter = { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter - }() - - private func durationString(_ flow: CapturedFlow) -> String { - guard let finished = flow.finishedAt else { return "…" } - let interval = finished.timeIntervalSince(flow.startedAt) - if interval < 1 { - return String(format: "%.0f ms", interval * 1000) - } - return String(format: "%.2f s", interval) - } } -private struct FilterBar: View { - @Binding var filter: TrafficFilter - let hostOptions: [String] - let methodOptions: [String] - let totalCount: Int - let visibleCount: Int +// MARK: - Header +private struct TrafficHeaderRow: View { var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Traffic") - .font(.title3.weight(.semibold)) - Text(countLabel) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - - Spacer() - - SearchField(text: $filter.search, placeholder: "Search requests") - .frame(width: 300) - - filterMenu - if activeFilterCount > 0 { - Button { - filter = TrafficFilter() - } label: { - Label("Reset", systemImage: "xmark.circle.fill") - } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Clear \(activeFilterCount) active filters") - } - } - - ResourceKindBar(selectedKinds: $filter.resourceKinds) + HStack(spacing: 0) { + HeaderLabel("Time", width: 72, align: .leading) + HeaderLabel("Method", width: 64, align: .leading) + HeaderLabel("Host", width: 200, align: .leading) + HeaderLabel("Path", width: nil, align: .leading) + HeaderLabel("Status", width: 60, align: .trailing) } .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.vertical, 6) + .background(Theme.appBackground) } +} - private var filterMenu: some View { - Menu { - Toggle("Errors only", isOn: $filter.onlyErrors) - Divider() - Section("Types") { - ForEach(TrafficFilter.ResourceKind.allCases) { kind in - toggleResource(kind) - } - } - Section("Methods") { - ForEach(methodOptions, id: \.self) { method in - toggle(method, in: \.methods) - } - } - Section("Hosts") { - ForEach(hostOptions, id: \.self) { host in - toggle(host, in: \.hosts) - } - } - Section("Status") { - ForEach(TrafficFilter.StatusBucket.allCases) { bucket in - toggleBucket(bucket) - } - } - Divider() - Button("Reset") { filter = TrafficFilter() } - } label: { - Label("Filters", systemImage: "line.3.horizontal.decrease") - } - .menuStyle(.borderlessButton) - .fixedSize() - } +private struct HeaderLabel: View { + let text: String + let width: CGFloat? + let align: Alignment - private var countLabel: String { - if activeFilterCount > 0 { - return "\(visibleCount) of \(totalCount) shown" - } - return "\(totalCount) captured" + init(_ text: String, width: CGFloat?, align: Alignment) { + self.text = text + self.width = width + self.align = align } - private var activeFilterCount: Int { - var count = 0 - if !filter.search.isEmpty { count += 1 } - if filter.onlyErrors { count += 1 } - count += filter.hosts.count - count += filter.methods.count - count += filter.statusBuckets.count - count += filter.resourceKinds.count - return count + var body: some View { + Text(text.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.6) + .frame(width: width, alignment: align) + .frame(maxWidth: width == nil ? .infinity : nil, alignment: align) } +} - private func toggle(_ value: String, in keyPath: WritableKeyPath>) -> some View { - Button { - if filter[keyPath: keyPath].contains(value) { - filter[keyPath: keyPath].remove(value) - } else { - filter[keyPath: keyPath].insert(value) - } - } label: { - HStack { - Image(systemName: filter[keyPath: keyPath].contains(value) ? "checkmark.square.fill" : "square") - Text(value) - } - } - } +// MARK: - Rows - private func toggleBucket(_ bucket: TrafficFilter.StatusBucket) -> some View { - Button { - if filter.statusBuckets.contains(bucket) { - filter.statusBuckets.remove(bucket) - } else { - filter.statusBuckets.insert(bucket) - } - } label: { - HStack { - Image(systemName: filter.statusBuckets.contains(bucket) ? "checkmark.square.fill" : "square") - Text(bucket.rawValue) - } - } - } +private struct TrafficRowList: View { + @Environment(AppState.self) private var state + let flows: [CapturedFlow] - private func toggleResource(_ kind: TrafficFilter.ResourceKind) -> some View { - Button { - if filter.resourceKinds.contains(kind) { - filter.resourceKinds.remove(kind) - } else { - filter.resourceKinds.insert(kind) + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(flows) { flow in + TrafficRow( + flow: flow, + isSelected: state.selectedFlowID == flow.id + ) { + state.selectedFlowID = flow.id + } + .id(flow.id) + } + } } - } label: { - HStack { - Image(systemName: filter.resourceKinds.contains(kind) ? "checkmark.square.fill" : "square") - Text(kind.rawValue) + .onChange(of: state.selectedFlowID) { _, new in + guard let new else { return } + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(new, anchor: .center) + } } } } } -private struct SearchField: NSViewRepresentable { - @Binding var text: String - let placeholder: String - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - func makeNSView(context: Context) -> NSSearchField { - let field = NSSearchField() - field.placeholderString = placeholder - field.sendsSearchStringImmediately = true - field.delegate = context.coordinator - field.font = .systemFont(ofSize: NSFont.systemFontSize) - field.controlSize = .regular - return field - } - - func updateNSView(_ field: NSSearchField, context: Context) { - context.coordinator.text = $text - if field.stringValue != text { - field.stringValue = text - } - field.placeholderString = placeholder - } +private struct TrafficRow: View { + let flow: CapturedFlow + let isSelected: Bool + let onSelect: () -> Void - final class Coordinator: NSObject, NSSearchFieldDelegate { - var text: Binding + @State private var isHovering = false - init(text: Binding) { - self.text = text - } + var body: some View { + Button(action: onSelect) { + HStack(spacing: 0) { + Text(flow.startedAt, format: .dateTime.hour().minute().second()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .frame(width: 72, alignment: .leading) - func controlTextDidChange(_ notification: Notification) { - guard let field = notification.object as? NSSearchField else { return } - text.wrappedValue = field.stringValue - } - } -} + MethodBadge(method: flow.method) + .frame(width: 64, alignment: .leading) -private struct ResourceKindBar: View { - @Binding var selectedKinds: Set + Text(flow.host) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 200, alignment: .leading) - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ResourceKindFilterButton(title: "All", isSelected: selectedKinds.isEmpty) { - selectedKinds.removeAll() - } + Text(flow.path) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) - ForEach(TrafficFilter.ResourceKind.allCases) { kind in - ResourceKindFilterButton(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { - if selectedKinds.contains(kind) { - selectedKinds.remove(kind) - } else { - selectedKinds.insert(kind) - } - } - } + StatusBadge(status: flow.responseStatus, error: flow.error) + .frame(width: 60, alignment: .trailing) } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(background) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .onHover { hovering in + isHovering = hovering + } } -} -private struct ResourceKindFilterButton: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.caption.weight(.medium)) - .foregroundStyle(isSelected ? Color.primary : Color.secondary) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background( - isSelected ? Color.accentColor.opacity(0.18) : Color.primary.opacity(0.045), - in: RoundedRectangle(cornerRadius: 6) - ) - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(isSelected ? Color.accentColor.opacity(0.55) : .clear, lineWidth: 1) - } - } - .help(title == "All" ? "Show all resource types" : "Toggle \(title) resources") + private var background: Color { + if isSelected { return Theme.elevated } + if isHovering { return Color.white.opacity(0.03) } + return Color.clear } } @@ -345,83 +158,75 @@ private struct MethodBadge: View { Text(method) .font(.system(.caption, design: .monospaced).weight(.semibold)) .foregroundStyle(color) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) } private var color: Color { switch method { - case "GET": return .blue - case "POST": return .green - case "PUT", "PATCH": return .orange - case "DELETE": return .red - case "CONNECT": return .purple - default: return .secondary + 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 } } } -private struct ResourceKindBadge: View { - let kind: TrafficFilter.ResourceKind +private struct StatusBadge: View { + let status: Int? + let error: String? var body: some View { - Text(kind.rawValue) - .font(.system(.caption, design: .monospaced).weight(.semibold)) - .foregroundStyle(color) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.13), in: RoundedRectangle(cornerRadius: 4)) + if let error { + Text("ERR") + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.danger) + .help(error) + } else if let status { + Text("\(status)") + .font(.system(.callout, design: .monospaced).weight(.medium)) + .foregroundStyle(color(for: status)) + } else { + Text("…") + .foregroundStyle(Theme.textTertiary) + } } - private var color: Color { - switch kind { - case .document: return .primary - case .fetch: return .blue - case .script: return .orange - case .stylesheet: return .blue - case .image: return .green - case .media: return .red - case .font: return .indigo - case .websocket: return .purple - case .other: return .secondary + private func color(for 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: - Empty states + private struct EmptyTrafficState: View { @Environment(AppState.self) private var state var body: some View { - VStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(Color.primary.opacity(0.055)) - .frame(width: 62, height: 62) - Image(systemName: state.isCapturing ? "dot.radiowaves.left.and.right" : "waveform.path.ecg.rectangle") - .font(.system(size: 28, weight: .medium)) - .foregroundStyle(state.isCapturing ? Color.green : Color.secondary) - } + VStack(spacing: 12) { + Image(systemName: state.isCapturing ? "dot.radiowaves.left.and.right" : "waveform.path.ecg") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(Theme.textTertiary) - VStack(spacing: 5) { + VStack(spacing: 4) { Text(title) - .font(.title3.weight(.semibold)) + .font(.headline) + .foregroundStyle(Theme.textPrimary) Text(message) .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) - .frame(maxWidth: 430) - } - - HStack(spacing: 8) { - EmptyStatePill(title: state.systemProxyEnabled ? "Device routed" : "Device not routed", systemImage: "network") - EmptyStatePill(title: state.caTrustInstalled ? "CA trusted" : "CA not trusted", systemImage: "seal") - EmptyStatePill(title: "127.0.0.1:\(state.port)", systemImage: "number") + .frame(maxWidth: 380) } } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) } private var title: String { @@ -432,82 +237,32 @@ private struct EmptyTrafficState: View { private var message: String { if state.isCapturing, !state.systemProxyEnabled { - return "Only clients configured to use the proxy will appear here. Switch to Device mode to route this Mac automatically." + return "Only clients configured to use the proxy will appear here." } if state.isCapturing, !state.caTrustInstalled { - return "HTTP traffic should appear immediately. Trust the CA to inspect HTTPS traffic without certificate errors." + return "HTTP shows up immediately. Trust the CA to inspect HTTPS." } if state.isCapturing { - return "Open an app or browser and make a request. New flows will appear as they start." + return "Open an app or browser to make a request." } - return "Start device capture to run the proxy and route this Mac through it." + return "Press Record to start capturing." } } private struct EmptyFilterState: View { var body: some View { - VStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 14) - .fill(Color.primary.opacity(0.055)) - .frame(width: 56, height: 56) - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 26, weight: .medium)) - .foregroundStyle(.secondary) - } + VStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(Theme.textTertiary) Text("No matching traffic") .font(.headline) + .foregroundStyle(Theme.textPrimary) Text("Clear or loosen the current filters.") .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .controlBackgroundColor)) - } -} - -private struct EmptyStatePill: View { - let title: String - let systemImage: String - - var body: some View { - Label(title, systemImage: systemImage) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 6)) - } -} - -private struct StatusBadge: View { - let status: Int? - let error: String? - - var body: some View { - if let error { - Text("ERR") - .font(.system(.caption, design: .monospaced).weight(.semibold)) - .foregroundStyle(Color.red) - .help(error) - } else if let status { - Text("\(status)") - .font(.system(.callout, design: .monospaced).weight(.medium)) - .foregroundStyle(color(for: status)) - } else { - Text("…") - .foregroundStyle(.tertiary) - } - } - - private func color(for 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 - } } } From fba86aec45d71b42f1fc1e2602e22e34ab382814 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:29:52 +0200 Subject: [PATCH 07/45] feat(macos): inspector preview tab and native segmented tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspector gets a real Preview tab and unifies its tab bar with the rest of the app: - Tabs (Overview / Request / Response / Preview) now use the shared NSSegmented capsule control — identical visuals + interactions to ModeToggle in ActionBar. - New Preview tab appears automatically when the response content-type is renderable: • image/*: NSImage decoded and drawn over a photoshop-style checkerboard (white@7% / white@2% tiles, 12pt). Tiny assets are capped at 16× scale instead of stretched to the full pane, and .interpolation(.none) keeps pixel art crisp. Tracking pixels (≤4×4) get a "tracking pixel" chip next to the dimensions so a 1×1 transparent GIF isn't mistaken for a broken image. • text/html: WKWebView with JavaScript disabled (defaultWebpagePreferences.allowsContentJavaScript = false) loads the raw HTML with the flow URL as baseURL so relative assets resolve. Layer-rounded container with subtle border to match the rest of the dark surfaces. • application/pdf: placeholder for a future PDFKit integration. - Strip nested rounded panels and gradient backgrounds out of the header/sections; rely on small SCREAMING-CAPS section labels and Theme.* colors so the inspector matches the rest of the redesigned shell. - Close button to dismiss the inspector inline without selecting another row. Co-Authored-By: Claude Opus 4.7 --- .../Sources/ReverseAPI/UI/InspectorView.swift | 596 ++++++++++++------ 1 file changed, 405 insertions(+), 191 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index 4b24f1a..7fc93db 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,133 +10,147 @@ 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) + .padding(16) + } + } + .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 } } - .background(Color(nsColor: .controlBackgroundColor)) + } + + private var tabBar: some View { + HStack { + NSSegmented( + labels: availableTabs.map { $0.rawValue }, + selection: Binding( + get: { availableTabs.firstIndex(of: tab) ?? 0 }, + set: { tab = availableTabs[$0] } + ) + ) + .fixedSize() + Spacer() + } + .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() if let finishedAt = flow.finishedAt { Text(formatDuration(flow.startedAt, finishedAt)) - .foregroundStyle(.secondary) - .font(.callout) + .foregroundStyle(Theme.textSecondary) + .font(.caption) + .monospacedDigit() } copyMenu + Button { + state.selectedFlowID = nil + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .help("Close inspector") } - VStack(alignment: .leading, spacing: 2) { - Text(flow.host) - .font(.headline) - .lineLimit(1) - .truncationMode(.middle) - Text(flow.url) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(2) - .truncationMode(.middle) - } + + Text(flow.url) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .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) - .padding(.top, 12) - .padding(.bottom, 10) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 12) } 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 +159,88 @@ 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, + flowURL: flow.url, + 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: 100, alignment: .leading) + .foregroundStyle(Theme.textSecondary) + .font(.callout) Text(value) .textSelection(.enabled) .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) } } + // 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 +249,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 +298,11 @@ 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 let text = String(data: data, encoding: .utf8), let ct, ct.lowercased().contains("text") { return text } return """ @@ -276,72 +312,251 @@ 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) } +} + +// MARK: - Preview kinds - private func headerValue(_ name: String, in headers: [HTTPHeader]) -> String? { - headers.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value +enum PreviewKind { + case image + case html + case pdf +} + +private struct PreviewPaneContent: View { + let data: Data + let kind: PreviewKind + let flowURL: String + 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: + HTMLPreview(data: data, baseURL: URL(string: flowURL)) + case .pdf: + PDFPreview(data: data) + } + } + + private var badgeIcon: String { + switch kind { + case .image: return "photo" + case .html: return "doc.richtext" + case .pdf: return "doc" + } + } + + private var badgeLabel: String { + contentType ?? { + switch kind { + case .image: return "image" + case .html: return "text/html" + case .pdf: return "application/pdf" + } + }() } } -private struct MetricCard: View { - let title: String - let value: String +private struct ImagePreview: View { + let data: Data var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.callout, design: .monospaced).weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) + if let image = NSImage(data: data) { + VStack(spacing: 8) { + ZStack { + CheckerboardPattern() + .clipShape(RoundedRectangle(cornerRadius: 8)) + Image(nsImage: image) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: maxDisplayWidth(for: image), maxHeight: maxDisplayHeight(for: image)) + .padding(8) + } + .frame(maxWidth: .infinity) + .frame(minHeight: 220, idealHeight: 360) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) + } + metaLine(for: image) + } + } else { + Text("Could not decode image data") + .foregroundStyle(Theme.textTertiary) + .font(.callout) + } + } + + private func metaLine(for image: NSImage) -> some View { + HStack(spacing: 8) { + Text("\(Int(image.size.width)) × \(Int(image.size.height))") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + if isTinyTracker(image) { + Text("tracking pixel") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Theme.elevated, in: Capsule()) + } + Spacer() } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8)) + } + + /// Avoid stretching tiny images to fill the preview — pixelated upscaling + /// destroys context. Cap them at 256pt so they remain readable. + private func maxDisplayWidth(for image: NSImage) -> CGFloat? { + guard image.size.width > 0, image.size.width <= 64 else { return nil } + return min(image.size.width * 16, 256) + } + + private func maxDisplayHeight(for image: NSImage) -> CGFloat? { + guard image.size.height > 0, image.size.height <= 64 else { return nil } + return min(image.size.height * 16, 256) + } + + private func isTinyTracker(_ image: NSImage) -> Bool { + image.size.width <= 4 && image.size.height <= 4 } } -private struct DetailPanel: View { - let title: String +/// Photoshop-style checkered transparency background — two visibly distinct +/// gray tiles so transparent/very-small images stay legible on a dark theme. +private struct CheckerboardPattern: View { + var body: some View { + Canvas { context, size in + let tileSize: CGFloat = 12 + let cols = Int(ceil(size.width / tileSize)) + let rows = Int(ceil(size.height / tileSize)) + let lightTile = Color.white.opacity(0.07) + let darkTile = Color.white.opacity(0.02) + for r in 0.. NSView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.appearance = NSAppearance(named: .darkAqua) + + let container = NSView() + container.wantsLayer = true + container.layer?.cornerRadius = 8 + container.layer?.borderColor = NSColor.white.withAlphaComponent(0.07).cgColor + container.layer?.borderWidth = 1 + container.layer?.masksToBounds = true + + webView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + container.heightAnchor.constraint(greaterThanOrEqualToConstant: 360), + ]) + loadContent(into: webView) + context.coordinator.webView = webView + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let webView = context.coordinator.webView else { return } + if context.coordinator.lastData != data { + loadContent(into: webView) + context.coordinator.lastData = data + } + } + + private func loadContent(into webView: WKWebView) { + let html = String(data: data, encoding: .utf8) ?? "" + webView.loadHTMLString(html, baseURL: baseURL) + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + var webView: WKWebView? + var lastData: Data? + } +} + +private struct PDFPreview: View { + let data: Data + + var body: some View { + Text("PDF preview is not yet supported. Use the response body to inspect raw bytes.") + .font(.callout) + .foregroundStyle(Theme.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Existing sections + +private struct Section: View { + let label: String let content: Content - init(title: String, @ViewBuilder content: () -> Content) { - self.title = title + init(label: String, @ViewBuilder content: () -> Content) { + self.label = label self.content = content() } var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.headline) - VStack(alignment: .leading, spacing: 8) { + Text(label.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.7) + VStack(alignment: .leading, spacing: 6) { content } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) } } } @@ -351,20 +566,21 @@ private struct HeadersSection: View { let headers: [HTTPHeader] var body: some View { - DetailPanel(title: title) { + Section(label: title) { if headers.isEmpty { Text("No headers") - .foregroundStyle(.tertiary) + .foregroundStyle(Theme.textTertiary) .font(.callout) } else { ForEach(Array(headers.enumerated()), id: \.offset) { _, header in HStack(alignment: .firstTextBaseline, spacing: 12) { Text(header.name) - .font(.system(.callout, design: .monospaced).weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 160, alignment: .leading) + .font(.system(.callout, design: .monospaced).weight(.medium)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 150, alignment: .leading) Text(header.value) .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) .textSelection(.enabled) .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) @@ -381,10 +597,10 @@ private struct BodySection: View { let headers: [HTTPHeader] var body: some View { - DetailPanel(title: title) { + Section(label: title) { if bodyData.isEmpty { Text("Empty body") - .foregroundStyle(.tertiary) + .foregroundStyle(Theme.textTertiary) .font(.callout) } else if let pretty = JSONFormatter.prettyPrintJSON(bodyData, contentType: contentType) { CodeBlock(text: pretty) @@ -439,37 +655,35 @@ private struct BinaryBodyNotice: View { let contentEncoding: String? var body: some View { - VStack(alignment: .leading, spacing: 8) { - Label("Binary body · \(byteCount) bytes", systemImage: "cube.transparent") - .font(.callout.weight(.medium)) + VStack(alignment: .leading, spacing: 6) { + Label("Binary · \(byteCount) bytes", systemImage: "cube.transparent") + .font(.callout) + .foregroundStyle(Theme.textPrimary) Text(reason) .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) .fixedSize(horizontal: false, vertical: true) if let contentType { Text("Content-Type: \(contentType)") .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.tertiary) + .foregroundStyle(Theme.textTertiary) } if let contentEncoding { Text("Content-Encoding: \(contentEncoding)") .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.tertiary) + .foregroundStyle(Theme.textTertiary) } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) } private var reason: String { if let contentEncoding, !contentEncoding.localizedCaseInsensitiveContains("identity") { - return "The server returned an encoded body. rae asks for identity encoding, but some servers still send compressed payloads." + return "Server returned an encoded body." } if let contentType, isKnownBinary(contentType) { - return "This response is a binary asset or protocol payload, so there is no text preview to show." + return "Binary asset — no text preview available." } - return "rae could not decode this body as JSON or readable UTF-8 text." + return "Could not decode body as JSON or readable text." } private func isKnownBinary(_ contentType: String) -> Bool { @@ -491,14 +705,14 @@ private struct CodeBlock: View { ScrollView(.horizontal, showsIndicators: true) { Text(text) .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) .textSelection(.enabled) - .padding(10) + .padding(12) .frame(maxWidth: .infinity, alignment: .leading) } - .background(Color(nsColor: .textBackgroundColor).opacity(0.62), in: RoundedRectangle(cornerRadius: 6)) + .background(Theme.appBackground, in: RoundedRectangle(cornerRadius: 8)) .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(.separator.opacity(0.45), lineWidth: 1) + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) } } } From e856ab9d27de6b3b12e7d3424a767e936057eb62 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:30:11 +0200 Subject: [PATCH 08/45] feat(macos): markdown rendering and cleaner agent panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssistantRow renders the agent's text through MarkdownView so headings, lists, fenced code blocks (with language tag and copy button), and inline bold/italic/code show correctly instead of one flat blob. - Tool calls and tool results collapse to a single chevron row each by default — expand to reveal the JSON / output. Cuts the visual noise dramatically during long tool-use chains. - Header, timeline, and composer all share Theme.appBackground (no more border between header/messages or messages/composer). The composer is a real NSTextView wrapped in SwiftUI: multi-line, ⇧⏎ for newline, ⏎ to send, ⌘⏎ also sends. Placeholder is drawn via a CATextLayer when the field is empty. - Send button: white pill on black with arrow.up, disabled state in Theme.elevated. Matches the rest of the dark UI. - ThinkingRow with three pulsing dots when status is .streaming for feedback during long agent runs. - FileWritten / Complete / GeneratedFiles rows now share a consistent surface (Theme.elevated + Theme.border) so each event-type doesn't scream a different color. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 502 ++++++++++++++----- 1 file changed, 377 insertions(+), 125 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 111508f..e2801bd 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -9,12 +9,17 @@ struct AgentPanel: View { @Bindable var agent = state.agent VStack(spacing: 0) { AgentHeader(target: $agent.target, status: agent.status, onClear: { agent.clear() }) - Divider() - AgentTimeline(events: agent.events, flowCount: flowsToSend.count, generatedFiles: agent.generatedFiles, workdir: agent.lastWorkdir, status: agent.status, error: agent.lastError) - Divider() + 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) } - .frame(minWidth: 360) + .background(Theme.appBackground) } private var flowsToSend: [CapturedFlow] { @@ -27,92 +32,128 @@ struct AgentPanel: View { } } +// MARK: - Header + private struct AgentHeader: View { @Binding var target: AgentTargetLanguage let status: AgentSession.Status let onClear: () -> Void var body: some View { - HStack(spacing: 12) { - HStack(spacing: 6) { - statusDot + HStack(spacing: 10) { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 0) { + Text("Agent") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) Text(statusLabel) - .font(.callout) - .foregroundStyle(.secondary) + .font(.system(size: 11)) + .foregroundStyle(Theme.textTertiary) } Spacer() - Picker("", selection: $target) { - ForEach(AgentTargetLanguage.allCases) { lang in - Text(lang.displayName).tag(lang) - } - } - .pickerStyle(.menu) - .fixedSize() + LanguageMenu(target: $target) Button(action: onClear) { - Image(systemName: "eraser.line.dashed") + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + .frame(width: 24, height: 24) } - .buttonStyle(.borderless) + .buttonStyle(.plain) .help("Clear conversation") } .padding(.horizontal, 14) .padding(.vertical, 10) - .background(.bar) - } - - private var statusDot: some View { - Circle() - .fill(statusColor) - .frame(width: 8, height: 8) } private var statusColor: Color { switch status { - case .idle: return .secondary + case .idle: return Theme.textTertiary case .launching: return .yellow - case .ready: return .green - case .streaming: return .blue - case .failed: return .red + case .ready: return Theme.success + case .streaming: return Theme.accent + case .failed: return Theme.danger } } private var statusLabel: String { switch status { - case .idle: return "Agent: idle" - case .launching: return "Agent: starting" - case .ready: return "Agent: ready" - case .streaming: return "Agent: thinking" - case .failed: return "Agent: error" + case .idle: return "Idle" + case .launching: return "Starting…" + case .ready: return "Ready" + case .streaming: return "Thinking…" + case .failed: return "Error" } } } +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 status: AgentSession.Status let error: String? + let status: AgentSession.Status var body: some View { ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 12) { - introCard + LazyVStack(alignment: .leading, spacing: 18) { + if events.isEmpty && error == nil { + IntroState(flowCount: flowCount) + } ForEach(events) { event in AgentEventRow(event: event) .id(event.id) } + if status == .streaming { + ThinkingRow() + } if let error { - ErrorBubble(message: error) + ErrorRow(message: error) } if !generatedFiles.isEmpty, let workdir { - GeneratedFilesCard(workdir: workdir, files: generatedFiles) + GeneratedFilesRow(workdir: workdir, files: generatedFiles) } } - .padding(14) + .padding(.horizontal, 16) + .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) } + .background(Theme.appBackground) .onChange(of: events.count) { _, _ in if let last = events.last { withAnimation(.easeOut(duration: 0.15)) { @@ -122,186 +163,279 @@ private struct AgentTimeline: View { } } } +} - private var introCard: some View { - VStack(alignment: .leading, spacing: 6) { +private struct IntroState: View { + let flowCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 8) { Text("Reverse engineer with the agent") - .font(.headline) - Text("The agent will see the filtered flows (currently \(flowCount)) and generate an API client. Files land in the session workdir.") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + Text("\(flowCount) filtered flow\(flowCount == 1 ? "" : "s") will be shared. Ask for an API client, an endpoint analysis, or anything else.") .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) } } +// MARK: - Event rows + private struct AgentEventRow: View { let event: AgentEvent var body: some View { switch event { case .assistantText(_, _, let text): - AssistantBubble(text: text) + AssistantRow(text: text) case .toolUse(_, _, let name, let inputJSON): - ToolUseBubble(name: name, inputJSON: inputJSON) + ToolUseRow(name: name, inputJSON: inputJSON) case .toolResult(_, _, let output, let isError): - ToolResultBubble(output: output, isError: isError) + ToolResultRow(output: output, isError: isError) case .fileWritten(_, _, let path): - FileWrittenBubble(path: path) + FileWrittenRow(path: path) case .complete(_, _, let workdir, let files): - CompleteBubble(workdir: workdir, fileCount: files.count) + CompleteRow(workdir: workdir, fileCount: files.count) case .error(_, _, let message): - ErrorBubble(message: message) + ErrorRow(message: message) } } } -private struct AssistantBubble: View { +private struct AssistantRow: View { let text: String var body: some View { - Text(text) - .font(.body) - .textSelection(.enabled) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + MarkdownView(text: text) } } -private struct ToolUseBubble: View { +private struct ToolUseRow: View { let name: String let inputJSON: String + @State private var isExpanded: Bool = false var body: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: "wrench.and.screwdriver") - .foregroundStyle(.tint) - Text(name) - .font(.system(.callout, design: .monospaced).weight(.semibold)) + Button { + if !inputJSON.isEmpty { + isExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "wrench.adjustable") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + Text(name) + .font(.system(.caption, design: .monospaced).weight(.medium)) + .foregroundStyle(Theme.textSecondary) + if !inputJSON.isEmpty { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + } + Spacer() + } } - if !inputJSON.isEmpty { + .buttonStyle(.plain) + + if isExpanded, !inputJSON.isEmpty { Text(inputJSON) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary) .textSelection(.enabled) - .lineLimit(8) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.appBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Theme.border, lineWidth: 1) + } } } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) } } -private struct ToolResultBubble: View { +private struct ToolResultRow: View { let output: String let isError: Bool + @State private var isExpanded: Bool = false var body: some View { - Text(output) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(isError ? .red : .secondary) - .textSelection(.enabled) - .lineLimit(10) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background((isError ? Color.red : Color.gray).opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 6) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 6) { + Image(systemName: isError ? "xmark.circle" : "checkmark.circle") + .font(.caption) + .foregroundStyle(isError ? Theme.danger : Theme.textTertiary) + Text(isError ? "result · error" : "result") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + Spacer() + } + } + .buttonStyle(.plain) + + if isExpanded { + Text(output) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(isError ? Theme.danger : Theme.textSecondary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.appBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Theme.border, lineWidth: 1) + } + } + } } } -private struct FileWrittenBubble: View { +private struct FileWrittenRow: View { let path: String var body: some View { - HStack(spacing: 6) { - Image(systemName: "doc.badge.plus") - .foregroundStyle(.green) + 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() Button { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } label: { Image(systemName: "arrow.up.right.square") + .font(.caption) + .foregroundStyle(Theme.textSecondary) } - .buttonStyle(.borderless) + .buttonStyle(.plain) + .help("Reveal in Finder") } .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .padding(.vertical, 7) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7).stroke(Theme.border, lineWidth: 1) + } } } -private struct CompleteBubble: View { +private struct CompleteRow: View { let workdir: String let fileCount: Int var body: some View { HStack(spacing: 8) { Image(systemName: "checkmark.seal.fill") - .foregroundStyle(.green) - Text("Agent finished — \(fileCount) file\(fileCount == 1 ? "" : "s") at \(URL(fileURLWithPath: workdir).lastPathComponent)") + .foregroundStyle(Theme.success) + .font(.callout) + Text("Finished · \(fileCount) file\(fileCount == 1 ? "" : "s")") .font(.callout) + .foregroundStyle(Theme.textPrimary) + Text(URL(fileURLWithPath: workdir).lastPathComponent) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + Spacer() } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) } } -private struct ErrorBubble: View { +private struct ErrorRow: View { let message: String var body: some View { HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.octagon.fill") - .foregroundStyle(.red) + 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(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .background(Theme.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.danger.opacity(0.25), lineWidth: 1) + } } } -private struct GeneratedFilesCard: View { +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 { let workdir: String let files: [String] var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("Generated files") - .font(.headline) + Text("GENERATED FILES") + .font(.system(size: 10, weight: .semibold, design: .default)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.8) Spacer() - Button("Open in Finder") { + Button { NSWorkspace.shared.open(URL(fileURLWithPath: workdir)) + } label: { + Text("Open folder") + .font(.caption) + .foregroundStyle(Theme.accent) } - .buttonStyle(.borderless) + .buttonStyle(.plain) } - ForEach(files, id: \.self) { file in - HStack { - Image(systemName: "doc") - .foregroundStyle(.secondary) - Text(file) - .font(.system(.callout, design: .monospaced)) - .textSelection(.enabled) - Spacer() + VStack(alignment: .leading, spacing: 4) { + ForEach(files, id: \.self) { file in + HStack(spacing: 8) { + Image(systemName: "doc") + .foregroundStyle(Theme.textTertiary) + .font(.caption) + Text(file) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textPrimary) + .textSelection(.enabled) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 6)) } } } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) + .padding(.top, 4) } } +// MARK: - Composer + private struct AgentComposer: View { @Binding var input: String let status: AgentSession.Status @@ -309,20 +443,27 @@ private struct AgentComposer: View { var body: some View { HStack(alignment: .bottom, spacing: 10) { - TextField("Ask the agent…", text: $input, axis: .vertical) - .lineLimit(1...6) - .textFieldStyle(.roundedBorder) - .onSubmit(onSend) - Button(action: onSend) { - Image(systemName: "paperplane.fill") - .frame(width: 22, height: 22) + ComposerTextField(text: $input, placeholder: "Ask the agent…", 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(.borderedProminent) + .buttonStyle(.plain) .keyboardShortcut(.return, modifiers: [.command]) .disabled(!canSend) + .help("Send (⌘↵)") } - .padding(14) - .background(.bar) + .padding(10) + .background(Theme.input, in: RoundedRectangle(cornerRadius: 12)) + .padding(12) + .background(Theme.appBackground) } private var canSend: Bool { @@ -330,3 +471,114 @@ private struct AgentComposer: View { return status != .launching } } + +private struct ComposerTextField: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(text: $text, onSubmit: onSubmit) } + + func makeNSView(context: Context) -> NSScrollView { + let textView = NSTextView() + textView.isRichText = false + textView.isEditable = true + textView.allowsUndo = true + textView.font = .systemFont(ofSize: 13) + textView.textColor = NSColor(Theme.textPrimary) + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.delegate = context.coordinator + textView.textContainerInset = NSSize(width: 0, height: 4) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.allowsCharacterPickerTouchBarItem = false + textView.appearance = NSAppearance(named: .darkAqua) + + let scroll = NSScrollView() + scroll.documentView = textView + scroll.drawsBackground = false + scroll.hasVerticalScroller = false + scroll.borderType = .noBorder + scroll.backgroundColor = .clear + + context.coordinator.textView = textView + context.coordinator.scrollView = scroll + context.coordinator.placeholder = placeholder + context.coordinator.updatePlaceholder() + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let textView = scroll.documentView as? NSTextView else { return } + context.coordinator.text = $text + context.coordinator.onSubmit = onSubmit + context.coordinator.placeholder = placeholder + if textView.string != text { + textView.string = text + } + context.coordinator.updatePlaceholder() + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var text: Binding + var onSubmit: () -> Void + weak var textView: NSTextView? + weak var scrollView: NSScrollView? + var placeholder: String = "" + private var placeholderLayer: CATextLayer? + + init(text: Binding, onSubmit: @escaping () -> Void) { + self.text = text + self.onSubmit = onSubmit + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + text.wrappedValue = textView.string + updatePlaceholder() + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + let event = NSApp.currentEvent + let isShift = event?.modifierFlags.contains(.shift) ?? false + if isShift { + textView.insertNewlineIgnoringFieldEditor(nil) + return true + } else { + onSubmit() + return true + } + } + return false + } + + func updatePlaceholder() { + guard let textView else { return } + if textView.string.isEmpty { + if placeholderLayer == nil { + let layer = CATextLayer() + layer.string = placeholder + layer.font = NSFont.systemFont(ofSize: 13) + layer.fontSize = 13 + layer.foregroundColor = NSColor(Theme.textTertiary).cgColor + layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer.alignmentMode = .left + textView.wantsLayer = true + textView.layer?.addSublayer(layer) + placeholderLayer = layer + } + if let layer = placeholderLayer { + layer.string = placeholder + let height: CGFloat = 18 + layer.frame = CGRect(x: 4, y: textView.bounds.height - height - 4, width: textView.bounds.width - 8, height: height) + } + } else { + placeholderLayer?.removeFromSuperlayer() + placeholderLayer = nil + } + } + } +} From 0d59f8a6c2f8759615a51ed1e4769a693ab877f7 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:30:22 +0200 Subject: [PATCH 09/45] chore(macos): commit Package.resolved to pin dependencies Co-Authored-By: Claude Opus 4.7 --- macos/Package.resolved | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 macos/Package.resolved diff --git a/macos/Package.resolved b/macos/Package.resolved new file mode 100644 index 0000000..5ab46b7 --- /dev/null +++ b/macos/Package.resolved @@ -0,0 +1,87 @@ +{ + "originHash" : "c1b419dd138e629ca0adc46a1f6bc06a7d5cb48df90e4e3914bd85837d0ecb09", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + }, + { + "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-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-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 +} From 2b3fb4862eed4ce9a11614075539155be8b7becc Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:50:48 +0200 Subject: [PATCH 10/45] refactor(macos): drop keyboard shortcuts and agent panel animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard shortcuts (⌘R, ⌘J, ⌘K, ⌘E, ⌘↵) were error-prone — they competed with text input focus on the search palette and agent composer, and the NSEvent.addLocalMonitorForEvents bridge could be ordered wrong with the responder chain. All actions are now discoverable through the toolbar buttons and … menu: - CaptureButton, agent toggle, Export HAR, Clear traffic: no more .keyboardShortcut modifiers. - Hidden Button("Search") used to surface ⌘K is removed. - ⌘K badge stripped from the SearchButton (was misleading without the shortcut) — it's just the magnifying glass icon now. - AppDelegate.NSEvent monitor for ⌘J and the toggleRaeAgent Notification.Name are deleted. - ContentView's matching .onReceive observer is removed. - The Hide/Show Agent CommandGroup is gone. Also drop the slide-in animation on the agent panel: the offset+width+clipped trick still produced visible layout shifts on some configurations. The panel is now a plain conditional view — shows / hides instantly without animating. The command palette animation is unaffected. WindowAccessor's NSView gains hitTest -> nil and acceptsFirstResponder = false so it can never intercept clicks or keystrokes intended for the SwiftUI content sitting in front of it. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/App/ReverseAPIApp.swift | 30 +++--------- macos/Sources/ReverseAPI/UI/ContentView.swift | 47 +++++-------------- 2 files changed, 19 insertions(+), 58 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 169b92d..38bf944 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -42,12 +42,6 @@ struct ReverseAPIApp: App { .windowToolbarStyle(.unifiedCompact) .commands { CommandGroup(replacing: .newItem) {} - CommandGroup(after: .toolbar) { - Button(isAgentVisible ? "Hide Agent" : "Show Agent") { - isAgentVisible.toggle() - } - .keyboardShortcut("j", modifiers: .command) - } } } } @@ -65,19 +59,10 @@ final class AppLifecycle { } final class AppDelegate: NSObject, NSApplicationDelegate { - private var keyMonitor: Any? private var isTerminating = false func applicationDidFinishLaunching(_ notification: Notification) { NSApp.appearance = NSAppearance(named: .darkAqua) - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let togglesAgent = event.charactersIgnoringModifiers?.lowercased() == "j" && - modifiers == .command - guard togglesAgent else { return event } - NotificationCenter.default.post(name: .toggleRaeAgent, object: nil) - return nil - } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -99,20 +84,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 toggleRaeAgent = Notification.Name("rae.toggleAgent") -} - enum AppSession { case ready(AppState) case failed(Error) @@ -161,6 +138,13 @@ private struct WindowAccessor: NSViewRepresentable { 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 } } } diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 0d094db..6580d9d 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -25,12 +25,10 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) - AgentPanel() - .frame(width: 380) - .offset(x: isAgentVisible ? 0 : 380) - .frame(width: isAgentVisible ? 380 : 0) - .clipped() - .allowsHitTesting(isAgentVisible) + if isAgentVisible { + AgentPanel() + .frame(width: 380) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -38,14 +36,6 @@ struct ContentView: View { StatusBar() } - // Hidden button to capture ⌘K globally - Button("Search") { - isPaletteVisible.toggle() - } - .keyboardShortcut("k", modifiers: [.command]) - .opacity(0) - .frame(width: 0, height: 0) - // Dim layer (fades alone) if isPaletteVisible { Theme.overlay @@ -68,7 +58,6 @@ struct ContentView: View { } } .animation(.spring(response: 0.32, dampingFraction: 0.84), value: isPaletteVisible) - .animation(.spring(response: 0.42, dampingFraction: 0.86), value: isAgentVisible) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Theme.appBackground) .preferredColorScheme(.dark) @@ -76,9 +65,6 @@ struct ContentView: View { .task { await state.recoverStaleSystemProxyOnLaunch() } - .onReceive(NotificationCenter.default.publisher(for: .toggleRaeAgent)) { _ in - isAgentVisible.toggle() - } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in state.restoreProxyBeforeExit() } @@ -93,7 +79,6 @@ struct ContentView: View { Image(systemName: isAgentVisible ? "sparkles.rectangle.stack.fill" : "sparkles.rectangle.stack") } .help(isAgentVisible ? "Hide agent" : "Show agent") - .keyboardShortcut("j", modifiers: [.command]) ActionsMenu() @@ -127,9 +112,8 @@ private struct CaptureButton: View { .frame(width: 22, height: 22) } .buttonStyle(.borderless) - .help(state.isCapturing ? "Stop capture (⌘R)" : "Start capture (⌘R)") + .help(state.isCapturing ? "Stop capture" : "Start capture") .disabled(state.isWorking) - .keyboardShortcut("r", modifiers: [.command]) } private var icon: String { @@ -187,12 +171,10 @@ private struct ActionsMenu: View { Section { Button("Export HAR…") { exportHAR() } - .keyboardShortcut("e", modifiers: [.command, .shift]) .disabled(state.store.flows.isEmpty) Button("Clear traffic") { state.clearFlows() } - .keyboardShortcut("k", modifiers: [.command]) .disabled(state.store.flows.isEmpty) } } label: { @@ -427,20 +409,15 @@ private struct SearchButton: View { var body: some View { Button(action: action) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Theme.textSecondary) - Text("⌘K") - .font(.system(.caption2, design: .monospaced).weight(.semibold)) - .foregroundStyle(Theme.textTertiary) - } - .padding(.horizontal, 11) - .padding(.vertical, 5) - .background(isHovering ? PillStyle.activeBackground : PillStyle.hoverBackground, in: Capsule()) + 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 (⌘K)") + .help("Search captured traffic") } } From 740715532b61cb729c9d3b50a0e75291d47bb868 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 03:51:10 +0200 Subject: [PATCH 11/45] fix(macos): use AppKit text field wrappers for palette and agent inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI TextField + @FocusState wasn't reliably routing keystrokes when the command palette was presented as an overlay or when the agent composer was inside the right-hand panel — focus appeared to move but the field never received key events. Replace both with NSViewRepresentable wrappers that talk to AppKit directly: - NativeSearchField wraps NSTextField (single-line) for the command palette query. textColor / placeholderAttributedString / insertionPointColor are all set explicitly against Theme.* so the text stays visible on the near-black background. First responder is grabbed via window.makeFirstResponder(field) on the next runloop tick so the palette is type-ready the moment it opens. - NativeMultilineTextField wraps NSTextView (multi-line, axis is effectively vertical) for the agent composer. Same explicit color setup; Return submits, Shift-Return inserts a newline via the textView(_:doCommandBy:) delegate hook. Drops the previous CATextLayer placeholder hack (added a sublayer to NSTextView, which interfered with the field's internal layer management). Placeholder is now a SwiftUI Text overlay with .allowsHitTesting(false) shown when the bound string is empty. While in the same files, also clean up the agent panel's empty state: drop "Reverse engineer with the agent" + "N filtered flows will be shared" and show a single centered chevron.left.forwardslash. chevron.right — the SF Symbol that doubles as a logo for the reverse-API-engineer name. Less text, more breathing room when no conversation has started yet. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 175 ++++++++---------- .../ReverseAPI/UI/CommandPalette.swift | 95 ++++++++-- 2 files changed, 158 insertions(+), 112 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index e2801bd..959eccb 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -129,35 +129,38 @@ private struct AgentTimeline: View { let status: AgentSession.Status var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - if events.isEmpty && error == nil { - IntroState(flowCount: flowCount) - } - 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) + if events.isEmpty && error == nil && generatedFiles.isEmpty && status != .streaming { + EmptyAgentState() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Theme.appBackground) + } 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) } - .padding(.horizontal, 16) - .padding(.vertical, 18) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(Theme.appBackground) - .onChange(of: events.count) { _, _ in - if let last = events.last { - withAnimation(.easeOut(duration: 0.15)) { - proxy.scrollTo(last.id, anchor: .bottom) + .background(Theme.appBackground) + .onChange(of: events.count) { _, _ in + if let last = events.last { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(last.id, anchor: .bottom) + } } } } @@ -165,19 +168,11 @@ private struct AgentTimeline: View { } } -private struct IntroState: View { - let flowCount: Int - +private struct EmptyAgentState: View { var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Reverse engineer with the agent") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - Text("\(flowCount) filtered flow\(flowCount == 1 ? "" : "s") will be shared. Ask for an API client, an endpoint analysis, or anything else.") - .font(.callout) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 42, weight: .light)) + .foregroundStyle(Theme.textTertiary) } } @@ -443,8 +438,21 @@ private struct AgentComposer: View { var body: some View { HStack(alignment: .bottom, spacing: 10) { - ComposerTextField(text: $input, placeholder: "Ask the agent…", onSubmit: { if canSend { onSend() } }) + 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)) @@ -456,9 +464,8 @@ private struct AgentComposer: View { ) } .buttonStyle(.plain) - .keyboardShortcut(.return, modifiers: [.command]) .disabled(!canSend) - .help("Send (⌘↵)") + .help("Send") } .padding(10) .background(Theme.input, in: RoundedRectangle(cornerRadius: 12)) @@ -472,113 +479,81 @@ private struct AgentComposer: View { } } -private struct ComposerTextField: NSViewRepresentable { +/// 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 placeholder: String let onSubmit: () -> Void - func makeCoordinator() -> Coordinator { Coordinator(text: $text, onSubmit: onSubmit) } + 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 textView.font = .systemFont(ofSize: 13) textView.textColor = NSColor(Theme.textPrimary) - textView.backgroundColor = .clear + textView.insertionPointColor = NSColor(Theme.textPrimary) textView.drawsBackground = false - textView.delegate = context.coordinator - textView.textContainerInset = NSSize(width: 0, height: 4) + 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.allowsCharacterPickerTouchBarItem = false - textView.appearance = NSAppearance(named: .darkAqua) + textView.isAutomaticTextCompletionEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + textView.isAutomaticDataDetectionEnabled = false let scroll = NSScrollView() scroll.documentView = textView scroll.drawsBackground = false scroll.hasVerticalScroller = false + scroll.hasHorizontalScroller = false scroll.borderType = .noBorder - scroll.backgroundColor = .clear + scroll.appearance = NSAppearance(named: .darkAqua) context.coordinator.textView = textView - context.coordinator.scrollView = scroll - context.coordinator.placeholder = placeholder - context.coordinator.updatePlaceholder() return scroll } func updateNSView(_ scroll: NSScrollView, context: Context) { guard let textView = scroll.documentView as? NSTextView else { return } - context.coordinator.text = $text - context.coordinator.onSubmit = onSubmit - context.coordinator.placeholder = placeholder + context.coordinator.parent = self if textView.string != text { textView.string = text } - context.coordinator.updatePlaceholder() } final class Coordinator: NSObject, NSTextViewDelegate { - var text: Binding - var onSubmit: () -> Void + var parent: NativeMultilineTextField weak var textView: NSTextView? - weak var scrollView: NSScrollView? - var placeholder: String = "" - private var placeholderLayer: CATextLayer? - init(text: Binding, onSubmit: @escaping () -> Void) { - self.text = text - self.onSubmit = onSubmit + init(_ parent: NativeMultilineTextField) { + self.parent = parent } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } - text.wrappedValue = textView.string - updatePlaceholder() + parent.text = textView.string } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.insertNewline(_:)) { - let event = NSApp.currentEvent - let isShift = event?.modifierFlags.contains(.shift) ?? false - if isShift { + let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shift { textView.insertNewlineIgnoringFieldEditor(nil) - return true } else { - onSubmit() - return true + parent.onSubmit() } + return true } return false } - - func updatePlaceholder() { - guard let textView else { return } - if textView.string.isEmpty { - if placeholderLayer == nil { - let layer = CATextLayer() - layer.string = placeholder - layer.font = NSFont.systemFont(ofSize: 13) - layer.fontSize = 13 - layer.foregroundColor = NSColor(Theme.textTertiary).cgColor - layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 - layer.alignmentMode = .left - textView.wantsLayer = true - textView.layer?.addSublayer(layer) - placeholderLayer = layer - } - if let layer = placeholderLayer { - layer.string = placeholder - let height: CGFloat = 18 - layer.frame = CGRect(x: 4, y: textView.bounds.height - height - 4, width: textView.bounds.width - 8, height: height) - } - } else { - placeholderLayer?.removeFromSuperlayer() - placeholderLayer = nil - } - } } } diff --git a/macos/Sources/ReverseAPI/UI/CommandPalette.swift b/macos/Sources/ReverseAPI/UI/CommandPalette.swift index 13002e2..c0c6f62 100644 --- a/macos/Sources/ReverseAPI/UI/CommandPalette.swift +++ b/macos/Sources/ReverseAPI/UI/CommandPalette.swift @@ -8,7 +8,6 @@ struct CommandPalette: View { @State private var query: String = "" @State private var highlightedIndex: Int = 0 - @FocusState private var isInputFocused: Bool @Namespace private var highlightNamespace var body: some View { @@ -41,11 +40,6 @@ struct CommandPalette: View { .onChange(of: query) { _, _ in highlightedIndex = 0 } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - isInputFocused = true - } - } .onKeyPress(.upArrow) { moveHighlight(-1) return .handled @@ -80,12 +74,13 @@ struct CommandPalette: View { .font(.system(size: 18, weight: .medium)) .foregroundStyle(Theme.textSecondary) - TextField("", text: $query, prompt: Text("Search traffic").foregroundColor(Theme.textTertiary)) - .textFieldStyle(.plain) - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(Theme.textPrimary) - .focused($isInputFocused) - .onSubmit { select() } + NativeSearchField( + text: $query, + placeholder: "Search traffic", + onSubmit: { select() } + ) + .frame(maxWidth: .infinity) + .frame(height: 28) if !query.isEmpty { Button { @@ -494,6 +489,82 @@ private struct CountChip: View { } } +// 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) + field.textColor = NSColor(Theme.textPrimary) + field.placeholderAttributedString = NSAttributedString( + string: placeholder, + attributes: [ + .font: NSFont.systemFont(ofSize: 18, weight: .regular), + .foregroundColor: NSColor(Theme.textTertiary), + ] + ) + 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. + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + if let editor = field.currentEditor() as? NSTextView { + editor.insertionPointColor = NSColor(Theme.textPrimary) + } + } + + 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 { From 92d995b7927670eb210c44326b7f673dbb224a04 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:07:47 +0200 Subject: [PATCH 12/45] fix(macos): set .regular activation policy so swift run inputs work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a real .app bundle wrapper, `swift run rae` launches the SwiftPM binary as an unbundled tool. macOS then doesn't treat it as a regular GUI application: the window never reliably becomes key, the responder chain is partially initialized, and AppKit text fields silently fail to receive keystrokes — which is what was making the command palette and agent composer feel "broken." Calling NSApp.setActivationPolicy(.regular) plus NSApp.activate(ignoringOtherApps:) in applicationDidFinishLaunching explicitly promotes the process to a foreground app (Dock icon, menu bar, focus). It's a no-op when the binary is launched from a proper .app bundle (Info.plist already configured it that way), so it's safe to leave in. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/App/ReverseAPIApp.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index 38bf944..f4aeb91 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -62,6 +62,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var isTerminating = false func applicationDidFinishLaunching(_ notification: Notification) { + // `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) } From 6177d9c4fd4157df99d5fa1606f782787edeb3dc Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:07:59 +0200 Subject: [PATCH 13/45] fix(macos): force explicit white on text views to stop invisible text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSTextView in particular renders typed characters using typingAttributes, not textColor alone, so .labelColor / NSColor(Color) resolved against the dark appearance occasionally landed on values that matched the composer background — text appeared transparent or the same shade as the surface around it. For both the palette search field (NSTextField) and the agent composer (NSTextView): - Hard-code white instead of semantic colors so we don't depend on the .darkAqua appearance being fully resolved at the moment the view is configured. - Set typingAttributes on NSTextView explicitly (foregroundColor + font) so newly typed text picks up white from character 1. - Re-apply attributes to the entire NSTextStorage whenever .string is set from the SwiftUI side, since setString strips existing attributes. - Match the field editor's caret/selection colors so the cursor and highlighted text both stay visible. - Use NSColor.white.withAlphaComponent(0.35) for the palette placeholder for the same reason — visible but distinctly muted. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 33 +++++++++++++++++-- .../ReverseAPI/UI/CommandPalette.swift | 20 ++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 959eccb..19485e8 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -496,9 +496,26 @@ private struct NativeMultilineTextField: NSViewRepresentable { textView.isEditable = true textView.isSelectable = true textView.allowsUndo = true - textView.font = .systemFont(ofSize: 13) - textView.textColor = NSColor(Theme.textPrimary) - textView.insertionPointColor = NSColor(Theme.textPrimary) + + // 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) @@ -527,6 +544,16 @@ private struct NativeMultilineTextField: NSViewRepresentable { 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) + ) } } diff --git a/macos/Sources/ReverseAPI/UI/CommandPalette.swift b/macos/Sources/ReverseAPI/UI/CommandPalette.swift index c0c6f62..3f1c840 100644 --- a/macos/Sources/ReverseAPI/UI/CommandPalette.swift +++ b/macos/Sources/ReverseAPI/UI/CommandPalette.swift @@ -511,12 +511,15 @@ private struct NativeSearchField: NSViewRepresentable { field.drawsBackground = false field.focusRingType = .none field.font = .systemFont(ofSize: 18, weight: .regular) - field.textColor = NSColor(Theme.textPrimary) + // 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(Theme.textTertiary), + .foregroundColor: NSColor.white.withAlphaComponent(0.35), ] ) field.stringValue = text @@ -525,11 +528,20 @@ private struct NativeSearchField: NSViewRepresentable { field.cell?.wraps = false field.cell?.isScrollable = true - // Take first responder once the view has a window. + // 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 = NSColor(Theme.textPrimary) + editor.insertionPointColor = .white + editor.typingAttributes = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 18, weight: .regular), + ] + editor.selectedTextAttributes = [ + .backgroundColor: NSColor.selectedTextBackgroundColor, + .foregroundColor: NSColor.white, + ] } } From 0aeb228175faf1c3ebeaf6e035e16359b0868a16 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:08:20 +0200 Subject: [PATCH 14/45] =?UTF-8?q?feat(macos):=20self-contained=20agent=20s?= =?UTF-8?q?idecar=20=E2=80=94=20embed=20python=20runtime=20in=20.app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End users should never have to touch Python, pip, or a virtualenv to use rae. Make the sidecar fully self-contained for production builds and add a one-shot dev setup so contributors get the same automatic behavior from `swift run`. AgentRuntime (new): single resolver that picks where to launch the sidecar from at runtime: - bundled: Contents/Resources/agent-runtime/bin/python3 in the .app — shipped with rae-agent + all deps already installed. The end-user flow. - dev: walks up from the executable to find /backend/rae_agent/ __init__.py, then uses /.venv/bin/python3 with PYTHONPATH set to backend/. Used by `swift run rae` after `dev-setup.sh` has installed the package once. - fallback: /usr/bin/env python3 if nothing else matched — kept only so failures produce a clean ModuleNotFoundError rather than an exec error. AgentSidecar: - LaunchSpec.python3 delegates to AgentRuntime so the right interpreter is picked automatically per environment. - LaunchSpec carries pythonPath; spec.run merges those entries into the child's PYTHONPATH on top of whatever the parent had. - waitForBoundPort now also drains stderr, and a new SidecarError.stderrSnapshot surfaces the actual Python traceback in the UI instead of just "process died with status 1" — way easier to diagnose missing deps or wrong interpreters. - exec failures (e.g. python3 not found) raise SidecarError .failedToStart with the underlying message instead of leaking the Foundation NSError. scripts/build-app.sh (new): builds release rae + creates a portable rae.app/. Uses `uv venv --relocatable` to embed Python and `uv pip install /backend` to install rae-agent into it. Writes an Info.plist so AppKit treats the binary as a regular GUI app (real Dock icon, menu bar, focus — same effect we get at runtime from setActivationPolicy(.regular), but now baked in). Final smoke test imports rae_agent.server in the embedded runtime so we fail loud if a dep is missing. scripts/dev-setup.sh (new): one command for contributors: `./macos/scripts/dev-setup.sh` creates /.venv (if missing) and installs backend/ in editable mode. After this, `swift run rae` finds the venv via AgentRuntime.dev and the agent starts on the first message. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/Agent/AgentRuntime.swift | 95 ++++++++++++++ .../ReverseAPI/Agent/AgentSidecar.swift | 85 ++++++++++--- macos/scripts/build-app.sh | 116 ++++++++++++++++++ macos/scripts/dev-setup.sh | 35 ++++++ 4 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 macos/Sources/ReverseAPI/Agent/AgentRuntime.swift create mode 100755 macos/scripts/build-app.sh create mode 100755 macos/scripts/dev-setup.sh 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/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift index 82ece77..2d8d48e 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -5,18 +5,31 @@ actor AgentSidecar { var executablePath: String var arguments: [String] var workdir: URL - - public init(executablePath: String, arguments: [String], 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 { - LaunchSpec( - executablePath: "/usr/bin/env", - arguments: ["python3", "-m", "rae_agent.server"], - workdir: workdir + let runtime = AgentRuntime.resolve() + return LaunchSpec( + executablePath: runtime.executablePath, + arguments: runtime.arguments, + workdir: workdir, + pythonPath: runtime.pythonPath, + origin: runtime.origin ) } @@ -25,11 +38,22 @@ actor AgentSidecar { } } - enum SidecarError: Error { + 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? @@ -49,6 +73,12 @@ actor AgentSidecar { 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() @@ -56,11 +86,15 @@ actor AgentSidecar { process.standardOutput = stdout process.standardError = stderr - try process.run() + 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, deadline: timeout, process: process) + let bound = try await waitForBoundPort(stdout: stdout, stderr: stderr, deadline: timeout, process: process) self.port = bound return bound } catch { @@ -82,16 +116,23 @@ actor AgentSidecar { self.port = nil } - private func waitForBoundPort(stdout: Pipe, deadline: Duration, process: Process) async throws -> Int { - let handle = stdout.fileHandleForReading + 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() - handle.readabilityHandler = { fileHandle in - let chunk = fileHandle.availableData - if !chunk.isEmpty { - buffer.append(chunk) - } + 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 } - defer { handle.readabilityHandler = nil } let deadlineDate = ContinuousClock.now.advanced(by: deadline) while ContinuousClock.now < deadlineDate { @@ -103,6 +144,10 @@ actor AgentSidecar { 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)) @@ -130,4 +175,10 @@ private final class AsyncStreamBuffer: @unchecked Sendable { } return nil } + + func snapshot() -> String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } } diff --git a/macos/scripts/build-app.sh b/macos/scripts/build-app.sh new file mode 100755 index 0000000..1686e25 --- /dev/null +++ b/macos/scripts/build-app.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Build a portable rae.app bundle with a self-contained Python runtime. +# +# Output: /macos/dist/rae.app — drop into /Applications and it just runs. +# No user-side Python or pip required. +# +# Requires: +# - swift (Xcode command line tools) +# - uv (https://docs.astral.sh/uv) — used to fetch a standalone Python build +# and create a relocatable venv inside the .app +# +# Usage: +# ./macos/scripts/build-app.sh # builds release +# PYTHON_VERSION=3.12 ./macos/scripts/build-app.sh # pin the Python version + +set -euo pipefail + +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$MACOS_DIR/.." && pwd)" +DIST_DIR="$MACOS_DIR/dist" +APP_DIR="$DIST_DIR/rae.app" +CONTENTS="$APP_DIR/Contents" +MACOS_BIN="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" +AGENT_RUNTIME="$RESOURCES/agent-runtime" + +require() { + command -v "$1" >/dev/null 2>&1 || { echo "error: '$1' not found in PATH" >&2; exit 1; } +} + +require swift +require uv + +# --------------------------------------------------------------------------- +# 1. Clean & scaffold the .app bundle layout +# --------------------------------------------------------------------------- +echo "→ cleaning $APP_DIR" +rm -rf "$APP_DIR" +mkdir -p "$MACOS_BIN" "$RESOURCES" + +# --------------------------------------------------------------------------- +# 2. Build the Swift release binary +# --------------------------------------------------------------------------- +echo "→ swift build (release)" +cd "$MACOS_DIR" +swift build -c release --product rae +cp "$MACOS_DIR/.build/release/rae" "$MACOS_BIN/rae" +chmod +x "$MACOS_BIN/rae" + +# --------------------------------------------------------------------------- +# 3. Embed a relocatable Python runtime with rae-agent installed +# +# uv venv --relocatable rewrites the activator + shebangs so the venv can +# be moved (or shipped) to a different absolute path. uv pip install with +# --python targets that specific interpreter so all deps land +# inside the venv's site-packages, not the host's. +# --------------------------------------------------------------------------- +echo "→ creating embedded Python $PYTHON_VERSION runtime" +uv venv --relocatable --python "$PYTHON_VERSION" "$AGENT_RUNTIME" + +echo "→ installing rae-agent into the embedded runtime" +uv pip install \ + --python "$AGENT_RUNTIME/bin/python3" \ + --quiet \ + "$REPO_ROOT/backend" + +# Strip __pycache__ + tests to shave a few MB +find "$AGENT_RUNTIME" -type d -name "__pycache__" -prune -exec rm -rf {} + +find "$AGENT_RUNTIME" -type d -name "tests" -prune -exec rm -rf {} + + +# --------------------------------------------------------------------------- +# 4. Info.plist — minimal so macOS treats the binary as a regular GUI app +# (Dock icon, menu bar, keyboard focus, the works). +# --------------------------------------------------------------------------- +cat > "$CONTENTS/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + app.rae.reverseapi + CFBundleName + rae + CFBundleDisplayName + rae + CFBundleExecutable + rae + CFBundleVersion + 1 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + LSMinimumSystemVersion + 14.0 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + +PLIST + +# --------------------------------------------------------------------------- +# 5. Smoke check — make sure rae_agent imports inside the embedded runtime +# --------------------------------------------------------------------------- +echo "→ smoke-testing embedded runtime" +"$AGENT_RUNTIME/bin/python3" -c "import rae_agent.server; print('rae_agent ok')" + +echo "" +echo "✓ built $APP_DIR" +du -sh "$APP_DIR" 2>/dev/null | awk '{print " size: " $1}' diff --git a/macos/scripts/dev-setup.sh b/macos/scripts/dev-setup.sh new file mode 100755 index 0000000..8243b1d --- /dev/null +++ b/macos/scripts/dev-setup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Prepare a dev environment so `swift run rae` can launch the agent sidecar +# without any manual Python setup. +# +# Creates /.venv (if missing), installs `rae-agent` from +# /backend in editable mode, and prints a summary. +# +# Requires uv (https://docs.astral.sh/uv). + +set -euo pipefail + +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +VENV="$REPO_ROOT/.venv" + +command -v uv >/dev/null 2>&1 || { + echo "error: 'uv' not found — install from https://docs.astral.sh/uv" >&2 + exit 1 +} + +if [[ ! -x "$VENV/bin/python3" ]]; then + echo "→ creating $VENV (python $PYTHON_VERSION)" + uv venv --python "$PYTHON_VERSION" "$VENV" +fi + +echo "→ installing rae-agent (editable) into $VENV" +uv pip install \ + --python "$VENV/bin/python3" \ + --quiet \ + -e "$REPO_ROOT/backend" + +echo "" +"$VENV/bin/python3" -c "import rae_agent.server; print('✓ rae_agent importable')" +echo "→ ready: swift run rae will find $VENV" From 695ea2dd313253c6eaae2e3161cd3df39069efd0 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:15:23 +0200 Subject: [PATCH 15/45] fix(macos): wrap agent composer text instead of overflowing horizontally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSTextView inside an NSScrollView defaults to a horizontally-growable text container — long lines kept extending past the right edge of the composer instead of wrapping to a new line. Configure the text container to track the scroll view's width so the layout engine inserts soft line breaks at the visible edge: - isVerticallyResizable = true, isHorizontallyResizable = false - minSize/maxSize bounded so the view can grow with content - autoresizingMask = [.width] so it follows the scroll view - textContainer.widthTracksTextView = true with a 0 width seed and unbounded height (line fragment padding zeroed for clean alignment) Also enable the vertical scroller with autohide so very long prompts can still scroll without horizontal overflow. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 19485e8..a7dd184 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -497,6 +497,22 @@ private struct NativeMultilineTextField: NSViewRepresentable { 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 @@ -530,8 +546,9 @@ private struct NativeMultilineTextField: NSViewRepresentable { let scroll = NSScrollView() scroll.documentView = textView scroll.drawsBackground = false - scroll.hasVerticalScroller = false + scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = false + scroll.autohidesScrollers = true scroll.borderType = .noBorder scroll.appearance = NSAppearance(named: .darkAqua) From b39af3cdf3a5e8b20800ddde607b4002b14562b4 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:15:37 +0200 Subject: [PATCH 16/45] refactor(macos): drop bottom status bar, surface state + CA in action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom StatusBar was eating a row of chrome to display three things: capture state + port, CA trust state, and a flow counter. Only the first two are actually useful day-to-day, and they belong next to the controls that change them. Promote them into the existing ActionBar as compact pills: - CaptureStateChip: small dot + "recording · " / "idle · " / "working…" in a capsule with Theme.input background. - CATrustChip: seal icon + "CA trusted" / "CA" in the same capsule shape, full text in a tooltip explaining what trust means. The flow count chip is dropped — it was decorative. The whole StatusBar view + StatusDot + Dot helpers are removed from ContentView, along with the trailing ThinDivider that separated it from the main area. The window is now: toolbar / action bar / main split, no footer. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/ContentView.swift | 84 +++++++++---------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 6580d9d..9269d74 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -31,9 +31,6 @@ struct ContentView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - - ThinDivider() - StatusBar() } // Dim layer (fades alone) @@ -228,8 +225,6 @@ private struct ActionBar: View { ResourceKindStrip(selectedKinds: $bindable.filter.resourceKinds) - SearchButton(action: onOpenPalette) - if activeFilterCount > 0 { Button { bindable.filter = TrafficFilter() @@ -241,6 +236,10 @@ private struct ActionBar: View { .buttonStyle(.borderless) .help("Clear \(activeFilterCount) active filter(s)") } + + CaptureStateChip() + CATrustChip() + SearchButton(action: onOpenPalette) } .padding(.horizontal, 14) .padding(.vertical, 8) @@ -343,61 +342,58 @@ private struct ErrorBanner: View { } } -// MARK: - Status bar (footer) +// MARK: - Inline status chips (live in the ActionBar) -private struct StatusBar: View { +private struct CaptureStateChip: View { @Environment(AppState.self) private var state var body: some View { - HStack(spacing: 12) { - StatusDot(color: state.isCapturing ? Theme.success : Theme.textTertiary) - Text(captureLabel) - .font(.caption) - .foregroundStyle(Theme.textSecondary) - - Dot() - - Image(systemName: state.caTrustInstalled ? "checkmark.seal.fill" : "seal") - .foregroundStyle(state.caTrustInstalled ? Theme.success : Theme.textTertiary) - .font(.caption) - Text(state.caTrustInstalled ? "CA trusted" : "CA not trusted") - .font(.caption) - .foregroundStyle(Theme.textSecondary) - - Spacer() - - Text("\(state.store.flows.count) flows") + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 6, height: 6) + Text(label) .font(.caption) .foregroundStyle(Theme.textSecondary) .monospacedDigit() - .contentTransition(.numericText()) } - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Theme.surface) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Theme.input, in: Capsule()) + .help("Capture state · 127.0.0.1:\(state.port)") } - private var captureLabel: String { - if state.isWorking { return "working…" } - if state.isCapturing { return "recording · 127.0.0.1:\(state.port)" } - return "idle · 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 struct StatusDot: View { - let color: Color - var body: some View { - Circle() - .fill(color) - .frame(width: 7, height: 7) + private var label: String { + if state.isWorking { return "working…" } + if state.isCapturing { return "recording · \(state.port)" } + return "idle · \(state.port)" } } -private struct Dot: View { +private struct CATrustChip: View { + @Environment(AppState.self) private var state + var body: some View { - Circle() - .fill(Theme.textTertiary.opacity(0.5)) - .frame(width: 3, height: 3) + 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") } } From 9abdb13f1e4834f2b144f94da4a3ede6a36fbd39 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:23:07 +0200 Subject: [PATCH 17/45] feat(agent): stream assistant replies incrementally instead of bulk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the agent panel only showed the assistant's reply once the turn finished — long answers landed all at once. Wire up include_partial_messages in the Claude Agent SDK so the backend receives StreamEvent objects (raw Anthropic API stream events) and forwards the text_delta chunks as a new assistant_text_chunk wire event. The macOS side folds chunks into the active assistantText event in-place, so the UI sees the response appear character group by character group as it generates. Backend (rae_agent): - session.py: opts in to include_partial_messages, handles StreamEvent in _translate, emits assistant_text_chunk for each text_delta. Skips TextBlock emission on AssistantMessage so the text doesn't end up duplicated by the final, fully-formed message. - protocol.py: new AgentEvent.assistant_text_chunk factory. macOS (Swift): - AgentProtocol: adds .assistantTextChunk(chatID, eventID, text) and routes the new wire event through AgentEventDecoder. - AgentSession.handle: when a chunk arrives, mutate the trailing .assistantText event with the appended text instead of appending a new event per delta. Falls back to seeding a fresh assistantText when the previous timeline row isn't already an assistant message (e.g. right after a tool result). - On .complete, scan the timeline backwards for the final assistant text and commit it into history so multi-turn conversations remember what was said. - AgentPanel.AgentEventRow renders .assistantTextChunk as EmptyView (chunks are merged before they ever reach the timeline view). Co-Authored-By: Claude Opus 4.7 --- backend/rae_agent/protocol.py | 4 ++ backend/rae_agent/session.py | 21 +++++++++- .../ReverseAPI/Agent/AgentProtocol.swift | 10 +++++ .../ReverseAPI/Agent/AgentSession.swift | 41 +++++++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/rae_agent/protocol.py b/backend/rae_agent/protocol.py index b86c73f..a52a3c4 100644 --- a/backend/rae_agent/protocol.py +++ b/backend/rae_agent/protocol.py @@ -118,6 +118,10 @@ def to_dict(self) -> dict[str, Any]: def assistant_text(cls, chat_id: str, text: str) -> "AgentEvent": return cls(type="assistant_text", payload={"id": chat_id, "text": text}) + @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}) diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index 97205a6..ea263bd 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -13,6 +13,7 @@ AssistantMessage, ClaudeAgentOptions, ResultMessage, + StreamEvent, TextBlock, ToolResultBlock, ToolUseBlock, @@ -75,6 +76,7 @@ async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentE cwd=str(dirs.output_dir), allowed_tools=["Read", "Write", "Edit"], permission_mode="acceptEdits", + include_partial_messages=True, ) pending_writes: dict[str, str] = {} @@ -102,11 +104,26 @@ async def _translate( 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): - if block.text: - yield AgentEvent.assistant_text(chat_id, block.text) + # Text already streamed via StreamEvent chunks above — skip + # to avoid emitting the same body twice. + continue elif isinstance(block, ToolUseBlock): tool_input = dict(block.input or {}) yield AgentEvent.tool_use(chat_id, block.name, tool_input) diff --git a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift index 93a5e9e..5fa92df 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift @@ -76,7 +76,9 @@ struct AgentChatRequest: Encodable { } enum AgentEvent: Sendable, Identifiable { + 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) @@ -85,7 +87,9 @@ enum AgentEvent: Sendable, Identifiable { 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 @@ -110,6 +114,12 @@ enum AgentEventDecoder { 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] ?? [:] diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift index 5bf0594..ef86aca 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSession.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -72,6 +72,10 @@ final class AgentSession { history: previousHistory ) history.append(userHistoryItem) + // Surface the user's prompt in the timeline immediately so they see + // their own message right next to the assistant's reply, before the + // agent has had a chance to respond. + events.append(.userText(eventID: UUID(), text: trimmed)) do { input = "" status = .streaming @@ -119,19 +123,50 @@ final class AgentSession { } private func handle(_ event: AgentEvent) { - events.append(event) switch event { + case .assistantTextChunk(let chatID, _, let chunk): + // Stream the chunk into the active assistant message instead of + // creating a new event per delta. If the previous event isn't an + // assistantText, start a fresh one with this chunk as the seed. + 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 .toolUse, .toolResult, .fileWritten: - break + case .userText, .toolUse, .toolResult, .fileWritten: + events.append(event) + } + } + + /// When the assistant streams its reply via chunks we only know the full + /// text once the turn completes. Walk the timeline backwards to find the + /// last assistantText and commit it into history (unless it's already the + /// latest entry, which is the legacy non-streaming path). + private func recordStreamedAssistantTextIntoHistory() { + for event in events.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 + } } } } From 7c5727d8891f97f59c245a252bc26941a7c6ccf4 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:23:18 +0200 Subject: [PATCH 18/45] feat(macos): show user messages in the agent timeline and hide noisy completion pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two quality-of-life fixes for the agent chat: - The user's own prompt was tracked only in the history list sent to the model, never displayed. Add UserMessageRow — a right-aligned Theme.elevated bubble — and inject a .userText event into the timeline at send time so the conversation looks like a chat, not a monologue. - CompleteRow used to fire after every turn, even on plain Q&A, showing "Finished · 0 files" which had no meaning since the model hadn't written anything. Render the completion pill only when at least one file was actually generated; otherwise drop it entirely via EmptyView so the timeline ends on the assistant's reply. - Tightened CompleteRow copy to "Wrote N files" since the workdir hash is already exposed via the Generated Files section below it. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 41 ++++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index a7dd184..bfc51de 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -183,22 +183,53 @@ private struct AgentEventRow: View { var body: some View { switch event { + case .userText(_, let text): + UserMessageRow(text: text) case .assistantText(_, _, let text): AssistantRow(text: text) + case .assistantTextChunk: + // Chunks are folded into the active assistantText event by + // AgentSession; we never render them as standalone rows. + 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 workdir, let files): - CompleteRow(workdir: workdir, fileCount: files.count) + 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 @@ -325,7 +356,6 @@ private struct FileWrittenRow: View { } private struct CompleteRow: View { - let workdir: String let fileCount: Int var body: some View { @@ -333,12 +363,9 @@ private struct CompleteRow: View { Image(systemName: "checkmark.seal.fill") .foregroundStyle(Theme.success) .font(.callout) - Text("Finished · \(fileCount) file\(fileCount == 1 ? "" : "s")") + Text("Wrote \(fileCount) file\(fileCount == 1 ? "" : "s")") .font(.callout) .foregroundStyle(Theme.textPrimary) - Text(URL(fileURLWithPath: workdir).lastPathComponent) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Theme.textTertiary) Spacer() } } From a9784709da3b10e646fd13fbe0ec20be83d30f6e Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:24:53 +0200 Subject: [PATCH 19/45] fix(agent): import StreamEvent from claude_agent_sdk.types submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude_agent_sdk 0.1.48 defines StreamEvent in its `types` module but does not re-export it from the package's public namespace, so the top-level import added in the streaming commit blew up with `ImportError: cannot import name 'StreamEvent' from 'claude_agent_sdk'`, taking the entire sidecar down at launch. Pull StreamEvent from claude_agent_sdk.types directly. If a future SDK release ever drops the type, fall back to a private stub class so `isinstance(message, StreamEvent)` is always callable — we just lose the streaming path and the sidecar still boots and serves non-streaming AssistantMessage events. Co-Authored-By: Claude Opus 4.7 --- backend/rae_agent/session.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index ea263bd..6c8dc8d 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -13,7 +13,6 @@ AssistantMessage, ClaudeAgentOptions, ResultMessage, - StreamEvent, TextBlock, ToolResultBlock, ToolUseBlock, @@ -21,6 +20,18 @@ 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. +try: + from claude_agent_sdk.types import StreamEvent +except ImportError: # pragma: no cover - SDK shape changed + 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.""" + pass + from rae_agent.prompts import SYSTEM_PROMPT, build_user_prompt from rae_agent.protocol import AgentEvent, ChatRequest, sanitize_session_id From ff7a97e74ac417cddf35ecb293cf3003762f0914 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:30:35 +0200 Subject: [PATCH 20/45] refactor(macos): render assistant markdown with swift-markdown-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled block parser in Markdown.swift with a thin wrapper around gonzalezreal/swift-markdown-ui's Markdown view. The new dependency handles the full CommonMark + GFM grammar — tables, task lists, nested code blocks, autolinks, soft/hard breaks, escapes — which our parser silently dropped. Theme.rae customizes the library for the dark agent panel: - Body text uses Theme.textPrimary at 13pt; links pick up Theme.accent with an underline. - Inline code gets a subtle white@8% pill on the monospaced font. - Headings 1-3 are sized down for the side panel (18 / 16 / 14pt semibold) instead of MarkdownUI's defaults that overpower a 380pt column. - Code blocks render against Theme.appBackground inside a rounded Theme.border outline, with the optional language label and a copy button living on a small Theme.elevated header — same look the old custom parser had, but now driven by the library. - Blockquotes get a 2pt Theme.border bar on the left and muted Theme.textSecondary content. - Paragraph/list spacing tightened with markdownMargin so consecutive blocks read like Claude's web chat rather than a Markdown rendered document. The AgentPanel.AssistantRow callsite is unchanged — it still imports `MarkdownView(text:)` from this file. The public surface stays the same; the implementation just defers to a tested library now. Co-Authored-By: Claude Opus 4.7 --- macos/Package.resolved | 29 +- macos/Package.swift | 2 + macos/Sources/ReverseAPI/UI/Markdown.swift | 365 +++++++-------------- 3 files changed, 146 insertions(+), 250 deletions(-) diff --git a/macos/Package.resolved b/macos/Package.resolved index 5ab46b7..92bb146 100644 --- a/macos/Package.resolved +++ b/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c1b419dd138e629ca0adc46a1f6bc06a7d5cb48df90e4e3914bd85837d0ecb09", + "originHash" : "ed5071e11155cb3e0a72fefe0bc3210d9c4257508d41f0557b76e5310c4c0b42", "pins" : [ { "identity" : "grdb.swift", @@ -10,6 +10,15 @@ "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", @@ -37,6 +46,15 @@ "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", @@ -55,6 +73,15 @@ "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", 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/UI/Markdown.swift b/macos/Sources/ReverseAPI/UI/Markdown.swift index b4d9407..fb1cef7 100644 --- a/macos/Sources/ReverseAPI/UI/Markdown.swift +++ b/macos/Sources/ReverseAPI/UI/Markdown.swift @@ -1,275 +1,142 @@ import SwiftUI +import MarkdownUI -/// Lightweight markdown renderer for assistant output. -/// Supports: fenced code blocks, headers, bullet/ordered lists, paragraphs. -/// Inline syntax (bold/italic/code/links) is handled by AttributedString. +/// Thin wrapper around MarkdownUI's `Markdown` view so the rest of the app +/// keeps its previous import (`MarkdownView(text:)`). Themed against our +/// dark palette so headings, code blocks, lists, links, etc. all match the +/// rest of the agent panel. struct MarkdownView: View { let text: String var body: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - view(for: block) + Markdown(text) + .markdownTheme(.rae) + .markdownTextStyle { + ForegroundColor(Theme.textPrimary) + FontSize(13) } - } - .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } +} - private var blocks: [MarkdownBlock] { - MarkdownParser.parse(text) +private extension Theme { + @MainActor static var monospacedFont: Font { + .system(.callout, design: .monospaced) } +} - @ViewBuilder - private func view(for block: MarkdownBlock) -> some View { - switch block { - case .heading(let level, let raw): - Text(inline(raw)) - .font(headingFont(level: level)) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .padding(.top, level == 1 ? 4 : 2) - - case .paragraph(let raw): - Text(inline(raw)) - .font(.body) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - - case .bullet(let items): - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 8) { - Text("•") - .foregroundStyle(Theme.textSecondary) - Text(inline(item)) - .font(.body) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - case .ordered(let items): - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(items.enumerated()), id: \.offset) { index, item in - HStack(alignment: .top, spacing: 8) { - Text("\(index + 1).") - .foregroundStyle(Theme.textSecondary) - .monospacedDigit() - Text(inline(item)) - .font(.body) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - case .codeBlock(let language, let code): - MarkdownCodeBlock(language: language, code: code) +extension MarkdownUI.Theme { + /// Dark theme tuned for the agent panel: no extra paragraph spacing on + /// top of MarkdownUI's defaults, headings sized down for the side panel, + /// inline code and code blocks render against `Theme.appBackground`, + /// blockquotes get a subtle left bar. + static let rae = MarkdownUI.Theme() + .text { + ForegroundColor(Theme.textPrimary) + FontSize(13) } - } - - private func inline(_ raw: String) -> AttributedString { - if let attr = try? AttributedString(markdown: raw, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { - return attr + .strong { + FontWeight(.semibold) } - return AttributedString(raw) - } - - private func headingFont(level: Int) -> Font { - switch level { - case 1: return .system(.title2, design: .default).weight(.semibold) - case 2: return .system(.title3, design: .default).weight(.semibold) - default: return .system(.headline, design: .default) + .link { + ForegroundColor(Theme.accent) + UnderlineStyle(.single) } - } -} - -private struct MarkdownCodeBlock: View { - let language: String? - let code: String - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if let language, !language.isEmpty { - HStack { - Text(language) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(Theme.textTertiary) - .textCase(.lowercase) - Spacer() - Button { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(code, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption2) - .foregroundStyle(Theme.textTertiary) - } - .buttonStyle(.plain) - .help("Copy") - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Theme.elevated) - } - ScrollView(.horizontal, showsIndicators: false) { - Text(code) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(Theme.appBackground) + .code { + FontFamilyVariant(.monospaced) + FontSize(.em(0.92)) + ForegroundColor(Theme.textPrimary) + BackgroundColor(Color.white.opacity(0.08)) } - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay { - RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) + .heading1 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(18) + ForegroundColor(Theme.textPrimary) + } + .markdownMargin(top: .em(0.8), bottom: .em(0.2)) } - } -} - -// MARK: - Parser - -enum MarkdownBlock: Equatable { - case heading(level: Int, text: String) - case paragraph(String) - case bullet([String]) - case ordered([String]) - case codeBlock(language: String?, code: String) -} - -enum MarkdownParser { - static func parse(_ text: String) -> [MarkdownBlock] { - var blocks: [MarkdownBlock] = [] - var lines = text.components(separatedBy: "\n")[...] - - var paragraphBuffer: [String] = [] - var bulletBuffer: [String] = [] - var orderedBuffer: [String] = [] - - func flushParagraph() { - if !paragraphBuffer.isEmpty { - blocks.append(.paragraph(paragraphBuffer.joined(separator: " "))) - paragraphBuffer.removeAll() - } + .heading2 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(16) + ForegroundColor(Theme.textPrimary) + } + .markdownMargin(top: .em(0.7), bottom: .em(0.2)) } - func flushBullets() { - if !bulletBuffer.isEmpty { - blocks.append(.bullet(bulletBuffer)) - bulletBuffer.removeAll() - } + .heading3 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(14) + ForegroundColor(Theme.textPrimary) + } + .markdownMargin(top: .em(0.6), bottom: .em(0.2)) } - func flushOrdered() { - if !orderedBuffer.isEmpty { - blocks.append(.ordered(orderedBuffer)) - orderedBuffer.removeAll() - } + .paragraph { configuration in + configuration.label + .fixedSize(horizontal: false, vertical: true) + .relativeLineSpacing(.em(0.18)) + .markdownMargin(top: .em(0.3), bottom: .em(0.3)) } - func flushAll() { - flushParagraph() - flushBullets() - flushOrdered() + .listItem { configuration in + configuration.label + .markdownMargin(top: .em(0.1)) } - - while let line = lines.first { - lines = lines.dropFirst() - - // Fenced code block - if let fence = codeFence(line) { - flushAll() - var collected: [String] = [] - while let next = lines.first { - lines = lines.dropFirst() - if codeFence(next) != nil { break } - collected.append(next) + .codeBlock { configuration in + VStack(alignment: .leading, spacing: 0) { + if let language = configuration.language, !language.isEmpty { + HStack { + Text(language) + .font(.caption2.monospaced()) + .foregroundStyle(Theme.textTertiary) + Spacer() + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(configuration.content, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundStyle(Theme.textTertiary) + } + .buttonStyle(.plain) + .help("Copy code") + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Theme.elevated) } - blocks.append(.codeBlock(language: fence, code: collected.joined(separator: "\n"))) - continue - } - - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // Blank line ⇒ flush paragraph - if trimmed.isEmpty { - flushAll() - continue - } - - // Heading - if let (level, rest) = headingMatch(trimmed) { - flushAll() - blocks.append(.heading(level: level, text: rest)) - continue + ScrollView(.horizontal, showsIndicators: false) { + configuration.label + .relativeLineSpacing(.em(0.15)) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(12.5) + ForegroundColor(Theme.textPrimary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.appBackground) } - - // Bullet - if let item = bulletMatch(trimmed) { - flushParagraph() - flushOrdered() - bulletBuffer.append(item) - continue + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) } - - // Ordered - if let item = orderedMatch(trimmed) { - flushParagraph() - flushBullets() - orderedBuffer.append(item) - continue - } - - // Paragraph - flushBullets() - flushOrdered() - paragraphBuffer.append(trimmed) + .markdownMargin(top: .em(0.4), bottom: .em(0.4)) } - - flushAll() - return blocks - } - - private static func codeFence(_ line: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.hasPrefix("```") else { return nil } - let after = trimmed.dropFirst(3) - let lang = after.trimmingCharacters(in: .whitespaces) - return lang.isEmpty ? "" : lang - } - - private static func headingMatch(_ line: String) -> (Int, String)? { - var level = 0 - var index = line.startIndex - while index < line.endIndex, line[index] == "#" { - level += 1 - index = line.index(after: index) - } - guard level > 0, level <= 3 else { return nil } - guard index < line.endIndex, line[index] == " " else { return nil } - let rest = String(line[line.index(after: index)...]) - return (level, rest) - } - - private static func bulletMatch(_ line: String) -> String? { - guard line.hasPrefix("- ") || line.hasPrefix("* ") else { return nil } - return String(line.dropFirst(2)) - } - - private static func orderedMatch(_ line: String) -> String? { - var index = line.startIndex - var hasDigits = false - while index < line.endIndex, line[index].isNumber { - hasDigits = true - index = line.index(after: index) + .blockquote { configuration in + HStack(spacing: 10) { + Rectangle() + .fill(Theme.border) + .frame(width: 2) + configuration.label + .markdownTextStyle { ForegroundColor(Theme.textSecondary) } + } + .markdownMargin(top: .em(0.4), bottom: .em(0.4)) } - guard hasDigits, index < line.endIndex, line[index] == "." else { return nil } - index = line.index(after: index) - guard index < line.endIndex, line[index] == " " else { return nil } - return String(line[line.index(after: index)...]) - } } From 2fef6ecb531a30333a792d500caf055b8c1c3a52 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:30:46 +0200 Subject: [PATCH 21/45] feat(macos): support deleting captured flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FlowStore.delete(ids:) — single source of truth that removes the records from both the in-memory list and the on-disk SQLite table in one shot, then bumps the generation counter so any in-flight persistence task for one of the deleted ids is skipped. AppState.deleteFlows wraps it and also: - clears state.selectedFlowID if the deleted set included the row currently in the inspector (otherwise the inspector kept pointing at a dangling UUID) - prunes the agent selection (next commit) so the agent panel can't try to send rows the user just dropped The UI side comes in a follow-up commit — this one keeps the model plumbing focused. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/App/AppState.swift | 12 +++++++++ .../ReverseAPI/Storage/FlowStore.swift | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index 76719a4..3be0140 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -20,9 +20,21 @@ 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 = [] var filter = TrafficFilter() var captureMode: CaptureMode = .device + 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 diff --git a/macos/Sources/ReverseAPI/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift index 5fe579e..66bf3af 100644 --- a/macos/Sources/ReverseAPI/Storage/FlowStore.swift +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -55,6 +55,32 @@ 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. + 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)") + } + 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): From da735911af9865690cf133ebd2a7e3a682cd253d Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:31:00 +0200 Subject: [PATCH 22/45] feat(macos): pick which flows go to the agent + delete from row context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending every captured flow to the agent is too coarse — running the proxy on the whole machine collects far more traffic than belongs in a single reverse-engineering task. Give the user explicit control: Traffic list: - New checkbox column on the leading edge of every row. Tapping the square toggles the flow's UUID in state.agentSelection — selected rows are marked with a Theme.accent filled checkmark, deselected ones with a Theme.textTertiary outline. - Header checkbox does select-all / deselect-all over the currently visible rows, with a tri-state minus glyph when only some of the visible rows are selected. - Context menu per row: "Add to agent selection" / "Remove from agent selection", a divider, and a destructive "Delete" that routes through AppState.deleteFlows. AgentPanel: - AgentPanel.flowsToSend prefers state.agentSelection when it's non-empty; otherwise falls back to the existing state.store.flows.filter { state.filter.matches } .prefix(100) behavior so a fresh session still has context. - AgentHeader subtitle now reads "Idle · 12 selected" or "Ready · 47 filtered" so the user can see at a glance which set of flows will be shared when they hit Send. The bottom-status footer was already gone, so there's no other place flow counts live — this is now the canonical "what does the agent see" indicator. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 44 ++++++--- .../ReverseAPI/UI/TrafficListView.swift | 98 ++++++++++++++++++- 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index bfc51de..1f13aaa 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -8,7 +8,13 @@ struct AgentPanel: View { var body: some View { @Bindable var agent = state.agent VStack(spacing: 0) { - AgentHeader(target: $agent.target, status: agent.status, onClear: { agent.clear() }) + AgentHeader( + target: $agent.target, + status: agent.status, + flowCount: flowsToSend.count, + isExplicitSelection: !state.agentSelection.isEmpty, + onClear: { agent.clear() } + ) AgentTimeline( events: agent.events, flowCount: flowsToSend.count, @@ -23,7 +29,13 @@ struct AgentPanel: View { } private var flowsToSend: [CapturedFlow] { - Array(state.store.flows.filter { state.filter.matches($0) }.prefix(100)) + // If the user has explicitly checked rows, share those; otherwise + // fall back to the filtered view so a fresh session still has + // something to chew on. + 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() { @@ -37,6 +49,8 @@ struct AgentPanel: View { private struct AgentHeader: View { @Binding var target: AgentTargetLanguage let status: AgentSession.Status + let flowCount: Int + let isExplicitSelection: Bool let onClear: () -> Void var body: some View { @@ -48,7 +62,7 @@ private struct AgentHeader: View { Text("Agent") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.textPrimary) - Text(statusLabel) + Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Theme.textTertiary) } @@ -67,6 +81,21 @@ private struct AgentHeader: View { .padding(.vertical, 10) } + private var subtitle: String { + let suffix: String + switch status { + case .idle: suffix = "Idle" + case .launching: suffix = "Starting…" + case .ready: suffix = "Ready" + case .streaming: suffix = "Thinking…" + case .failed: suffix = "Error" + } + let source = isExplicitSelection + ? "\(flowCount) selected" + : "\(flowCount) filtered" + return "\(suffix) · \(source)" + } + private var statusColor: Color { switch status { case .idle: return Theme.textTertiary @@ -77,15 +106,6 @@ private struct AgentHeader: View { } } - private var statusLabel: String { - switch status { - case .idle: return "Idle" - case .launching: return "Starting…" - case .ready: return "Ready" - case .streaming: return "Thinking…" - case .failed: return "Error" - } - } } private struct LanguageMenu: View { diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index cac3586..f44ecc0 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -10,7 +10,7 @@ struct TrafficListView: View { Theme.appBackground.ignoresSafeArea() VStack(spacing: 0) { - TrafficHeaderRow() + TrafficHeaderRow(visibleIDs: filteredFlows.map(\.id)) ThinDivider() if state.store.flows.isEmpty { EmptyTrafficState() @@ -31,8 +31,13 @@ struct TrafficListView: View { // MARK: - Header private struct TrafficHeaderRow: View { + @Environment(AppState.self) private var state + let visibleIDs: [UUID] + var body: some View { HStack(spacing: 0) { + SelectAllCheckbox(visibleIDs: visibleIDs) + .frame(width: 28, alignment: .center) HeaderLabel("Time", width: 72, align: .leading) HeaderLabel("Method", width: 64, align: .leading) HeaderLabel("Host", width: 200, align: .leading) @@ -45,6 +50,52 @@ private struct TrafficHeaderRow: View { } } +private struct SelectAllCheckbox: View { + @Environment(AppState.self) private var state + let visibleIDs: [UUID] + + var body: some View { + Button { + toggle() + } label: { + Image(systemName: glyph) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .help(allSelected + ? "Clear agent selection" + : "Select all visible rows for the agent") + } + + private var allSelected: Bool { + !visibleIDs.isEmpty && visibleIDs.allSatisfy { state.agentSelection.contains($0) } + } + + private var someSelected: Bool { + visibleIDs.contains(where: { state.agentSelection.contains($0) }) + } + + private var glyph: String { + if allSelected { return "checkmark.square.fill" } + if someSelected { return "minus.square.fill" } + return "square" + } + + private var tint: Color { + (allSelected || someSelected) ? Theme.accent : Theme.textTertiary + } + + private func toggle() { + if allSelected { + visibleIDs.forEach { state.agentSelection.remove($0) } + } else { + visibleIDs.forEach { state.agentSelection.insert($0) } + } + } +} + private struct HeaderLabel: View { let text: String let width: CGFloat? @@ -98,15 +149,29 @@ private struct TrafficRowList: View { } private struct TrafficRow: View { + @Environment(AppState.self) private var state let flow: CapturedFlow let isSelected: Bool let onSelect: () -> Void @State private var isHovering = false + private var isCheckedForAgent: Bool { + state.agentSelection.contains(flow.id) + } + var body: some View { Button(action: onSelect) { HStack(spacing: 0) { + AgentCheckbox(isOn: isCheckedForAgent) { + if state.agentSelection.contains(flow.id) { + state.agentSelection.remove(flow.id) + } else { + state.agentSelection.insert(flow.id) + } + } + .frame(width: 28, alignment: .center) + Text(flow.startedAt, format: .dateTime.hour().minute().second()) .font(.system(.caption, design: .monospaced)) .foregroundStyle(Theme.textTertiary) @@ -142,6 +207,21 @@ private struct TrafficRow: View { .onHover { hovering in isHovering = hovering } + .contextMenu { + if isCheckedForAgent { + Button("Remove from agent selection") { + state.agentSelection.remove(flow.id) + } + } else { + Button("Add to agent selection") { + state.agentSelection.insert(flow.id) + } + } + Divider() + Button("Delete", role: .destructive) { + state.deleteFlows([flow.id]) + } + } } private var background: Color { @@ -151,6 +231,22 @@ private struct TrafficRow: View { } } +private struct AgentCheckbox: View { + let isOn: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: isOn ? "checkmark.square.fill" : "square") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(isOn ? Theme.accent : Theme.textTertiary) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .help(isOn ? "Remove from agent selection" : "Add to agent selection") + } +} + private struct MethodBadge: View { let method: String From 8c200303567c1335512c154784a1a9fe7d421e1d Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:36:10 +0200 Subject: [PATCH 23/45] fix(agent): run sidecar with bypassPermissions so reads don't prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent kept stalling on prompts like "Claude requested permissions to read from /flows/flows.json, but you haven't granted it yet." because acceptEdits only auto-approves Write/Edit — Read still surfaces a permission request, including for the session's own flows.json (which lives in the sibling flows/ directory of the cwd). Switch the sidecar's permission_mode to bypassPermissions, matching how reverse_api/collector.py and reverse_api/auto_engineer.py configure their long-running agent runs. The blast radius is still constrained: the sidecar process runs with cwd pinned to a per-chat session output directory and only requests Read/Write/Edit on allowed_tools, so this doesn't open up broader-than-intended filesystem access — it just stops asking for paths inside the session it already owns. Co-Authored-By: Claude Opus 4.7 --- backend/rae_agent/session.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index 6c8dc8d..c03586b 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -86,7 +86,14 @@ async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentE system_prompt=SYSTEM_PROMPT, cwd=str(dirs.output_dir), allowed_tools=["Read", "Write", "Edit"], - permission_mode="acceptEdits", + # `acceptEdits` still prompts for Read on paths outside the cwd — + # including the session's own flows.json, which lives in the + # sibling flows/ directory. Use `bypassPermissions` (same setting + # the reverse_api collector + auto_engineer use) so the agent can + # operate end-to-end without a permission dialog. The sidecar is + # already isolated to per-chat session directories, so there's no + # broader-than-intended access enabled here. + permission_mode="bypassPermissions", include_partial_messages=True, ) From 4b3f96e931d4693098ba4cc2e58cf1ac8a41b744 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:36:26 +0200 Subject: [PATCH 24/45] feat(macos): refine tool call / result rows in the agent timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tool rows were two collapsible disclosure rows with generic icons — fine but noisy and offered no preview of what the agent was actually doing. ToolUseRow: - Rounded Theme.elevated card with a 1pt Theme.border so each tool call reads as a discrete event, not a flat list of toggles. - Semantic icon per tool name: doc.text for Read, square.and.pencil for Write, pencil for Edit, terminal for Bash, magnifyingglass for Glob/Grep. Falls back to wrench.adjustable for anything else. - Inline summary parses the tool input JSON and pulls the most meaningful argument (file_path / path / command / pattern / url / query) so the row reads "Read · flows.json", "Write · client.py", "Bash · npm install" before expansion. - Chevron is the same affordance everywhere and animates from 0° to 90° on expand via rotationEffect. - Expanded body sits below an inline Divider so the JSON args feel attached to the call, not a popover. ToolResultRow: - Renders flush against the preceding tool call (24pt leading indent so it sits under the call's content, no surrounding box). - Headline is the first non-empty line of output trimmed to 80 chars — quick "what came back" preview that matches the call's inline summary on the row above. Errors render in Theme.danger. - Same rotating chevron for the expand affordance. Net effect: a Read+result pair now reads more like a single git-style log entry than two unrelated rows. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 200 +++++++++++++------ 1 file changed, 139 insertions(+), 61 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 1f13aaa..91d5b1e 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -264,42 +264,85 @@ private struct ToolUseRow: View { @State private var isExpanded: Bool = false var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { Button { - if !inputJSON.isEmpty { - isExpanded.toggle() - } + if !inputJSON.isEmpty { isExpanded.toggle() } } label: { - HStack(spacing: 6) { - Image(systemName: "wrench.adjustable") - .font(.caption) - .foregroundStyle(Theme.textTertiary) - Text(name) - .font(.system(.caption, design: .monospaced).weight(.medium)) + 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: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 8, weight: .semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Theme.textTertiary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) } - Spacer() } + .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(8) + .padding(10) .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.appBackground, in: RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6).stroke(Theme.border, lineWidth: 1) - } } } + .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 } } @@ -309,69 +352,90 @@ private struct ToolResultRow: View { @State private var isExpanded: Bool = false var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { Button { isExpanded.toggle() } label: { - HStack(spacing: 6) { - Image(systemName: isError ? "xmark.circle" : "checkmark.circle") - .font(.caption) - .foregroundStyle(isError ? Theme.danger : Theme.textTertiary) - Text(isError ? "result · error" : "result") + 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) - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 8, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Theme.textTertiary) - Spacer() + .rotationEffect(.degrees(isExpanded ? 90 : 0)) } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) if isExpanded { - Text(output) + Divider().overlay(Theme.border) + Text(output.isEmpty ? "(empty)" : output) .font(.system(.caption, design: .monospaced)) .foregroundStyle(isError ? Theme.danger : Theme.textSecondary) .textSelection(.enabled) - .padding(8) + .padding(10) .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.appBackground, in: RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6).stroke(Theme.border, lineWidth: 1) - } } } + .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 { - 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() - Button { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) - } label: { - Image(systemName: "arrow.up.right.square") + Button { + state.viewFile(at: path) + } label: { + HStack(spacing: 8) { + Image(systemName: "doc.text.fill") + .foregroundStyle(Theme.success) .font(.caption) - .foregroundStyle(Theme.textSecondary) + 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) } - .buttonStyle(.plain) - .help("Reveal in Finder") + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) } - .padding(.horizontal, 10) - .padding(.vertical, 7) + .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)]) + } + } } } @@ -434,6 +498,7 @@ private struct ThinkingRow: View { } private struct GeneratedFilesRow: View { + @Environment(AppState.self) private var state let workdir: String let files: [String] @@ -456,19 +521,32 @@ private struct GeneratedFilesRow: View { } VStack(alignment: .leading, spacing: 4) { ForEach(files, id: \.self) { file in - HStack(spacing: 8) { - Image(systemName: "doc") - .foregroundStyle(Theme.textTertiary) - .font(.caption) - Text(file) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - Spacer() + 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()) } - .padding(.horizontal, 10) - .padding(.vertical, 6) + .buttonStyle(.plain) .background(Theme.elevated, in: RoundedRectangle(cornerRadius: 6)) + .help("View file contents") } } } From 978696c5e838499def14fc780e3fa1535c01bb6a Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:36:45 +0200 Subject: [PATCH 25/45] feat(macos): tap an agent-written file to open it in a viewer sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a built-in file viewer so the user doesn't have to reveal in Finder + open the file in a separate editor just to confirm what the agent produced. Tap a row in either FileWrittenRow (the inline "Wrote foo.swift" pill) or GeneratedFilesRow and a sheet slides up with the file contents. AgentFileViewer (new): - Rounded 14pt continuous-corner card on Theme.surface with a Theme.border outline, sized 720-880pt wide × 480-640pt tall so it's roomy without dominating the window. - Header shows a tool-style icon picked from the file extension (curlybraces for source code, doc.richtext for markdown, terminal for shell, etc.), the filename in monospaced semibold, and a metadata line ("Python · 3.4 KB" / "JSON · 821 bytes") parsed from the file attributes. Right-aligned actions: Reveal in Finder, Copy contents, Close. - Body is a horizontally-and-vertically scrollable monospaced rendering against Theme.appBackground with a Theme.surface line gutter — the visual cue lands somewhere between a GitHub blob preview and a side-by-side diff pane, which matches the user's ask for "un truc un peu comme un git diff." - File loads off-main on a detached task; binary files fall back to a "" sentinel instead of crashing. AppState: - New `viewingFile: AgentFileRef?` slot (Identifiable URL wrapper for SwiftUI's .sheet(item:) API) and `viewFile(at:)` helper that the agent panel rows call. ContentView: - .sheet(item:) on the root binds to state.viewingFile, presenting AgentFileViewer when set and clearing the slot on dismiss. AgentPanel: - FileWrittenRow + GeneratedFilesRow become Button wrappers so the whole row is a tap target. Trailing chevron + cursor-pointer affordance hints at the disclosure; "Reveal in Finder" moves to the context menu on FileWrittenRow so the primary action stays "view inline." Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/App/AppState.swift | 13 + macos/Sources/ReverseAPI/UI/ContentView.swift | 9 + macos/Sources/ReverseAPI/UI/FileViewer.swift | 237 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 macos/Sources/ReverseAPI/UI/FileViewer.swift diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index 3be0140..3a1b37f 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -3,6 +3,13 @@ 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 final class AppState { @@ -23,9 +30,15 @@ final class AppState { /// 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) { diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 9269d74..e24105b 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -55,6 +55,15 @@ struct ContentView: View { } } .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(Theme.appBackground) .preferredColorScheme(.dark) 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) + } + } +} From 23c0e975b6c7800e9381b3a2780e69e094d703f0 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:42:18 +0200 Subject: [PATCH 26/45] feat(macos): native syntax highlighter for assistant code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RaeSyntaxHighlighter — a CodeSyntaxHighlighter implementation that ships with the app instead of pulling in a JS bundle or a TextMate-grammar dependency. Pure Swift, regex-based, tuned for the languages the agent realistically emits when reverse-engineering an API: Swift, Python, JavaScript / TypeScript / JSX / TSX, JSON, Shell (bash/zsh), HTML, CSS, and SQL. How it works: - SyntaxColorizer.colorize walks the source once per rule, in priority order (comments → strings → numbers → keywords → builtins → JSON property keys) and writes foregroundColor onto an AttributedString. A rule never overwrites a range that's already non-default-color, which prevents bugs like coloring the word "if" inside a string literal. - Per-language rule sets pick the right comment markers (`//`, `#`, ``) and string delimiters (triple-quoted for Python, backticks for JS template strings, etc.). - Palette inspired by VS Code Dark+: purple-pink keywords, warm orange strings, soft-green numbers, muted-green comments, teal types, yellow functions, light-blue JSON keys. Tuned against Theme.appBackground (#050506) so contrast is comfortable on the near-black canvas. - Exposes itself as `.rae` via a `CodeSyntaxHighlighter` extension so MarkdownUI consumers can write `.markdownCodeSyntaxHighlighter(.rae)`. Fallback language `.generic` still picks up strings + numbers so even an unknown fence has some visual structure. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/UI/SyntaxHighlighter.swift | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 macos/Sources/ReverseAPI/UI/SyntaxHighlighter.swift diff --git a/macos/Sources/ReverseAPI/UI/SyntaxHighlighter.swift b/macos/Sources/ReverseAPI/UI/SyntaxHighlighter.swift new file mode 100644 index 0000000..a4d8c23 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/SyntaxHighlighter.swift @@ -0,0 +1,302 @@ +import SwiftUI +import MarkdownUI + +/// Lightweight, pure-Swift code-block syntax highlighter for the most common +/// languages we expect to see in agent answers: Swift, Python, JS/TS, JSON, +/// Shell, and reasonable fallbacks (HTML, CSS, SQL). +/// +/// Conforms to MarkdownUI's `CodeSyntaxHighlighter` so it plugs straight into +/// `.markdownCodeSyntaxHighlighter(...)`. Tokens are detected with regular +/// expressions and styled via `AttributedString` foreground colors — no JS +/// engine, no WebKit, no transitive dependency. +struct RaeSyntaxHighlighter: CodeSyntaxHighlighter { + func highlightCode(_ code: String, language: String?) -> Text { + let language = (language ?? "").lowercased() + let attributed = SyntaxColorizer.colorize(code, language: language) + return Text(attributed) + } +} + +extension CodeSyntaxHighlighter where Self == RaeSyntaxHighlighter { + static var rae: RaeSyntaxHighlighter { RaeSyntaxHighlighter() } +} + +// MARK: - Palette + +enum SyntaxPalette { + // Colors picked to read well against `Theme.appBackground` (#050506). + // Match a VS Code Dark+ vibe so they look familiar. + static let keyword = Color(red: 0.78, green: 0.52, blue: 0.78) // #C586C0 — purple-pink + static let string = Color(red: 0.87, green: 0.66, blue: 0.42) // #DEA86B — warm orange + static let number = Color(red: 0.71, green: 0.81, blue: 0.66) // #B5CEA8 — soft green + static let comment = Color(red: 0.40, green: 0.55, blue: 0.40) // #67925E — muted green + static let type = Color(red: 0.31, green: 0.78, blue: 0.69) // #4FC8B0 — teal + static let function = Color(red: 0.86, green: 0.86, blue: 0.67) // #DCDCAA — yellow + static let punctuation = Color(red: 0.71, green: 0.73, blue: 0.77) // #B5BAC4 — light gray + static let propertyKey = Color(red: 0.61, green: 0.79, blue: 1.00) // #9CCAFF — light blue +} + +// MARK: - Colorizer + +enum SyntaxColorizer { + static func colorize(_ source: String, language: String) -> AttributedString { + var attributed = AttributedString(source) + attributed.foregroundColor = Theme.textPrimary + + let lang = canonicalLanguage(language) + let rules = SyntaxRules.forLanguage(lang) + + // Apply rules in priority order: comments → strings → numbers → + // keywords → builtins → property keys. Later rules don't overwrite + // ranges already colored by earlier ones, which is how we avoid + // tinting keywords inside strings or comments. + for rule in rules { + applyRule(rule, on: &attributed, source: source) + } + return attributed + } + + private static func canonicalLanguage(_ raw: String) -> Language { + switch raw { + case "swift": return .swift + case "py", "python", "python3": return .python + case "js", "javascript", "jsx", "mjs": return .javascript + case "ts", "typescript", "tsx": return .typescript + case "json": return .json + case "sh", "bash", "zsh", "shell": return .shell + case "html", "htm", "xml": return .html + case "css", "scss": return .css + case "sql": return .sql + default: return .generic + } + } + + private static func applyRule(_ rule: SyntaxRule, on attributed: inout AttributedString, source: String) { + let nsSource = source as NSString + let range = NSRange(location: 0, length: nsSource.length) + let matches = rule.regex.matches(in: source, options: [], range: range) + + for match in matches { + let group = rule.captureGroup ?? 0 + guard group < match.numberOfRanges else { continue } + let r = match.range(at: group) + guard r.location != NSNotFound, r.length > 0, + let attrRange = Range(r, in: source) + .flatMap({ Range($0, in: attributed) }) + else { continue } + // Skip if the range already got a non-primary color from an + // earlier (higher-priority) rule. + if attributed[attrRange].foregroundColor != Theme.textPrimary { + continue + } + attributed[attrRange].foregroundColor = rule.color + } + } +} + +private enum Language { + case swift, python, javascript, typescript, json, shell, html, css, sql, generic +} + +private struct SyntaxRule { + let regex: NSRegularExpression + let color: Color + /// Optional capture group whose range gets colored. `nil` colors the full + /// match. Used so keyword rules with leading word boundaries don't paint + /// the boundary character itself. + var captureGroup: Int? +} + +// MARK: - Rule sets + +private enum SyntaxRules { + static func forLanguage(_ language: Language) -> [SyntaxRule] { + var rules: [SyntaxRule] = [] + + rules.append(contentsOf: commentRules(for: language)) + rules.append(contentsOf: stringRules(for: language)) + rules.append(contentsOf: numberRules()) + rules.append(contentsOf: keywordRules(for: language)) + rules.append(contentsOf: builtinRules(for: language)) + if language == .json { + rules.append(contentsOf: jsonPropertyKeyRules()) + } + return rules + } + + // MARK: comments + private static func commentRules(for language: Language) -> [SyntaxRule] { + var patterns: [String] = [] + switch language { + case .swift, .javascript, .typescript, .css, .sql: + patterns = [#"//[^\n]*"#, #"/\*[\s\S]*?\*/"#] + case .python, .shell: + patterns = [#"#[^\n]*"#] + case .html: + patterns = [#""#] + case .json, .generic: + patterns = [] + } + return patterns.compactMap { try? rule($0, color: SyntaxPalette.comment) } + } + + // MARK: strings + private static func stringRules(for language: Language) -> [SyntaxRule] { + var patterns: [String] + switch language { + case .python: + patterns = [ + #""""[\s\S]*?""""#, + #"'''[\s\S]*?'''"#, + #"f?"(?:\\.|[^"\\\n])*""#, + #"f?'(?:\\.|[^'\\\n])*'"#, + ] + case .swift, .javascript, .typescript, .json, .css, .sql: + patterns = [ + #""(?:\\.|[^"\\\n])*""#, + #"'(?:\\.|[^'\\\n])*'"#, + ] + if language == .javascript || language == .typescript || language == .swift { + patterns.append(#"`(?:\\.|[^`\\])*`"#) + } + case .shell: + patterns = [ + #""(?:\\.|[^"\\])*""#, + #"'(?:[^'\\])*'"#, + ] + case .html: + patterns = [ + #""(?:\\.|[^"\\])*""#, + #"'(?:\\.|[^'\\])*'"#, + ] + case .generic: + patterns = [ + #""(?:\\.|[^"\\\n])*""#, + #"'(?:\\.|[^'\\\n])*'"#, + ] + } + return patterns.compactMap { try? rule($0, color: SyntaxPalette.string) } + } + + // MARK: numbers + private static func numberRules() -> [SyntaxRule] { + let patterns = [ + #"\b0x[0-9A-Fa-f]+\b"#, + #"\b\d+\.\d+(?:[eE][+-]?\d+)?\b"#, + #"\b\d+\b"#, + ] + return patterns.compactMap { try? rule($0, color: SyntaxPalette.number) } + } + + // MARK: keywords + private static func keywordRules(for language: Language) -> [SyntaxRule] { + let words = keywords(for: language) + guard !words.isEmpty else { return [] } + let pattern = "\\b(?:" + words.joined(separator: "|") + ")\\b" + return [try? rule(pattern, color: SyntaxPalette.keyword)].compactMap { $0 } + } + + private static func keywords(for language: Language) -> [String] { + switch language { + case .swift: + return [ + "associatedtype", "class", "deinit", "enum", "extension", "fileprivate", + "func", "import", "init", "inout", "internal", "let", "open", "operator", + "private", "protocol", "public", "rethrows", "static", "struct", "subscript", + "typealias", "var", "break", "case", "continue", "default", "defer", "do", + "else", "fallthrough", "for", "guard", "if", "in", "repeat", "return", + "switch", "where", "while", "as", "catch", "is", "nil", "self", "Self", + "super", "throw", "throws", "true", "false", "try", "async", "await", + "actor", "any", "some", "@MainActor", "@Observable", + ] + case .python: + return [ + "False", "None", "True", "and", "as", "assert", "async", "await", + "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", + "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", + "with", "yield", "match", "case", + ] + case .javascript, .typescript: + var base = [ + "var", "let", "const", "function", "return", "if", "else", "for", "while", + "do", "switch", "case", "default", "break", "continue", "class", "extends", + "new", "delete", "typeof", "instanceof", "in", "of", "this", "super", + "import", "export", "from", "as", "async", "await", "try", "catch", + "finally", "throw", "yield", "void", "null", "undefined", "true", "false", + "static", "get", "set", + ] + if language == .typescript { + base.append(contentsOf: [ + "type", "interface", "enum", "implements", "readonly", "public", + "private", "protected", "abstract", "declare", "namespace", "satisfies", + "keyof", "infer", "is", + ]) + } + return base + case .json: + return ["true", "false", "null"] + case .shell: + return [ + "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", + "case", "esac", "function", "return", "exit", "in", "local", "export", + "source", "set", "unset", "alias", "readonly", "declare", + ] + case .sql: + return [ + "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "INSERT", "INTO", "VALUES", + "UPDATE", "SET", "DELETE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", + "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", "AS", "ON", "NULL", + "CREATE", "TABLE", "DROP", "ALTER", "INDEX", "PRIMARY", "KEY", "FOREIGN", + ] + case .css: + return [] + case .html: + return [] + case .generic: + return [] + } + } + + // MARK: builtins / types + private static func builtinRules(for language: Language) -> [SyntaxRule] { + switch language { + case .swift: + return [ + try? rule(#"\b(?:String|Int|Double|Float|Bool|Array|Dictionary|Set|Optional|Void|Never|Result|URL|Data|Date|UUID|Color|View|Text|Image|VStack|HStack|ZStack|Button|TextField|List|ScrollView|NavigationStack|EnvironmentValues)\b"#, color: SyntaxPalette.type), + ].compactMap { $0 } + case .python: + return [ + try? rule(#"\b(?:print|len|range|str|int|float|bool|list|dict|set|tuple|type|isinstance|getattr|setattr|hasattr|open|input|enumerate|zip|map|filter|sorted|reversed|sum|min|max|abs|round|self|cls|Exception|ValueError|TypeError|KeyError|IndexError|AttributeError)\b"#, color: SyntaxPalette.type), + ].compactMap { $0 } + case .javascript, .typescript: + return [ + try? rule(#"\b(?:console|window|document|process|module|require|globalThis|JSON|Math|Date|Promise|Array|Object|String|Number|Boolean|Map|Set|WeakMap|WeakSet|Symbol|Error|TypeError|RangeError|Proxy|Reflect)\b"#, color: SyntaxPalette.type), + ].compactMap { $0 } + case .shell: + return [ + try? rule(#"\b(?:echo|cd|ls|grep|sed|awk|cat|head|tail|find|xargs|cp|mv|rm|mkdir|chmod|chown|curl|wget|git|npm|pip|python|node|brew|sudo|env|export|which)\b"#, color: SyntaxPalette.function), + ].compactMap { $0 } + default: + return [] + } + } + + // MARK: JSON property keys (the "key" before ":") + private static func jsonPropertyKeyRules() -> [SyntaxRule] { + // Match "key" before a colon. We've already colored strings as + // string-color; this rule re-paints keys via a higher-priority + // detection. Use capture group 1 to skip the trailing quote+colon. + let pattern = #""((?:[^"\\]|\\.)*)"\s*:"# + if let r = try? NSRegularExpression(pattern: pattern) { + return [SyntaxRule(regex: r, color: SyntaxPalette.propertyKey, captureGroup: 1)] + } + return [] + } + + // MARK: helper + private static func rule(_ pattern: String, color: Color) throws -> SyntaxRule { + let regex = try NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) + return SyntaxRule(regex: regex, color: color) + } +} From 1fb23ddcd62c2f7d6fc41afead019e226f0abf72 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:42:39 +0200 Subject: [PATCH 27/45] =?UTF-8?q?feat(macos):=20full=20markdown=20theme=20?= =?UTF-8?q?=E2=80=94=20tables,=20code=20header,=20lists,=20task=20lists,?= =?UTF-8?q?=20HR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the rae markdown theme so the agent's full GFM output renders properly inside the side panel. Previously only paragraphs / headings / lists / code blocks were styled; tables fell back to MarkdownUI's defaults (which on a dark background look unreadable), code blocks had no copy-state feedback, task lists rendered as ASCII brackets, images / horizontal rules / emphasis / strikethrough were all unstyled. Inline elements: - emphasis renders italic - strikethrough renders single-line through Theme.textTertiary - code uses white@8% backdrop on a monospaced variant — matches the inline-code pill style ChatGPT/Claude.app use Block elements: - heading4 styled (13pt semibold) so deep section nesting still reads as a heading instead of a paragraph - thematicBreak renders a Theme.border Divider with vertical padding instead of MarkdownUI's default Divider - image rendering clips to an 8pt rounded rect so inline screenshots don't blow out the panel edge - blockquote bar promoted to Theme.borderStrong so the left rail is visible Code blocks: - Hook in the syntax highlighter via `.markdownCodeSyntaxHighlighter(.rae)` - Header is a discrete view with `Theme.elevated` background, the language slug in lowercased monospace, and a copy button that swaps to a checkmark + "Copied" for 1.2s on click - Body still scrolls horizontally for long lines Task lists & list markers: - bulletedListMarker = .disc, numberedListMarker = .decimal so nested lists look consistent - taskListMarker renders an SF Symbol filled checkbox in Theme.accent for completed items, hollow square in Theme.textTertiary otherwise Tables (the previously broken bit): - `.table` wraps the content in a rounded Theme.border outline, picks up the `.alternatingRows` backgroundStyle so every other row has a Theme.textPrimary @ 2.5% wash for readability, and uses a 1pt all-borders style in Theme.border - `.tableCell` adds proper 10×6 padding, bumps the first row to semibold Theme.textPrimary (header row), keeps body cells at Theme.textSecondary The MarkdownView wrapper is unchanged externally — AssistantRow still just renders `MarkdownView(text:)`. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/Markdown.swift | 192 +++++++++++++++------ 1 file changed, 141 insertions(+), 51 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/Markdown.swift b/macos/Sources/ReverseAPI/UI/Markdown.swift index fb1cef7..3a926ec 100644 --- a/macos/Sources/ReverseAPI/UI/Markdown.swift +++ b/macos/Sources/ReverseAPI/UI/Markdown.swift @@ -1,37 +1,27 @@ import SwiftUI import MarkdownUI -/// Thin wrapper around MarkdownUI's `Markdown` view so the rest of the app -/// keeps its previous import (`MarkdownView(text:)`). Themed against our -/// dark palette so headings, code blocks, lists, links, etc. all match the -/// rest of the agent panel. +/// Renders an assistant message as full GitHub-Flavored Markdown with our +/// dark theme + syntax-highlighted code blocks. struct MarkdownView: View { let text: String var body: some View { Markdown(text) .markdownTheme(.rae) - .markdownTextStyle { - ForegroundColor(Theme.textPrimary) - FontSize(13) - } + .markdownCodeSyntaxHighlighter(.rae) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } -private extension Theme { - @MainActor static var monospacedFont: Font { - .system(.callout, design: .monospaced) - } -} - extension MarkdownUI.Theme { - /// Dark theme tuned for the agent panel: no extra paragraph spacing on - /// top of MarkdownUI's defaults, headings sized down for the side panel, - /// inline code and code blocks render against `Theme.appBackground`, - /// blockquotes get a subtle left bar. + /// Dark theme tuned for the agent panel: headings sized down for the side + /// panel, code blocks have a language header + copy button + syntax + /// colors, tables render against `Theme.appBackground` with a subtle + /// border, links pick up `Theme.accent`, etc. static let rae = MarkdownUI.Theme() + // ───────────────────────────── inline text .text { ForegroundColor(Theme.textPrimary) FontSize(13) @@ -39,6 +29,13 @@ extension MarkdownUI.Theme { .strong { FontWeight(.semibold) } + .emphasis { + FontStyle(.italic) + } + .strikethrough { + StrikethroughStyle(.single) + ForegroundColor(Theme.textTertiary) + } .link { ForegroundColor(Theme.accent) UnderlineStyle(.single) @@ -49,6 +46,7 @@ extension MarkdownUI.Theme { ForegroundColor(Theme.textPrimary) BackgroundColor(Color.white.opacity(0.08)) } + // ───────────────────────────── headings .heading1 { configuration in configuration.label .markdownTextStyle { @@ -76,6 +74,16 @@ extension MarkdownUI.Theme { } .markdownMargin(top: .em(0.6), bottom: .em(0.2)) } + .heading4 { configuration in + configuration.label + .markdownTextStyle { + FontWeight(.semibold) + FontSize(13) + ForegroundColor(Theme.textPrimary) + } + .markdownMargin(top: .em(0.5), bottom: .em(0.2)) + } + // ───────────────────────────── block elements .paragraph { configuration in configuration.label .fixedSize(horizontal: false, vertical: true) @@ -86,39 +94,47 @@ extension MarkdownUI.Theme { configuration.label .markdownMargin(top: .em(0.1)) } + .bulletedListMarker(.disc) + .numberedListMarker(.decimal) + .taskListMarker { configuration in + Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(configuration.isCompleted ? Theme.accent : Theme.textTertiary) + .imageScale(.small) + } + .thematicBreak { + Divider() + .overlay(Theme.border) + .padding(.vertical, 4) + } + .image { configuration in + configuration.label + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .blockquote { configuration in + HStack(spacing: 10) { + Rectangle() + .fill(Theme.borderStrong) + .frame(width: 2) + configuration.label + .markdownTextStyle { ForegroundColor(Theme.textSecondary) } + } + .markdownMargin(top: .em(0.4), bottom: .em(0.4)) + } + // ───────────────────────────── code blocks .codeBlock { configuration in VStack(alignment: .leading, spacing: 0) { - if let language = configuration.language, !language.isEmpty { - HStack { - Text(language) - .font(.caption2.monospaced()) - .foregroundStyle(Theme.textTertiary) - Spacer() - Button { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(configuration.content, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption2) - .foregroundStyle(Theme.textTertiary) - } - .buttonStyle(.plain) - .help("Copy code") - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Theme.elevated) - } + CodeBlockHeader(language: configuration.language, code: configuration.content) ScrollView(.horizontal, showsIndicators: false) { configuration.label - .relativeLineSpacing(.em(0.15)) + .relativeLineSpacing(.em(0.18)) .markdownTextStyle { FontFamilyVariant(.monospaced) FontSize(12.5) - ForegroundColor(Theme.textPrimary) } - .padding(10) + .padding(.horizontal, 12) + .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) } .background(Theme.appBackground) @@ -129,14 +145,88 @@ extension MarkdownUI.Theme { } .markdownMargin(top: .em(0.4), bottom: .em(0.4)) } - .blockquote { configuration in - HStack(spacing: 10) { - Rectangle() - .fill(Theme.border) - .frame(width: 2) - configuration.label - .markdownTextStyle { ForegroundColor(Theme.textSecondary) } + // ───────────────────────────── tables + .table { configuration in + configuration.label + .markdownTableBorderStyle(.init(color: Theme.border, strokeStyle: .init(lineWidth: 1))) + .markdownTableBackgroundStyle( + .alternatingRows(Theme.appBackground, Color.white.opacity(0.025)) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1) + } + .markdownMargin(top: .em(0.4), bottom: .em(0.4)) + } + .tableCell { configuration in + configuration.label + .markdownTextStyle { + if configuration.row == 0 { + FontWeight(.semibold) + ForegroundColor(Theme.textPrimary) + } else { + ForegroundColor(Theme.textSecondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } +} + +// MARK: - Code block header + +private struct CodeBlockHeader: View { + let language: String? + let code: String + + var body: some View { + HStack(spacing: 8) { + if let language, !language.isEmpty { + Text(language) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .textCase(.lowercase) + } else { + Text("code") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .textCase(.lowercase) } - .markdownMargin(top: .em(0.4), bottom: .em(0.4)) + Spacer() + CopyCodeButton(code: code) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Theme.elevated) + .overlay(alignment: .bottom) { + Rectangle().fill(Theme.border).frame(height: 1) + } + } +} + +private struct CopyCodeButton: View { + let code: String + @State private var didCopy = false + + var body: some View { + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(code, forType: .string) + withAnimation(.easeOut(duration: 0.15)) { didCopy = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation(.easeOut(duration: 0.2)) { didCopy = false } + } + } label: { + HStack(spacing: 4) { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(.system(size: 10, weight: .semibold)) + Text(didCopy ? "Copied" : "Copy") + .font(.caption2) + } + .foregroundStyle(didCopy ? Theme.success : Theme.textTertiary) } + .buttonStyle(.plain) + .help("Copy code") + } } From 67cce06533fbe1fcbbf9d0dc3a283dea8c1af42b Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 04:57:07 +0200 Subject: [PATCH 28/45] feat(agent): persist sessions to disk and resume via Claude SDK session id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up the data layer so conversations survive across app launches and the user can pick a previous session back up where they left off: Backend (rae_agent): - ChatRequest gains a claude_session_id field (accepts camelCase or snake_case from the wire). When present, we pass it as ClaudeAgentOptions.resume so the SDK rehydrates its own conversation state instead of asking us to replay history. - New AgentEvent.session_started(chat_id, claude_session_id) is emitted exactly once per chat — captured off the first SDK message that carries a session_id. Subsequent sends pass the captured id back as resume. macOS (Swift): - AgentEvent / AgentHistoryItem / AgentTargetLanguage are now Codable so the timeline + history can roundtrip through JSON. - AgentChatRequest gains claudeSessionId (Encodable, optional). When set, AgentSession ships an empty history array on the wire — the SDK already has the conversation context, no point shipping it twice. - New AgentEvent.sessionStarted case + decoder branch captures the id but doesn't render in the timeline. AgentPanel folds it into the same EmptyView path as assistantTextChunk. - AgentSession.handle stores the captured id in claudeSessionID and persists every event via store.save. Persistence layer (new): - AgentSessionRecord — the on-disk payload: id, title, createdAt, lastModifiedAt, target, events, history, lastWorkdir, generatedFiles, claudeSessionID. Saved as //session.json alongside the flows/ and out/ directories the sidecar already writes. - AgentSessionSummary — the lightweight row shown in lists (id, title, createdAt, lastModifiedAt, messageCount counting only user + assistant text events). - AgentSessionStore — @MainActor @Observable owner of the sessions list. reload() scans the directory off-main and sorts by recency; save/load/delete run on detached tasks. Auto-loads on init. AgentSession lifecycle (new): - startNewSession() / openSession(id:) / backToList() / deleteSession(id:) — high-level operations that drive the upcoming sessions-list UI. - mode: Mode (list | session) — currently always defaults to .list so the panel opens on the history view (UI for it lands in the next commit). - Title auto-derives from the first 60 chars of the user's first message; falls back to "Untitled session" until that's written. - handle() now calls persist() after every event so a crash mid-stream still leaves a recoverable transcript. No UI changes in this commit — the storage layer is wired up but the sessions list view + cards layout follow in a separate change. Co-Authored-By: Claude Opus 4.7 --- backend/rae_agent/protocol.py | 14 ++ backend/rae_agent/session.py | 21 ++- .../ReverseAPI/Agent/AgentProtocol.swift | 21 ++- .../ReverseAPI/Agent/AgentSession.swift | 139 ++++++++++++++++-- .../ReverseAPI/Agent/AgentSessionRecord.swift | 33 +++++ .../ReverseAPI/Agent/AgentSessionStore.swift | 105 +++++++++++++ macos/Sources/ReverseAPI/UI/AgentPanel.swift | 5 +- 7 files changed, 313 insertions(+), 25 deletions(-) create mode 100644 macos/Sources/ReverseAPI/Agent/AgentSessionRecord.swift create mode 100644 macos/Sources/ReverseAPI/Agent/AgentSessionStore.swift diff --git a/backend/rae_agent/protocol.py b/backend/rae_agent/protocol.py index a52a3c4..5e5c463 100644 --- a/backend/rae_agent/protocol.py +++ b/backend/rae_agent/protocol.py @@ -82,6 +82,10 @@ class ChatRequest: 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": @@ -97,12 +101,15 @@ def from_payload(cls, payload: dict[str, Any]) -> "ChatRequest": {"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, ) @@ -118,6 +125,13 @@ def to_dict(self) -> dict[str, Any]: 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}) diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index c03586b..f5de7f2 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -86,21 +86,28 @@ async def run_chat(request: ChatRequest, base_dir: Path) -> AsyncIterator[AgentE system_prompt=SYSTEM_PROMPT, cwd=str(dirs.output_dir), allowed_tools=["Read", "Write", "Edit"], - # `acceptEdits` still prompts for Read on paths outside the cwd — - # including the session's own flows.json, which lives in the - # sibling flows/ directory. Use `bypassPermissions` (same setting - # the reverse_api collector + auto_engineer use) so the agent can - # operate end-to-end without a permission dialog. The sidecar is - # already isolated to per-chat session directories, so there's no - # broader-than-intended access enabled here. 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: diff --git a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift index 5fa92df..09139a4 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentProtocol.swift @@ -1,7 +1,7 @@ import Foundation import ReverseAPIProxy -enum AgentTargetLanguage: String, CaseIterable, Identifiable, Sendable { +enum AgentTargetLanguage: String, CaseIterable, Identifiable, Sendable, Codable { case python case typescript case go @@ -61,7 +61,7 @@ struct AgentFlowPayload: Encodable { } } -struct AgentHistoryItem: Encodable { +struct AgentHistoryItem: Codable { let role: String let content: String } @@ -73,9 +73,13 @@ struct AgentChatRequest: Encodable { 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 { +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) @@ -84,6 +88,10 @@ enum AgentEvent: Sendable, Identifiable { 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 { @@ -95,6 +103,7 @@ enum AgentEvent: Sendable, Identifiable { case .fileWritten(_, let id, _): return id case .complete(_, let id, _, _): return id case .error(_, let id, _): return id + case .sessionStarted(_, let id, _): return id } } } @@ -152,6 +161,12 @@ enum AgentEventDecoder { 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) } diff --git a/macos/Sources/ReverseAPI/Agent/AgentSession.swift b/macos/Sources/ReverseAPI/Agent/AgentSession.swift index ef86aca..ef2ec37 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSession.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSession.swift @@ -13,6 +13,13 @@ final class AgentSession { 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? @@ -22,19 +29,81 @@ final class AgentSession { 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 var sessionID = UUID().uuidString + 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: @@ -62,20 +131,27 @@ final class AgentSession { guard !trimmed.isEmpty else { return } await ensureRunning() guard status == .ready || status == .streaming else { return } - let previousHistory = history 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: previousHistory + history: historyToSend, + claudeSessionId: claudeSessionID ) history.append(userHistoryItem) - // Surface the user's prompt in the timeline immediately so they see - // their own message right next to the assistant's reply, before the - // agent has had a chance to respond. 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 @@ -86,6 +162,9 @@ final class AgentSession { } } + /// 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() @@ -93,6 +172,9 @@ final class AgentSession { lastWorkdir = nil lastError = nil sessionID = UUID().uuidString + sessionCreatedAt = Date() + sessionTitle = nil + claudeSessionID = nil if status == .failed { status = .idle } } @@ -125,9 +207,6 @@ final class AgentSession { private func handle(_ event: AgentEvent) { switch event { case .assistantTextChunk(let chatID, _, let chunk): - // Stream the chunk into the active assistant message instead of - // creating a new event per delta. If the previous event isn't an - // assistantText, start a fresh one with this chunk as the seed. if let lastIndex = events.indices.last, case .assistantText(let c, let id, let existing) = events[lastIndex], c == chatID { @@ -148,15 +227,16 @@ final class AgentSession { 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() } } - /// When the assistant streams its reply via chunks we only know the full - /// text once the turn completes. Walk the timeline backwards to find the - /// last assistantText and commit it into history (unless it's already the - /// latest entry, which is the legacy non-streaming path). private func recordStreamedAssistantTextIntoHistory() { for event in events.reversed() { if case .assistantText(_, _, let text) = event, !text.isEmpty { @@ -169,4 +249,37 @@ final class AgentSession { } } } + + // 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/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 91d5b1e..c4b6a90 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -207,9 +207,10 @@ private struct AgentEventRow: View { UserMessageRow(text: text) case .assistantText(_, _, let text): AssistantRow(text: text) - case .assistantTextChunk: + case .assistantTextChunk, .sessionStarted: // Chunks are folded into the active assistantText event by - // AgentSession; we never render them as standalone rows. + // 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) From e46d031beebb5cb6b461f8761b709c87ae374473 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:00:18 +0200 Subject: [PATCH 29/45] feat(macos): cards layout + agent sessions history view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main window switches from "traffic | optional inspector | optional agent" into two always-on inset cards: traffic on the left, agent on the right, both with rounded corners + Theme.border on Theme.surface, separated by an HSplitView so the user can resize the column ratio. ContentView: - New Card { content } wrapper applies clipShape(RoundedRectangle 10pt continuous) + 1pt Theme.border outline + Theme.surface background. - Outer HSplitView with 10pt padding around both cards. The agent card starts at 380pt ideal width / 320pt min; the traffic card takes the rest with a 420pt min. - Inside the traffic card, an inner HSplitView shows the table on the left and (when state.selectedFlowID is set) the inspector on the right — both views now live inside the same card instead of as separate columns. - Drops the agent-toggle plumbing entirely: no more isAgentVisible binding, no more sparkles button in the toolbar, no more AppStorage key. The agent is always visible. ReverseAPIApp: - Removes the @AppStorage("rae.agent.visible") and the binding it passed to ContentView. SessionsListView (new): - "Sessions" title + count chip + "+" button on the right of the card header. Tap "+" to call agent.startNewSession(). - Body is a ScrollView+LazyVStack of SessionRow entries sorted by recency (the store does that on reload). Each row shows the auto-derived title (up to 2 lines), a relative "5m ago" timestamp, and the user+assistant message count. Hover paints Theme.elevated. - Context menu on each row exposes "Delete" (destructive) — routes through agent.deleteSession(id:). - Empty state when there are no sessions yet: bubble icon + "No sessions yet · Tap + to start a new conversation". AgentPanel: - Now a 2-mode router: when agent.mode == .list, render SessionsListView; when .session, render ActiveSessionView. - ActiveSessionView wraps SessionHeader + AgentTimeline + AgentComposer the way the old panel did, just split into a separate type. - SessionHeader replaces AgentHeader: drops the "Agent" label, the status dot, the "Idle · N selected" subtitle, and the trash button. Keeps just a back chevron (returns to the sessions list) and the language picker. Status feedback is now implicit — the composer's send button is disabled while launching, the streaming dots still appear in the timeline. The Card primitive is reusable: the file viewer could later adopt it for consistency, but that's out of scope here. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/App/ReverseAPIApp.swift | 3 +- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 94 ++++-------- macos/Sources/ReverseAPI/UI/ContentView.swift | 52 ++++--- .../ReverseAPI/UI/SessionsListView.swift | 137 ++++++++++++++++++ 4 files changed, 202 insertions(+), 84 deletions(-) create mode 100644 macos/Sources/ReverseAPI/UI/SessionsListView.swift diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index f4aeb91..d8eb0ac 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.agent.visible") private var isAgentVisible = false var body: some Scene { Window("rae", id: "main") { switch session { case .ready(let state): - ContentView(isAgentVisible: $isAgentVisible) + ContentView() .environment(state) .onAppear { AppLifecycle.shared.state = state diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index c4b6a90..96662c5 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -5,16 +5,27 @@ import ReverseAPIProxy struct AgentPanel: View { @Environment(AppState.self) private var state + var body: some View { + switch state.agent.mode { + case .list: + SessionsListView() + .background(Theme.appBackground) + case .session: + ActiveSessionView() + .background(Theme.appBackground) + } + } +} + +// 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) { - AgentHeader( - target: $agent.target, - status: agent.status, - flowCount: flowsToSend.count, - isExplicitSelection: !state.agentSelection.isEmpty, - onClear: { agent.clear() } - ) + SessionHeader(target: $agent.target) AgentTimeline( events: agent.events, flowCount: flowsToSend.count, @@ -25,13 +36,9 @@ struct AgentPanel: View { ) AgentComposer(input: $agent.input, status: agent.status, onSend: send) } - .background(Theme.appBackground) } private var flowsToSend: [CapturedFlow] { - // If the user has explicitly checked rows, share those; otherwise - // fall back to the filtered view so a fresh session still has - // something to chew on. if !state.agentSelection.isEmpty { return state.store.flows.filter { state.agentSelection.contains($0.id) } } @@ -44,68 +51,31 @@ struct AgentPanel: View { } } -// MARK: - Header +// MARK: - Session header (minimal: back, language picker) -private struct AgentHeader: View { +private struct SessionHeader: View { + @Environment(AppState.self) private var state @Binding var target: AgentTargetLanguage - let status: AgentSession.Status - let flowCount: Int - let isExplicitSelection: Bool - let onClear: () -> Void var body: some View { - HStack(spacing: 10) { - Circle() - .fill(statusColor) - .frame(width: 7, height: 7) - VStack(alignment: .leading, spacing: 0) { - Text("Agent") + HStack(spacing: 8) { + Button { + state.agent.backToList() + } label: { + Image(systemName: "chevron.left") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - Text(subtitle) - .font(.system(size: 11)) - .foregroundStyle(Theme.textTertiary) + .foregroundStyle(Theme.textSecondary) + .frame(width: 26, height: 26) + .background(Theme.elevated, in: Circle()) } + .buttonStyle(.plain) + .help("Back to sessions") Spacer() LanguageMenu(target: $target) - Button(action: onClear) { - Image(systemName: "trash") - .font(.caption) - .foregroundStyle(Theme.textTertiary) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .help("Clear conversation") } .padding(.horizontal, 14) - .padding(.vertical, 10) - } - - private var subtitle: String { - let suffix: String - switch status { - case .idle: suffix = "Idle" - case .launching: suffix = "Starting…" - case .ready: suffix = "Ready" - case .streaming: suffix = "Thinking…" - case .failed: suffix = "Error" - } - let source = isExplicitSelection - ? "\(flowCount) selected" - : "\(flowCount) filtered" - return "\(suffix) · \(source)" + .padding(.vertical, 12) } - - private var statusColor: Color { - switch status { - case .idle: return Theme.textTertiary - case .launching: return .yellow - case .ready: return Theme.success - case .streaming: return Theme.accent - case .failed: return Theme.danger - } - } - } private struct LanguageMenu: View { diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index e24105b..3257c8f 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -5,7 +5,6 @@ import UniformTypeIdentifiers struct ContentView: View { @Environment(AppState.self) private var state - @Binding var isAgentVisible: Bool @State private var isPaletteVisible: Bool = false var body: some View { @@ -13,23 +12,25 @@ struct ContentView: View { VStack(spacing: 0) { ActionBar(onOpenPalette: { isPaletteVisible = true }) ThinDivider() - HStack(spacing: 0) { - HSplitView { - TrafficListView() - .frame(minWidth: 420, maxHeight: .infinity) - - if state.selectedFlowID != nil { - InspectorView() - .frame(minWidth: 360, idealWidth: 520, maxHeight: .infinity) + HSplitView { + Card { + HSplitView { + TrafficListView() + .frame(minWidth: 360, maxHeight: .infinity) + if state.selectedFlowID != nil { + InspectorView() + .frame(minWidth: 320, idealWidth: 480, maxHeight: .infinity) + } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 420) - if isAgentVisible { + Card { AgentPanel() - .frame(width: 380) } + .frame(minWidth: 320, idealWidth: 380) } + .padding(10) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -79,20 +80,31 @@ struct ContentView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItemGroup(placement: .primaryAction) { - Button { - isAgentVisible.toggle() - } label: { - Image(systemName: isAgentVisible ? "sparkles.rectangle.stack.fill" : "sparkles.rectangle.stack") - } - .help(isAgentVisible ? "Hide agent" : "Show agent") - 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: - Thin divider struct ThinDivider: View { diff --git a/macos/Sources/ReverseAPI/UI/SessionsListView.swift b/macos/Sources/ReverseAPI/UI/SessionsListView.swift new file mode 100644 index 0000000..3e1bf15 --- /dev/null +++ b/macos/Sources/ReverseAPI/UI/SessionsListView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +/// History view that lives inside the agent card. Lists every persisted +/// session sorted by recency, plus a "+" affordance at the top to create +/// a fresh one. Tapping a row opens that session in the timeline view. +struct SessionsListView: View { + @Environment(AppState.self) private var state + @State private var hoveredID: String? + + var body: some View { + VStack(spacing: 0) { + header + ThinDivider() + list + } + } + + // MARK: - Header + + private var header: some View { + HStack { + Text("Sessions") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + Text("\(state.agent.store.sessions.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(Theme.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Theme.elevated, in: Capsule()) + Spacer() + Button { + state.agent.startNewSession() + } label: { + Image(systemName: "plus") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 26, height: 26) + .background(Theme.elevated, in: Circle()) + } + .buttonStyle(.plain) + .help("Start a new session") + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } + + // MARK: - List body + + @ViewBuilder + private var list: some View { + if state.agent.store.sessions.isEmpty { + emptyState + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(state.agent.store.sessions) { summary in + SessionRow( + summary: summary, + isHovered: hoveredID == summary.id + ) + .onHover { hoveredID = $0 ? summary.id : nil } + .onTapGesture { + Task { await state.agent.openSession(id: summary.id) } + } + .contextMenu { + Button("Delete", role: .destructive) { + Task { await state.agent.deleteSession(id: summary.id) } + } + } + } + } + .padding(8) + } + } + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 26, weight: .light)) + .foregroundStyle(Theme.textTertiary) + Text("No sessions yet") + .font(.callout) + .foregroundStyle(Theme.textSecondary) + Text("Tap + to start a new conversation") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } +} + +// MARK: - Row + +private struct SessionRow: View { + let summary: AgentSessionSummary + let isHovered: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(summary.title) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + .truncationMode(.tail) + HStack(spacing: 6) { + Text(relativeTime) + .font(.caption) + .foregroundStyle(Theme.textTertiary) + if summary.messageCount > 0 { + Circle() + .fill(Theme.textTertiary.opacity(0.5)) + .frame(width: 3, height: 3) + Text("\(summary.messageCount) message\(summary.messageCount == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(Theme.textTertiary) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isHovered ? Theme.elevated : Color.clear, + in: RoundedRectangle(cornerRadius: 7) + ) + .contentShape(Rectangle()) + } + + private var relativeTime: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: summary.lastModifiedAt, relativeTo: Date()) + } +} From 4e4682390242fb3ff44c79cc297bc9d806b01863 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:33:21 +0200 Subject: [PATCH 30/45] refactor(macos): redesign traffic + inspector rows for narrow widths, fix card spacing, neutral checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tabular traffic row had fixed columns (time / method / host / path / status) that broke once the traffic card got squeezed below ~500pt with the inspector or a narrow window — the host column overflowed into the path column, columns collapsed onto themselves, and the header label row stopped lining up with the data rows. Same story in the inspector: 150pt fixed header-name column wasted space and the URL line ran past the card edge. TrafficListView: - Header row replaced with a compact "Traffic · N" title + select-all checkbox at the leading edge. No more tabular HeaderLabel columns — the new rows aren't strictly tabular anyway. - TrafficRow is a two-line layout: checkbox + method badge on the left, host on top and path below in the flex middle column, status + timestamp stacked on the right. The middle column truncates host and path with .middle so the most identifying part of the URL survives at any width. - Survives down to ~260pt of card width without overlap. Hover and selected backgrounds unchanged (Theme.elevated on select, white@3% on hover). - AgentCheckbox checked-state color flips from Theme.accent (blue) to Theme.textPrimary (off-white). The select-all header checkbox follows the same rule. The user kept asking for a less loud color for the selection affordance. InspectorView: - Header repacks: METHOD + STATUS on the top row with a Theme.elevated pill close button on the right; the URL splits into a host line + path line below in the same style as the traffic row so the identification stays consistent between the two views. - Padding tightened from 16pt to 14pt (matches the traffic header and rows). - Tab bar wraps in a horizontal ScrollView so the four-tab NSSegmented stays usable even when the card is too narrow to fit all labels. - HeadersSection switches from "name | value" fixed-width side-by-side to a vertical layout: lowercase muted name on top, mono value below. Reads like a typical HTTP request listing in browser devtools and stops clipping when the panel is narrow. - Overview metric rows: key column shrinks from 100pt to 84pt and values get a 2-line truncation cap so a long Content-Type header doesn't push the row off the trailing edge. ContentView: - Card spacing: traffic card gets `.padding(.trailing, 6)`, agent card gets `.padding(.leading, 6)` — visible 12pt gap between cards instead of the previous flush HSplitView divider. Outer padding around the pair bumped from 10pt to 12pt for breathing room. - Min widths: traffic card minWidth = 320pt (was 420), table inside it = 280pt (was 360), inspector inside it = 320pt (unchanged) so the user can shrink the window further without breaking the layout. Agent card stays 340–380pt range so it doesn't fall below the size where the composer becomes unusable. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/ContentView.swift | 14 +- .../Sources/ReverseAPI/UI/InspectorView.swift | 76 ++++++---- .../ReverseAPI/UI/TrafficListView.swift | 140 ++++++++---------- 3 files changed, 116 insertions(+), 114 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 3257c8f..c316934 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -16,21 +16,27 @@ struct ContentView: View { Card { HSplitView { TrafficListView() - .frame(minWidth: 360, maxHeight: .infinity) + .frame(minWidth: 280, maxHeight: .infinity) if state.selectedFlowID != nil { InspectorView() .frame(minWidth: 320, idealWidth: 480, maxHeight: .infinity) } } } - .frame(minWidth: 420) + .frame(minWidth: 320) + // Right padding on the trailing edge of the traffic card + // gives the visible gap between cards. HSplitView itself + // owns a 1pt system divider; the padding pushes it off + // the card outline so the two cards read as separate. + .padding(.trailing, 6) Card { AgentPanel() } - .frame(minWidth: 320, idealWidth: 380) + .frame(minWidth: 340, idealWidth: 380) + .padding(.leading, 6) } - .padding(10) + .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/macos/Sources/ReverseAPI/UI/InspectorView.swift b/macos/Sources/ReverseAPI/UI/InspectorView.swift index 7fc93db..79f6f66 100644 --- a/macos/Sources/ReverseAPI/UI/InspectorView.swift +++ b/macos/Sources/ReverseAPI/UI/InspectorView.swift @@ -55,7 +55,7 @@ private struct FlowInspector: View { ScrollView { content .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) + .padding(14) } } .background(Theme.surface) @@ -68,19 +68,20 @@ private struct FlowInspector: View { } private var tabBar: some View { - HStack { - NSSegmented( - labels: availableTabs.map { $0.rawValue }, - selection: Binding( - get: { availableTabs.firstIndex(of: tab) ?? 0 }, - set: { tab = availableTabs[$0] } + ScrollView(.horizontal, showsIndicators: false) { + HStack { + NSSegmented( + labels: availableTabs.map { $0.rawValue }, + selection: Binding( + get: { availableTabs.firstIndex(of: tab) ?? 0 }, + set: { tab = availableTabs[$0] } + ) ) - ) - .fixedSize() - Spacer() + .fixedSize() + } + .padding(.horizontal, 14) + .padding(.vertical, 8) } - .padding(.horizontal, 14) - .padding(.vertical, 8) } private var availableTabs: [InspectorTab] { @@ -100,10 +101,10 @@ private struct FlowInspector: View { .font(.system(.callout, design: .monospaced).weight(.semibold)) .foregroundStyle(statusColor(status)) } - Spacer() + Spacer(minLength: 4) if let finishedAt = flow.finishedAt { Text(formatDuration(flow.startedAt, finishedAt)) - .foregroundStyle(Theme.textSecondary) + .foregroundStyle(Theme.textTertiary) .font(.caption) .monospacedDigit() } @@ -114,18 +115,26 @@ private struct FlowInspector: View { Image(systemName: "xmark") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.textSecondary) - .frame(width: 20, height: 20) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) } .buttonStyle(.plain) .help("Close inspector") } - Text(flow.url) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(Theme.textPrimary) - .textSelection(.enabled) - .lineLimit(2) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 1) { + Text(flow.host) + .font(.callout) + .foregroundStyle(Theme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Text(flow.path) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Theme.textSecondary) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + } if let error = flow.error { Label(error, systemImage: "exclamationmark.octagon.fill") @@ -133,9 +142,9 @@ private struct FlowInspector: View { .font(.caption) } } - .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 12) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 10) } private var copyMenu: some View { @@ -214,14 +223,16 @@ private struct FlowInspector: View { private func row(_ key: String, _ value: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(key) - .frame(width: 100, alignment: .leading) - .foregroundStyle(Theme.textSecondary) - .font(.callout) + .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) } } @@ -573,18 +584,19 @@ private struct HeadersSection: View { .font(.callout) } else { ForEach(Array(headers.enumerated()), id: \.offset) { _, header in - HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { Text(header.name) - .font(.system(.callout, design: .monospaced).weight(.medium)) - .foregroundStyle(Theme.textSecondary) - .frame(width: 150, alignment: .leading) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(Theme.textTertiary) + .textCase(.lowercase) Text(header.value) .font(.system(.callout, design: .monospaced)) .foregroundStyle(Theme.textPrimary) .textSelection(.enabled) .lineLimit(nil) - .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index f44ecc0..327b78e 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -2,25 +2,30 @@ import AppKit import SwiftUI import ReverseAPIProxy +/// Traffic list rendered as a vertical stack of compact two-line rows. The +/// previous fixed-column tabular layout broke when the traffic card got +/// narrow (paths and hosts collided, columns wrapped). The new row keeps +/// host on top + path below in a flex middle column so the row stays +/// readable down to ~240pt wide. struct TrafficListView: View { @Environment(AppState.self) private var state var body: some View { - ZStack { - Theme.appBackground.ignoresSafeArea() - - VStack(spacing: 0) { - TrafficHeaderRow(visibleIDs: filteredFlows.map(\.id)) - ThinDivider() - if state.store.flows.isEmpty { - EmptyTrafficState() - } else if filteredFlows.isEmpty { - EmptyFilterState() - } else { - TrafficRowList(flows: filteredFlows) - } + VStack(spacing: 0) { + TrafficListHeader( + visibleCount: filteredFlows.count, + visibleIDs: filteredFlows.map(\.id) + ) + ThinDivider() + if state.store.flows.isEmpty { + EmptyTrafficState() + } else if filteredFlows.isEmpty { + EmptyFilterState() + } else { + TrafficRowList(flows: filteredFlows) } } + .background(Theme.surface) } private var filteredFlows: [CapturedFlow] { @@ -30,23 +35,26 @@ struct TrafficListView: View { // MARK: - Header -private struct TrafficHeaderRow: View { - @Environment(AppState.self) private var state +private struct TrafficListHeader: View { + let visibleCount: Int let visibleIDs: [UUID] var body: some View { - HStack(spacing: 0) { + HStack(spacing: 10) { SelectAllCheckbox(visibleIDs: visibleIDs) - .frame(width: 28, alignment: .center) - HeaderLabel("Time", width: 72, align: .leading) - HeaderLabel("Method", width: 64, align: .leading) - HeaderLabel("Host", width: 200, align: .leading) - HeaderLabel("Path", width: nil, align: .leading) - HeaderLabel("Status", width: 60, align: .trailing) + Text("Traffic") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + Text("\(visibleCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(Theme.textTertiary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Theme.elevated, in: Capsule()) + Spacer() } .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Theme.appBackground) + .padding(.vertical, 12) } } @@ -55,11 +63,9 @@ private struct SelectAllCheckbox: View { let visibleIDs: [UUID] var body: some View { - Button { - toggle() - } label: { + Button(action: toggle) { Image(systemName: glyph) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 14, weight: .medium)) .foregroundStyle(tint) .frame(width: 18, height: 18) } @@ -84,7 +90,7 @@ private struct SelectAllCheckbox: View { } private var tint: Color { - (allSelected || someSelected) ? Theme.accent : Theme.textTertiary + (allSelected || someSelected) ? Theme.textPrimary : Theme.textTertiary } private func toggle() { @@ -96,27 +102,6 @@ private struct SelectAllCheckbox: View { } } -private struct HeaderLabel: View { - let text: String - let width: CGFloat? - let align: Alignment - - init(_ text: String, width: CGFloat?, align: Alignment) { - self.text = text - self.width = width - self.align = align - } - - var body: some View { - Text(text.uppercased()) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Theme.textTertiary) - .tracking(0.6) - .frame(width: width, alignment: align) - .frame(maxWidth: width == nil ? .infinity : nil, alignment: align) - } -} - // MARK: - Rows private struct TrafficRowList: View { @@ -162,7 +147,7 @@ private struct TrafficRow: View { var body: some View { Button(action: onSelect) { - HStack(spacing: 0) { + HStack(spacing: 10) { AgentCheckbox(isOn: isCheckedForAgent) { if state.agentSelection.contains(flow.id) { state.agentSelection.remove(flow.id) @@ -170,35 +155,33 @@ private struct TrafficRow: View { state.agentSelection.insert(flow.id) } } - .frame(width: 28, alignment: .center) - - Text(flow.startedAt, format: .dateTime.hour().minute().second()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Theme.textTertiary) - .frame(width: 72, alignment: .leading) MethodBadge(method: flow.method) - .frame(width: 64, alignment: .leading) - - Text(flow.host) - .font(.callout) - .foregroundStyle(Theme.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 200, alignment: .leading) - - Text(flow.path) - .font(.system(.callout, design: .monospaced)) - .foregroundStyle(Theme.textSecondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(width: 46, alignment: .leading) + + VStack(alignment: .leading, spacing: 1) { + 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) + } + .frame(maxWidth: .infinity, alignment: .leading) - StatusBadge(status: flow.responseStatus, error: flow.error) - .frame(width: 60, alignment: .trailing) + VStack(alignment: .trailing, spacing: 1) { + StatusBadge(status: flow.responseStatus, error: flow.error) + Text(flow.startedAt, format: .dateTime.hour().minute().second()) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + } } - .padding(.horizontal, 14) - .padding(.vertical, 6) + .padding(.horizontal, 12) + .padding(.vertical, 7) .frame(maxWidth: .infinity) .background(background) .contentShape(Rectangle()) @@ -238,8 +221,8 @@ private struct AgentCheckbox: View { var body: some View { Button(action: action) { Image(systemName: isOn ? "checkmark.square.fill" : "square") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(isOn ? Theme.accent : Theme.textTertiary) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isOn ? Theme.textPrimary : Theme.textTertiary) .frame(width: 18, height: 18) } .buttonStyle(.plain) @@ -254,6 +237,7 @@ private struct MethodBadge: View { Text(method) .font(.system(.caption, design: .monospaced).weight(.semibold)) .foregroundStyle(color) + .lineLimit(1) } private var color: Color { From ae5b3267c3fc918915f691c5b8018be08e98e15b Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:34:38 +0200 Subject: [PATCH 31/45] refactor(macos): drop divider between action bar and cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ThinDivider() between the ActionBar (filters / search / state chips) and the HSplitView underneath was load-bearing back when the main area was a flat panel with no other visual separation. Now that the traffic and agent cards each draw their own RoundedRectangle outline with Theme.border and there's 12pt of padding around the pair, the divider is redundant — it just adds a horizontal line through what should already read as a clean separation between the action bar and the inset cards. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/ContentView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index c316934..700ab4d 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -11,7 +11,6 @@ struct ContentView: View { ZStack { VStack(spacing: 0) { ActionBar(onOpenPalette: { isPaletteVisible = true }) - ThinDivider() HSplitView { Card { HSplitView { From afcee6d497c3f35b116f9c61854d71cadbabc870 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:45:06 +0200 Subject: [PATCH 32/45] fix(macos): align card headers, unify backgrounds, raise traffic card min width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small batch of cleanups so the two cards read as visually matched and the user can't squeeze the traffic card into a glitchy state any more. ContentView: - Traffic card minWidth bumps from 320pt to 540pt (~½ the 980pt minimum window). Below that we hit the layout issues from earlier iterations — host/path collisions, inspector tabs scrolling under themselves, the table header desync from rows. The HSplitView divider now stops at this floor so the user can't drag the traffic side narrower than usable. AgentPanel / SessionsListView: - AgentPanel drops its own `.background(Theme.appBackground)` override. Both modes (list + active session) now inherit Theme.surface from the enclosing Card, which keeps the agent card's color identical to the traffic card. Previously the agent card read distinctly darker because Theme.appBackground sits a shade below Theme.surface. - AgentTimeline ScrollView + EmptyAgentState frame + AgentComposer outer background all repointed to Theme.surface for the same reason. The send button's arrow glyph stays in Theme.appBackground since it sits against a Theme.textPrimary white circle and needs the contrast. - SessionsListView header drops the `N` session count chip — the count was already obvious from the visible list, the badge was just noise. Header content keeps the title + "+" affordance on the trailing edge. - New session "+" button shrinks to 22pt (from 26pt) so the header has the same intrinsic height as the traffic header. - Same 22pt sizing applied to the SessionHeader back-chevron button (active session mode). TrafficListView header: - Same horizontal padding (12pt) as TrafficRow so the select-all checkbox sits in the same column as the per-row AgentCheckbox. - Both headers (Sessions list, Traffic) now use `.frame(height: 44)` instead of varying vertical padding, so the title row in the two cards lines up at the exact same Y position. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/AgentPanel.swift | 19 +++++++++++-------- macos/Sources/ReverseAPI/UI/ContentView.swift | 8 ++++++-- .../ReverseAPI/UI/SessionsListView.swift | 14 ++++---------- .../ReverseAPI/UI/TrafficListView.swift | 6 ++++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/AgentPanel.swift b/macos/Sources/ReverseAPI/UI/AgentPanel.swift index 96662c5..c684391 100644 --- a/macos/Sources/ReverseAPI/UI/AgentPanel.swift +++ b/macos/Sources/ReverseAPI/UI/AgentPanel.swift @@ -6,13 +6,16 @@ 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() - .background(Theme.appBackground) case .session: ActiveSessionView() - .background(Theme.appBackground) } } } @@ -63,9 +66,9 @@ private struct SessionHeader: View { state.agent.backToList() } label: { Image(systemName: "chevron.left") - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.textSecondary) - .frame(width: 26, height: 26) + .frame(width: 22, height: 22) .background(Theme.elevated, in: Circle()) } .buttonStyle(.plain) @@ -74,7 +77,7 @@ private struct SessionHeader: View { LanguageMenu(target: $target) } .padding(.horizontal, 14) - .padding(.vertical, 12) + .frame(height: 44) } } @@ -122,7 +125,7 @@ private struct AgentTimeline: View { if events.isEmpty && error == nil && generatedFiles.isEmpty && status != .streaming { EmptyAgentState() .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Theme.appBackground) + .background(Theme.surface) } else { ScrollViewReader { proxy in ScrollView { @@ -145,7 +148,7 @@ private struct AgentTimeline: View { .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) } - .background(Theme.appBackground) + .background(Theme.surface) .onChange(of: events.count) { _, _ in if let last = events.last { withAnimation(.easeOut(duration: 0.15)) { @@ -566,7 +569,7 @@ private struct AgentComposer: View { .padding(10) .background(Theme.input, in: RoundedRectangle(cornerRadius: 12)) .padding(12) - .background(Theme.appBackground) + .background(Theme.surface) } private var canSend: Bool { diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 700ab4d..d1da84e 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -15,14 +15,18 @@ struct ContentView: View { Card { HSplitView { TrafficListView() - .frame(minWidth: 280, maxHeight: .infinity) + .frame(minWidth: 300, maxHeight: .infinity) if state.selectedFlowID != nil { InspectorView() .frame(minWidth: 320, idealWidth: 480, maxHeight: .infinity) } } } - .frame(minWidth: 320) + // Roughly half the minimum window (980pt) so the user + // can never compress the traffic card into the layout + // glitch zone we hit earlier — host/path collisions, + // header label overflow, inspector tabs scrolling. + .frame(minWidth: 540) // Right padding on the trailing edge of the traffic card // gives the visible gap between cards. HSplitView itself // owns a 1pt system divider; the padding pushes it off diff --git a/macos/Sources/ReverseAPI/UI/SessionsListView.swift b/macos/Sources/ReverseAPI/UI/SessionsListView.swift index 3e1bf15..f2d16d1 100644 --- a/macos/Sources/ReverseAPI/UI/SessionsListView.swift +++ b/macos/Sources/ReverseAPI/UI/SessionsListView.swift @@ -18,31 +18,25 @@ struct SessionsListView: View { // MARK: - Header private var header: some View { - HStack { + HStack(spacing: 10) { Text("Sessions") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.textPrimary) - Text("\(state.agent.store.sessions.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(Theme.textTertiary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Theme.elevated, in: Capsule()) Spacer() Button { state.agent.startNewSession() } label: { Image(systemName: "plus") - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.textPrimary) - .frame(width: 26, height: 26) + .frame(width: 22, height: 22) .background(Theme.elevated, in: Circle()) } .buttonStyle(.plain) .help("Start a new session") } .padding(.horizontal, 14) - .padding(.vertical, 12) + .frame(height: 44) } // MARK: - List body diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 327b78e..b19fe16 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -53,8 +53,10 @@ private struct TrafficListHeader: View { .background(Theme.elevated, in: Capsule()) Spacer() } - .padding(.horizontal, 14) - .padding(.vertical, 12) + // Same horizontal padding as TrafficRow so the header select-all + // checkbox sits in the same column as the per-row checkboxes. + .padding(.horizontal, 12) + .frame(height: 44) } } From da71445f7987268cd9a5967f467d3a4737bed393 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:52:16 +0200 Subject: [PATCH 33/45] fix(macos): traffic card auto-grows when inspector opens to prevent overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overflow glitch the user kept seeing on a narrow traffic card — rounded border disappearing, rows clipped past the right edge, scroll + row hit-testing broken — was a hard layout conflict. The card's inner HSplitView holds the table (minWidth 320) + the inspector (minWidth 340) = 660pt minimum when both are shown. The outer Card was capped at 540pt, so SwiftUI rendered the inner panes wider than the Card frame, leaking past the clipShape. Two fixes: - Traffic card frame minWidth is now conditional: - No flow selected: 380pt — the user can still compress the card down when the inspector isn't taking up space. - Flow selected: 700pt — covers the inner content min with headroom for the HSplitView divider so the inner views always fit inside the rounded clipShape. When the user clicks a flow, SwiftUI smoothly grows the card to the new minimum (eating into the agent card's flex room, which has its own 340pt floor). - Inner table minWidth bumped from 300pt → 320pt; inner inspector from 320pt → 340pt. Both have a touch more breathing room before their internal content (host/path stacks, header sections, tabs) starts feeling cramped. Window minimum width bumped from 980pt to 1100pt so the new expanded layout (traffic 700 + agent 340 + 36pt padding/gap = 1076) fits at the smallest window the user is allowed to create. On 13-inch MacBook Airs at scaled resolutions this still leaves plenty of room above the 1100pt floor. Co-Authored-By: Claude Opus 4.7 --- .../Sources/ReverseAPI/App/ReverseAPIApp.swift | 7 ++++++- macos/Sources/ReverseAPI/UI/ContentView.swift | 18 +++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift index d8eb0ac..3e742aa 100644 --- a/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift +++ b/macos/Sources/ReverseAPI/App/ReverseAPIApp.swift @@ -26,7 +26,12 @@ struct ReverseAPIApp: App { window.backgroundColor = NSColor(Theme.appBackground) }) .frame( - minWidth: 980, + // 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: 640, maxHeight: .infinity, diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index d1da84e..e7b9537 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -15,18 +15,22 @@ struct ContentView: View { Card { HSplitView { TrafficListView() - .frame(minWidth: 300, maxHeight: .infinity) + .frame(minWidth: 320, maxHeight: .infinity) if state.selectedFlowID != nil { InspectorView() - .frame(minWidth: 320, idealWidth: 480, maxHeight: .infinity) + .frame(minWidth: 340, idealWidth: 480, maxHeight: .infinity) } } } - // Roughly half the minimum window (980pt) so the user - // can never compress the traffic card into the layout - // glitch zone we hit earlier — host/path collisions, - // header label overflow, inspector tabs scrolling. - .frame(minWidth: 540) + // Conditional minWidth so the user can still compress the + // traffic card down when no inspector is showing, but the + // card auto-grows to fit table+inspector the moment a + // flow gets selected. Below 700pt with the inspector + // open, SwiftUI rendered the inner HSplitView wider + // than the Card frame, leaking past the rounded + // clipShape — borders disappeared, rows got clipped + // past the right edge, scroll + hit testing broke. + .frame(minWidth: state.selectedFlowID == nil ? 380 : 700) // Right padding on the trailing edge of the traffic card // gives the visible gap between cards. HSplitView itself // owns a 1pt system divider; the padding pushes it off From e0b1249bae7a4d015393cd8b478f3378b8ca6159 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 05:58:24 +0200 Subject: [PATCH 34/45] refactor(macos): slim down ActionBar, move filter + delete-all into traffic card header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action bar was getting cluttered: it owned both window-level status (capture state, CA trust, search) and traffic-scoped controls (capture mode toggle, resource-kind filter chips, reset filters badge). Split them so each lives where it makes sense. ActionBar (top, app-wide controls): - Drops the Device/Manual NSSegmented capture mode toggle. The same setting is already exposed inside the … menu in the toolbar (ActionsMenu), so there's no functionality lost. - Drops the ResourceKindStrip chip row — that filter UI moves into the traffic card itself (next section). - Drops the inline "X filters active" reset badge — same reason; the filter UI now lives next to the traffic. - Result: ActionBar is just CaptureStateChip + CATrustChip + Search (⌘K) pill. Much calmer; reads as "the current state of the proxy" rather than "every control we have." - Also removes the now-orphaned ModeToggle, ResourceKindStrip, and Chip private structs from ContentView since nothing references them anymore. TrafficListHeader (per-card controls): - New FilterButton — round button on the right with a `line.3.horizontal.decrease` icon. Opens a SwiftUI Menu containing the full filter surface: Errors-only toggle at the top, then Sections for Type (resource kinds), Method (driven by the live store.methodOptions), and Status buckets. Each option is a Toggle bound through a Binding wrapper that updates the TrafficFilter set in place. - When at least one filter is active, the button shows a small Theme.success dot in the top-right corner so the user always sees there's an active filter without opening the menu. The menu surface gets a trailing destructive "Reset filters" action under a Divider — appears only when activeFilterCount > 0 so the menu stays tight in the default case. - New DeleteAllButton — round trash icon button next to the filter, routes through state.clearFlows. Disabled (Theme.textTertiary) when the traffic list is empty. - Both new buttons sized at 22×22pt with Theme.elevated circle background, matching the "+" button in the sessions card header and the back chevron in the active-session header — same visual language across the two cards. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/ContentView.swift | 93 +------------- .../ReverseAPI/UI/TrafficListView.swift | 118 ++++++++++++++++++ 2 files changed, 120 insertions(+), 91 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index e7b9537..5d675f9 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -241,38 +241,21 @@ private struct ActionsMenu: View { } } -// MARK: - Action bar (mode + chips + search) +// MARK: - Action bar (status + search) private struct ActionBar: View { @Environment(AppState.self) private var state let onOpenPalette: () -> Void var body: some View { - @Bindable var bindable = state VStack(spacing: 0) { if let error = state.lastError { ErrorBanner(message: error) } HStack(spacing: 14) { - ModeToggle(selection: $bindable.captureMode) - .disabled(state.isCapturing || state.isWorking) - - ResourceKindStrip(selectedKinds: $bindable.filter.resourceKinds) - - if activeFilterCount > 0 { - Button { - bindable.filter = TrafficFilter() - } label: { - Label("\(activeFilterCount)", systemImage: "xmark.circle.fill") - .labelStyle(.titleAndIcon) - .foregroundStyle(Theme.textSecondary) - } - .buttonStyle(.borderless) - .help("Clear \(activeFilterCount) active filter(s)") - } - CaptureStateChip() CATrustChip() + Spacer() SearchButton(action: onOpenPalette) } .padding(.horizontal, 14) @@ -280,78 +263,6 @@ private struct ActionBar: View { } .background(Theme.appBackground) } - - private var activeFilterCount: Int { - var count = 0 - if !state.filter.search.isEmpty { count += 1 } - if state.filter.onlyErrors { count += 1 } - count += state.filter.hosts.count - count += state.filter.methods.count - count += state.filter.statusBuckets.count - count += state.filter.resourceKinds.count - return count - } -} - -private struct ModeToggle: View { - @Binding var selection: AppState.CaptureMode - - var body: some View { - NSSegmented( - labels: AppState.CaptureMode.allCases.map { $0.rawValue }, - selection: Binding( - get: { AppState.CaptureMode.allCases.firstIndex(of: selection) ?? 0 }, - set: { selection = AppState.CaptureMode.allCases[$0] } - ) - ) - .fixedSize() - .help("Capture mode") - } -} - - -private struct ResourceKindStrip: View { - @Binding var selectedKinds: Set - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - Chip(title: "All", isSelected: selectedKinds.isEmpty) { - selectedKinds.removeAll() - } - ForEach(TrafficFilter.ResourceKind.allCases) { kind in - Chip(title: kind.rawValue, isSelected: selectedKinds.contains(kind)) { - if selectedKinds.contains(kind) { - selectedKinds.remove(kind) - } else { - selectedKinds.insert(kind) - } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct Chip: View { - let title: String - let isSelected: Bool - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - Text(title) - .font(.caption.weight(.medium)) - .foregroundStyle(isSelected ? Theme.textPrimary : Theme.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .pillBackground(isActive: isSelected, isHovering: isHovering) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } } private struct ErrorBanner: View { diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index b19fe16..e3453a3 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -36,6 +36,7 @@ struct TrafficListView: View { // MARK: - Header private struct TrafficListHeader: View { + @Environment(AppState.self) private var state let visibleCount: Int let visibleIDs: [UUID] @@ -52,6 +53,8 @@ private struct TrafficListHeader: View { .padding(.vertical, 1) .background(Theme.elevated, in: Capsule()) Spacer() + FilterButton() + DeleteAllButton() } // Same horizontal padding as TrafficRow so the header select-all // checkbox sits in the same column as the per-row checkboxes. @@ -60,6 +63,121 @@ private struct TrafficListHeader: View { } } +// MARK: - Filter + delete-all actions + +private struct FilterButton: View { + @Environment(AppState.self) private var state + + var body: some View { + @Bindable var bindable = state + Menu { + Toggle(isOn: $bindable.filter.onlyErrors) { + Label("Errors only", systemImage: "exclamationmark.octagon") + } + Section("Type") { + ForEach(TrafficFilter.ResourceKind.allCases) { kind in + Toggle(isOn: bindingForResource(kind)) { Text(kind.rawValue) } + } + } + Section("Method") { + ForEach(state.store.methodOptions, id: \.self) { method in + Toggle(isOn: bindingForMethod(method)) { Text(method) } + } + } + Section("Status") { + ForEach(TrafficFilter.StatusBucket.allCases) { bucket in + Toggle(isOn: bindingForBucket(bucket)) { Text(bucket.rawValue) } + } + } + if activeFilterCount > 0 { + Divider() + Button("Reset filters", role: .destructive) { + bindable.filter = TrafficFilter() + } + } + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + if activeFilterCount > 0 { + Circle() + .fill(Theme.success) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(activeFilterCount > 0 + ? "Filter traffic · \(activeFilterCount) active" + : "Filter traffic") + } + + private var activeFilterCount: Int { + var count = 0 + if state.filter.onlyErrors { count += 1 } + count += state.filter.resourceKinds.count + count += state.filter.methods.count + count += state.filter.statusBuckets.count + count += state.filter.hosts.count + return count + } + + private func bindingForResource(_ kind: TrafficFilter.ResourceKind) -> Binding { + Binding( + get: { state.filter.resourceKinds.contains(kind) }, + set: { isOn in + if isOn { state.filter.resourceKinds.insert(kind) } + else { state.filter.resourceKinds.remove(kind) } + } + ) + } + + private func bindingForMethod(_ method: String) -> Binding { + Binding( + get: { state.filter.methods.contains(method) }, + set: { isOn in + if isOn { state.filter.methods.insert(method) } + else { state.filter.methods.remove(method) } + } + ) + } + + private func bindingForBucket(_ bucket: TrafficFilter.StatusBucket) -> Binding { + Binding( + get: { state.filter.statusBuckets.contains(bucket) }, + set: { isOn in + if isOn { state.filter.statusBuckets.insert(bucket) } + else { state.filter.statusBuckets.remove(bucket) } + } + ) + } +} + +private struct DeleteAllButton: View { + @Environment(AppState.self) private var state + + var body: some View { + Button { + state.clearFlows() + } label: { + Image(systemName: "trash") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(state.store.flows.isEmpty ? Theme.textTertiary : Theme.textPrimary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + } + .buttonStyle(.plain) + .disabled(state.store.flows.isEmpty) + .help("Delete all captured traffic") + } +} + private struct SelectAllCheckbox: View { @Environment(AppState.self) private var state let visibleIDs: [UUID] From 640b582333099882bcf7ddbd0768b5612be1fdd9 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 06:07:55 +0200 Subject: [PATCH 35/45] style(macos): warmer dark grey palette instead of near-black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the previous near-black canvas (#050506) read too sharp. Bump every background tier up roughly 6% luminance and lean a touch into a cool grey so the surfaces still feel dark but no longer charcoal-black: - appBackground: #050506 → #12131A - surface : #0B0B0D → #1B1C1F - elevated : #16161A → #28292E - input : #0F0F11 → #1F2024 The relative gap between tiers stays ~5% luminance, so hover and selected states keep their contrast. Border + text tokens are untouched. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/Theme.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/Theme.swift b/macos/Sources/ReverseAPI/UI/Theme.swift index 0a1778f..a51e961 100644 --- a/macos/Sources/ReverseAPI/UI/Theme.swift +++ b/macos/Sources/ReverseAPI/UI/Theme.swift @@ -1,11 +1,14 @@ import SwiftUI enum Theme { - // Backgrounds (darkest → lightest) — near-black palette - static let appBackground = Color(red: 0.020, green: 0.020, blue: 0.024) // #050506 - static let surface = Color(red: 0.043, green: 0.043, blue: 0.051) // #0B0B0D - static let elevated = Color(red: 0.086, green: 0.086, blue: 0.102) // #16161A - static let input = Color(red: 0.059, green: 0.059, blue: 0.067) // #0F0F11 + // Backgrounds (darkest → lightest) — exploration: bumped roughly +6% + // luminance across the dark stack so the canvas reads as warm dark grey + // instead of near-black. Each surface keeps the same relative gap + // (~5% between tiers) so contrast on hover/selected states is preserved. + static let appBackground = Color(red: 0.071, green: 0.075, blue: 0.086) // #12131A + static let surface = Color(red: 0.106, green: 0.110, blue: 0.122) // #1B1C1F + static let elevated = Color(red: 0.157, green: 0.161, blue: 0.180) // #28292E + static let input = Color(red: 0.122, green: 0.125, blue: 0.141) // #1F2024 static let overlay = Color.black.opacity(0.55) // Borders & dividers From bb3502d1d50aefe7a1bea583fdfac101b4f10459 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 06:12:02 +0200 Subject: [PATCH 36/45] refactor(macos): replace outer HSplitView with custom transparent split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the black bar between the traffic + agent cards looked weird, and the traffic-card minWidth conditional wasn't actually forcing the card to grow when the inspector opened — SwiftUI was still letting the inner content overflow past the rounded clipShape. Swap the outer HSplitView for a hand-rolled HStack split: - SplitLayout (new): computes the traffic + agent widths from the current GeometryReader width, the user-driven trafficWidth state, and whether the inspector is open. Mins live as static constants (trafficMinNoInspector 380, trafficMinWithInspector 720, agentMin 340, handleWidth 12, outerPadding 12), so clamping is one struct away from any caller. trafficMax is `usable - agentMin` so the agent never gets squeezed under its minimum. - DragHandle (new): a 12pt-wide invisible Rectangle between the cards. NSCursor.resizeLeftRight on hover gives the resize affordance, and a DragGesture forwards the translation back to the parent. Because the rectangle is `Color.clear`, the gap between the two cards reads as just app-background showing through — no more system splitter line. - ContentView: tracks `@State var trafficWidth` (seed 720pt) and `@State var dragStartWidth: CGFloat?`. The drag start snapshot is what fixes the cumulative-translation drift you get from naïve DragGesture handling. - `onChange(of: selectedFlowID)` runs the new flow through SplitLayout to detect whether the inspector's bigger minimum pushed the traffic card past its current width — if so, it animates the traffic card open with easeOut(0.2). The animation smooths the layout shift that used to feel jarring. The inner HSplitView between table and inspector inside the traffic card is unchanged. That divider lives inside the card's rounded clipShape and reads as expected. Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/UI/ContentView.swift | 139 ++++++++++++++---- 1 file changed, 111 insertions(+), 28 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/ContentView.swift b/macos/Sources/ReverseAPI/UI/ContentView.swift index 5d675f9..6a14982 100644 --- a/macos/Sources/ReverseAPI/UI/ContentView.swift +++ b/macos/Sources/ReverseAPI/UI/ContentView.swift @@ -6,44 +6,68 @@ import UniformTypeIdentifiers struct ContentView: View { @Environment(AppState.self) private var state @State private var isPaletteVisible: Bool = false + @State private var trafficWidth: CGFloat = 720 + @State private var dragStartWidth: CGFloat? var body: some View { ZStack { VStack(spacing: 0) { ActionBar(onOpenPalette: { isPaletteVisible = true }) - HSplitView { - Card { - HSplitView { - TrafficListView() - .frame(minWidth: 320, maxHeight: .infinity) - if state.selectedFlowID != nil { - InspectorView() - .frame(minWidth: 340, idealWidth: 480, maxHeight: .infinity) + 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) } - // Conditional minWidth so the user can still compress the - // traffic card down when no inspector is showing, but the - // card auto-grows to fit table+inspector the moment a - // flow gets selected. Below 700pt with the inspector - // open, SwiftUI rendered the inner HSplitView wider - // than the Card frame, leaking past the rounded - // clipShape — borders disappeared, rows got clipped - // past the right edge, scroll + hit testing broke. - .frame(minWidth: state.selectedFlowID == nil ? 380 : 700) - // Right padding on the trailing edge of the traffic card - // gives the visible gap between cards. HSplitView itself - // owns a 1pt system divider; the padding pushes it off - // the card outline so the two cards read as separate. - .padding(.trailing, 6) - - Card { - AgentPanel() + .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(minWidth: 340, idealWidth: 380) - .padding(.leading, 6) } - .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -118,6 +142,65 @@ struct Card: View { } } +// 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 { From a8dbbdfff79be1ad393d4c148eee34efb4cf1f0d Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 06:12:23 +0200 Subject: [PATCH 37/45] feat(macos): inline filter chips + custom text filter inside the traffic card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Filter button surfaced everything through a SwiftUI Menu which made multi-toggle filtering tedious (close menu to see the table, reopen to flip the next chip). Bring filters back inline — the user asked for the resource-kind chips like before, plus matching chip rows for methods and statuses, and a search input that isn't the system .searchable bar. TrafficFilterBar (new, lives between the card header and the row list): - Custom text filter row — an NSTextField wrapped via NSViewRepresentable so we don't ship the SwiftUI default search bar. Mag-glass icon on the left, "xmark.circle.fill" clear affordance on the right when there's content. Background is Theme.input behind a 7pt rounded rectangle, matching the agent composer's text well. Esc inside the field clears the search. - ResourceKindRow — horizontal scroll of chips, one per TrafficFilter.ResourceKind. Tint defaults to Theme.textPrimary. - MethodRow — same layout, but each chip is tinted with its method color (GET blue, POST green, PUT/PATCH orange, DELETE red, CONNECT purple) so selected method chips read at a glance. Only renders when state.store.methodOptions has at least one method (avoids an empty scroll strip on a fresh capture). - StatusRow — same again, tinted per status bucket: 1xx neutral, 2xx success green, 3xx blue, 4xx orange, 5xx red. FilterChip (new): reusable chip with a `tint` parameter. Selected state paints `tint.opacity(0.18)` background with a `tint.opacity(0.5)` stroke; idle state is transparent with a Theme.border stroke; hover nudges the background to white@5%. Foreground flips to the tint when selected so the chips read as colored pills, not generic toggles. TrafficListHeader cleanup: - Drops the old FilterButton menu — its job is now done by the inline chips + text filter. - Adds ResetFiltersButton next to DeleteAllButton, but only when at least one filter (search, kinds, methods, statuses, hosts, onlyErrors) is active. Same 22×22 round Theme.elevated button style as the trash and "+" buttons across the app. - DeleteAllButton stays untouched. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/UI/TrafficListView.swift | 306 +++++++++++++----- 1 file changed, 232 insertions(+), 74 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index e3453a3..e94d515 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -16,6 +16,7 @@ struct TrafficListView: View { visibleCount: filteredFlows.count, visibleIDs: filteredFlows.map(\.id) ) + TrafficFilterBar() ThinDivider() if state.store.flows.isEmpty { EmptyTrafficState() @@ -53,7 +54,9 @@ private struct TrafficListHeader: View { .padding(.vertical, 1) .background(Theme.elevated, in: Capsule()) Spacer() - FilterButton() + if hasActiveFilters { + ResetFiltersButton() + } DeleteAllButton() } // Same horizontal padding as TrafficRow so the header select-all @@ -61,101 +64,256 @@ private struct TrafficListHeader: View { .padding(.horizontal, 12) .frame(height: 44) } + + private var hasActiveFilters: Bool { + let f = state.filter + return !f.search.isEmpty + || f.onlyErrors + || !f.resourceKinds.isEmpty + || !f.methods.isEmpty + || !f.statusBuckets.isEmpty + || !f.hosts.isEmpty + } } -// MARK: - Filter + delete-all actions +// MARK: - Filter bar (text + chip rows) -private struct FilterButton: View { +private struct TrafficFilterBar: View { @Environment(AppState.self) private var state var body: some View { @Bindable var bindable = state - Menu { - Toggle(isOn: $bindable.filter.onlyErrors) { - Label("Errors only", systemImage: "exclamationmark.octagon") + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Theme.textTertiary) + TrafficSearchField(text: $bindable.filter.search) + .frame(maxWidth: .infinity) + .frame(height: 16) + if !state.filter.search.isEmpty { + Button { + state.filter.search = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(Theme.textTertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Theme.input, in: RoundedRectangle(cornerRadius: 7)) + + ResourceKindRow(selection: $bindable.filter.resourceKinds) + if !state.store.methodOptions.isEmpty { + MethodRow(options: state.store.methodOptions, selection: $bindable.filter.methods) } - Section("Type") { + StatusRow(selection: $bindable.filter.statusBuckets) + } + .padding(.horizontal, 12) + .padding(.bottom, 10) + } +} + +private struct TrafficSearchField: NSViewRepresentable { + @Binding var text: String + + 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: 12) + field.textColor = .white + field.placeholderAttributedString = NSAttributedString( + string: "Filter by host, path, method…", + attributes: [ + .font: NSFont.systemFont(ofSize: 12), + .foregroundColor: NSColor.white.withAlphaComponent(0.32), + ] + ) + field.stringValue = text + field.appearance = NSAppearance(named: .darkAqua) + field.cell?.usesSingleLineMode = true + field.cell?.wraps = false + field.cell?.isScrollable = true + 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: TrafficSearchField + + init(_ parent: TrafficSearchField) { 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.cancelOperation(_:)) { + parent.text = "" + return true + } + return false + } + } +} + +private struct ResourceKindRow: View { + @Binding var selection: Set + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { ForEach(TrafficFilter.ResourceKind.allCases) { kind in - Toggle(isOn: bindingForResource(kind)) { Text(kind.rawValue) } + FilterChip( + title: kind.rawValue, + isSelected: selection.contains(kind) + ) { + if selection.contains(kind) { selection.remove(kind) } + else { selection.insert(kind) } + } } } - Section("Method") { - ForEach(state.store.methodOptions, id: \.self) { method in - Toggle(isOn: bindingForMethod(method)) { Text(method) } + } + } +} + +private struct MethodRow: View { + let options: [String] + @Binding var selection: Set + + var body: some View { + if options.isEmpty { + EmptyView() + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(options, id: \.self) { method in + FilterChip( + title: method, + isSelected: selection.contains(method), + tint: methodTint(method) + ) { + if selection.contains(method) { selection.remove(method) } + else { selection.insert(method) } + } + } } } - Section("Status") { + } + } + + private func methodTint(_ 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 + } + } +} + +private struct StatusRow: View { + @Binding var selection: Set + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { ForEach(TrafficFilter.StatusBucket.allCases) { bucket in - Toggle(isOn: bindingForBucket(bucket)) { Text(bucket.rawValue) } - } - } - if activeFilterCount > 0 { - Divider() - Button("Reset filters", role: .destructive) { - bindable.filter = TrafficFilter() + FilterChip( + title: bucket.rawValue, + isSelected: selection.contains(bucket), + tint: statusTint(bucket) + ) { + if selection.contains(bucket) { selection.remove(bucket) } + else { selection.insert(bucket) } + } } } - } label: { - ZStack(alignment: .topTrailing) { - Image(systemName: "line.3.horizontal.decrease") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 22, height: 22) - .background(Theme.elevated, in: Circle()) - if activeFilterCount > 0 { - Circle() - .fill(Theme.success) - .frame(width: 6, height: 6) - .offset(x: 1, y: -1) + } + } + + private func statusTint(_ bucket: TrafficFilter.StatusBucket) -> Color { + switch bucket { + case .informational: return Theme.textSecondary + case .success: return Theme.success + case .redirect: return Theme.methodGet + case .clientError: return Theme.methodPut + case .serverError: return Theme.danger + } + } +} + +private struct FilterChip: View { + let title: String + let isSelected: Bool + var tint: Color = Theme.textPrimary + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(foreground) + .padding(.horizontal, 9) + .padding(.vertical, 3) + .background(background, in: Capsule()) + .overlay { + Capsule().stroke(borderColor, lineWidth: 1) } - } } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .help(activeFilterCount > 0 - ? "Filter traffic · \(activeFilterCount) active" - : "Filter traffic") - } - - private var activeFilterCount: Int { - var count = 0 - if state.filter.onlyErrors { count += 1 } - count += state.filter.resourceKinds.count - count += state.filter.methods.count - count += state.filter.statusBuckets.count - count += state.filter.hosts.count - return count - } - - private func bindingForResource(_ kind: TrafficFilter.ResourceKind) -> Binding { - Binding( - get: { state.filter.resourceKinds.contains(kind) }, - set: { isOn in - if isOn { state.filter.resourceKinds.insert(kind) } - else { state.filter.resourceKinds.remove(kind) } - } - ) + .buttonStyle(.plain) + .onHover { isHovering = $0 } } - private func bindingForMethod(_ method: String) -> Binding { - Binding( - get: { state.filter.methods.contains(method) }, - set: { isOn in - if isOn { state.filter.methods.insert(method) } - else { state.filter.methods.remove(method) } - } - ) + private var foreground: Color { + isSelected ? tint : Theme.textSecondary } - private func bindingForBucket(_ bucket: TrafficFilter.StatusBucket) -> Binding { - Binding( - get: { state.filter.statusBuckets.contains(bucket) }, - set: { isOn in - if isOn { state.filter.statusBuckets.insert(bucket) } - else { state.filter.statusBuckets.remove(bucket) } - } - ) + private var background: Color { + if isSelected { return tint.opacity(0.18) } + if isHovering { return Color.white.opacity(0.05) } + return Color.clear + } + + private var borderColor: Color { + if isSelected { return tint.opacity(0.5) } + return Theme.border + } +} + +private struct ResetFiltersButton: View { + @Environment(AppState.self) private var state + + var body: some View { + Button { + state.filter = TrafficFilter() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + } + .buttonStyle(.plain) + .help("Reset all filters") } } From 7a1f3bc597222d9e52295f64bbb1ebfded0bc879 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 06:15:48 +0200 Subject: [PATCH 38/45] refactor(macos): move filter chips back inside a single popover button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline filter bar (text input + three chip rows under the header) read as too much chrome — three horizontally-scrolling chip strips plus a search field plus the row list itself, all stacked. Collapse the whole thing behind one filter button. TrafficListView body drops the always-visible TrafficFilterBar. The filter button in the header opens a SwiftUI popover containing the same surface, just hidden by default: FilterButton (header): - Same 22×22 round Theme.elevated affordance as DeleteAllButton. - `line.3.horizontal.decrease` glyph. - Theme.success dot in the top-right when at least one filter is active, so the user can see the state without opening the popover. The hasActiveFilters check covers search, only-errors, resource kinds, methods, status buckets, and host inclusions. - Tap toggles the popover, anchored to the top of the button. - Drops the inline ResetFiltersButton — reset lives inside the popover now (only rendered when at least one filter is active). FilterPopoverContent: - 320pt wide popover with 14pt padding. - Custom text filter row at the top (still the NSTextField wrapper, not SwiftUI's .searchable) on Theme.input rounded background with the mag-glass icon and a clear button. - Three sections — Type, Method, Status — each labeled with a small uppercased Theme.textTertiary header (FilterSectionLabel) above the corresponding chip row. Method section is skipped entirely when no traffic has been captured yet. - Errors-only toggle as a SwiftUI Toggle with .switch style tinted Theme.success. Sits as its own row under the chip groups. - Reset footer (Theme.border divider + counterclockwise icon + "Reset filters" label) appears under all sections only when there is something to reset. TrafficSearchField, ResourceKindRow, MethodRow, StatusRow, and FilterChip are unchanged — they were already the right primitives, they just moved from the inline bar to the popover content view. TrafficFilterBar struct itself is gone since the popover is its replacement. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/UI/TrafficListView.swift | 132 ++++++++++++++---- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index e94d515..1a38fa1 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -16,7 +16,6 @@ struct TrafficListView: View { visibleCount: filteredFlows.count, visibleIDs: filteredFlows.map(\.id) ) - TrafficFilterBar() ThinDivider() if state.store.flows.isEmpty { EmptyTrafficState() @@ -54,9 +53,7 @@ private struct TrafficListHeader: View { .padding(.vertical, 1) .background(Theme.elevated, in: Capsule()) Spacer() - if hasActiveFilters { - ResetFiltersButton() - } + FilterButton(hasActiveFilters: hasActiveFilters) DeleteAllButton() } // Same horizontal padding as TrafficRow so the header select-all @@ -76,14 +73,46 @@ private struct TrafficListHeader: View { } } -// MARK: - Filter bar (text + chip rows) +// MARK: - Filter button + popover + +private struct FilterButton: View { + let hasActiveFilters: Bool + @State private var isShowing = false + + var body: some View { + Button { + isShowing.toggle() + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 22, height: 22) + .background(Theme.elevated, in: Circle()) + if hasActiveFilters { + Circle() + .fill(Theme.success) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } + } + .buttonStyle(.plain) + .help(hasActiveFilters ? "Filter traffic · active" : "Filter traffic") + .popover(isPresented: $isShowing, arrowEdge: .top) { + FilterPopoverContent() + .frame(width: 320) + } + } +} -private struct TrafficFilterBar: View { +private struct FilterPopoverContent: View { @Environment(AppState.self) private var state var body: some View { @Bindable var bindable = state - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { + // Text filter HStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.system(size: 11, weight: .medium)) @@ -106,17 +135,78 @@ private struct TrafficFilterBar: View { .padding(.vertical, 6) .background(Theme.input, in: RoundedRectangle(cornerRadius: 7)) - ResourceKindRow(selection: $bindable.filter.resourceKinds) + VStack(alignment: .leading, spacing: 10) { + FilterSectionLabel("Type") + ResourceKindRow(selection: $bindable.filter.resourceKinds) + } + if !state.store.methodOptions.isEmpty { - MethodRow(options: state.store.methodOptions, selection: $bindable.filter.methods) + VStack(alignment: .leading, spacing: 10) { + FilterSectionLabel("Method") + MethodRow(options: state.store.methodOptions, selection: $bindable.filter.methods) + } + } + + VStack(alignment: .leading, spacing: 10) { + FilterSectionLabel("Status") + StatusRow(selection: $bindable.filter.statusBuckets) + } + + Toggle(isOn: $bindable.filter.onlyErrors) { + Text("Errors only") + .font(.callout) + .foregroundStyle(Theme.textPrimary) + } + .toggleStyle(.switch) + .controlSize(.small) + .tint(Theme.success) + + if hasActiveFilters { + Divider().overlay(Theme.border) + Button { + state.filter = TrafficFilter() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 10, weight: .semibold)) + Text("Reset filters") + .font(.callout) + } + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) } - StatusRow(selection: $bindable.filter.statusBuckets) } - .padding(.horizontal, 12) - .padding(.bottom, 10) + .padding(14) + } + + private var hasActiveFilters: Bool { + let f = state.filter + return !f.search.isEmpty + || f.onlyErrors + || !f.resourceKinds.isEmpty + || !f.methods.isEmpty + || !f.statusBuckets.isEmpty + || !f.hosts.isEmpty + } +} + +private struct FilterSectionLabel: View { + let text: String + + init(_ text: String) { self.text = text } + + var body: some View { + Text(text.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .tracking(0.6) } } +// MARK: - Text filter (custom NSTextField — not SwiftUI's default searchable) + private struct TrafficSearchField: NSViewRepresentable { @Binding var text: String @@ -299,24 +389,6 @@ private struct FilterChip: View { } } -private struct ResetFiltersButton: View { - @Environment(AppState.self) private var state - - var body: some View { - Button { - state.filter = TrafficFilter() - } label: { - Image(systemName: "arrow.counterclockwise") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Theme.textPrimary) - .frame(width: 22, height: 22) - .background(Theme.elevated, in: Circle()) - } - .buttonStyle(.plain) - .help("Reset all filters") - } -} - private struct DeleteAllButton: View { @Environment(AppState.self) private var state From f1d3d494857f4f296e6a836da38c25487fbefb41 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 14:40:38 +0200 Subject: [PATCH 39/45] feat(macos): show request duration next to the timestamp on each row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the response time inline so the user can spot slow requests without opening the inspector. The duration trails the start timestamp in parentheses, monospaced, slightly dimmer than the timestamp itself to keep the timestamp as the primary glance value: 200 12:34:56 (234ms) Format: - Sub-second responses render as "234ms" - Anything ≥ 1s renders as "1.23s" - Nothing shown while the response is still streaming (flow.finishedAt is nil) — the row simply ends after the timestamp until the response lands. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/UI/TrafficListView.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/macos/Sources/ReverseAPI/UI/TrafficListView.swift b/macos/Sources/ReverseAPI/UI/TrafficListView.swift index 1a38fa1..ff1dfad 100644 --- a/macos/Sources/ReverseAPI/UI/TrafficListView.swift +++ b/macos/Sources/ReverseAPI/UI/TrafficListView.swift @@ -525,9 +525,15 @@ private struct TrafficRow: View { VStack(alignment: .trailing, spacing: 1) { StatusBadge(status: flow.responseStatus, error: flow.error) - Text(flow.startedAt, format: .dateTime.hour().minute().second()) - .font(.system(size: 10, weight: .regular, design: .monospaced)) - .foregroundStyle(Theme.textTertiary) + HStack(spacing: 4) { + Text(flow.startedAt, format: .dateTime.hour().minute().second()) + if let duration = durationLabel { + Text("(\(duration))") + .foregroundStyle(Theme.textTertiary.opacity(0.8)) + } + } + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) } } .padding(.horizontal, 12) @@ -562,6 +568,17 @@ private struct TrafficRow: View { if isHovering { return Color.white.opacity(0.03) } return Color.clear } + + /// Returns a compact human-readable duration (e.g. "234ms", "1.2s") for + /// the row's request, or nil while the response is still in flight. + private var durationLabel: String? { + guard let finished = flow.finishedAt else { return nil } + let interval = finished.timeIntervalSince(flow.startedAt) + if interval < 1 { + return String(format: "%.0fms", interval * 1000) + } + return String(format: "%.2fs", interval) + } } private struct AgentCheckbox: View { From d3eea633ac9796fd0f9c60daa50e348943e08d8d Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 14:51:18 +0200 Subject: [PATCH 40/45] =?UTF-8?q?fix(macos):=20harden=20AgentSidecar=20por?= =?UTF-8?q?t=20discovery=20=E2=80=94=20require=20newline,=20recheck=20live?= =?UTF-8?q?ness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1 bugs in waitForBoundPort + takeLine surfaced in PR review: - takeLine matched the announcement line even when the trailing newline hadn't arrived yet, because `text.split(separator: "\n")` happily returns the final trailing fragment as its own segment. On a fragmented stdout read that's "RAE_AGENT_LISTENING:5" (the client still flushing the rest of "54321\n"), so `launch()` would hand back port 5 and the WebSocket client would connect to the wrong process. Now we only accept the prefixed line when it's truly complete: either `text` ends with `\n`, or the line sits before another segment. - After parsing the port, `launch()` returned immediately. If the sidecar announced the port and then crashed one runloop tick later (Python exception during `serve()` accept loop, etc.), callers connected a WebSocket to a dead pid and saw confusing "notConnected" errors with no signal of what went wrong. Sleep 20ms then re-check `process.isRunning`; if the process exited between the announcement and the recheck, surface the stderr snapshot (or processDied) so the UI gets the real cause. Co-Authored-By: Claude Opus 4.7 --- .../ReverseAPI/Agent/AgentSidecar.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift index 2d8d48e..c66cc84 100644 --- a/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift +++ b/macos/Sources/ReverseAPI/Agent/AgentSidecar.swift @@ -141,6 +141,19 @@ actor AgentSidecar { 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 { @@ -166,12 +179,24 @@ private final class AsyncStreamBuffer: @unchecked Sendable { 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 } - for line in text.split(separator: "\n") { - if line.hasPrefix(prefix) { return String(line) } + 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 } From e2c9c2250f9730630c5f617357951efd1a097019 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 14:51:29 +0200 Subject: [PATCH 41/45] fix(agent): emit TextBlocks when StreamEvent SDK fallback is in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `claude_agent_sdk.types.StreamEvent` isn't exposed by the installed SDK build, the import fallback substitutes a sentinel class so isinstance(...) checks never match. Previously the AssistantMessage handler still unconditionally `continue`d on every TextBlock — the rationale being "the text already came through as a StreamEvent chunk" — but with the stub class, no StreamEvents ever arrive, so the user got tool calls and the completion event but never the assistant's reply. Track whether streaming was actually wired up (`_STREAMING_ENABLED`) and only skip TextBlocks when it is. In the fallback path, emit the whole TextBlock as `assistant_text` so the UI still renders the reply — just bulk instead of streaming, matching the pre-streaming behavior. Co-Authored-By: Claude Opus 4.7 --- backend/rae_agent/session.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/rae_agent/session.py b/backend/rae_agent/session.py index f5de7f2..4f2e46c 100644 --- a/backend/rae_agent/session.py +++ b/backend/rae_agent/session.py @@ -24,12 +24,17 @@ # 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.""" + 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 @@ -146,9 +151,15 @@ async def _translate( if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, TextBlock): - # Text already streamed via StreamEvent chunks above — skip - # to avoid emitting the same body twice. - continue + # 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) From b29f248dfb9fe994d9845542e258d17592463f03 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 14:51:44 +0200 Subject: [PATCH 42/45] fix(macos): keep in-memory + persisted flow state in sync on delete/clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related P2 issues around flow deletion: - FlowStore.delete(ids:) caught the database write failure, logged it, and then still purged the rows from the in-memory list. The on-disk store and the live `flows` array could drift apart, and the user could "delete" rows that reappeared on next launch. Return after the catch so failed deletes leave both sides untouched and the user can retry. - AppState.clearFlows wiped the store + selectedFlowID but never reset agentSelection. Stale UUIDs hung around, leaving the agent panel in explicit-selection mode (header reading "N selected") with nothing matchable in store.flows — so the next send went out with zero flows attached. Wipe agentSelection alongside selectedFlowID after a successful store.clear(). Co-Authored-By: Claude Opus 4.7 --- macos/Sources/ReverseAPI/App/AppState.swift | 4 ++++ macos/Sources/ReverseAPI/Storage/FlowStore.swift | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/ReverseAPI/App/AppState.swift b/macos/Sources/ReverseAPI/App/AppState.swift index 3a1b37f..526de81 100644 --- a/macos/Sources/ReverseAPI/App/AppState.swift +++ b/macos/Sources/ReverseAPI/App/AppState.swift @@ -242,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/Storage/FlowStore.swift b/macos/Sources/ReverseAPI/Storage/FlowStore.swift index 66bf3af..946126e 100644 --- a/macos/Sources/ReverseAPI/Storage/FlowStore.swift +++ b/macos/Sources/ReverseAPI/Storage/FlowStore.swift @@ -56,7 +56,9 @@ public final class FlowStore { } /// Remove the given flows from both the in-memory list and the database. - /// Safe to call with empty/unknown ids. + /// 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() @@ -72,6 +74,7 @@ public final class FlowStore { }.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) } From 73c00468880afaa29b5c0582485d4c64d3629ad0 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Wed, 20 May 2026 14:51:57 +0200 Subject: [PATCH 43/45] =?UTF-8?q?fix(macos):=20inspector=20=E2=80=94=20nil?= =?UTF-8?q?=20HTML=20baseURL=20+=20don't=20gate=20text=20copy=20on=20conte?= =?UTF-8?q?nt-type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 inspector issues from PR review: - The HTML Preview tab was passing the flow's original URL as the WKWebView baseURL, so when the user inspected a captured HTML response WebKit would resolve ``, ``, `