feat(cursor): add independent Cursor protocol debugging proxy and build tasks

- Introduced a new task for running the Cursor protocol debugging proxy.
- Added a build task for the Cursor protocol debugging proxy.
- Updated documentation to include usage instructions for the debugging tool.
This commit is contained in:
leokun
2026-08-03 00:19:18 +08:00
parent 5742073be5
commit 1854a3ab9a
15 changed files with 3006 additions and 0 deletions
+23
View File
@@ -23,6 +23,29 @@ description: 本地模式实现指南
客户端是:/Users/leokun/Library/Application\ Support/Cursor 客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js 客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照 ## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。 - 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
+10
View File
@@ -178,6 +178,16 @@ tasks:
cmds: cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install: ads:install:
summary: 安装广告页依赖 summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page' dir: '{{.TASKFILE_DIR}}/ads-page'
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
proxydebugger "cursor/cursor-proxy-debugger"
"github.com/pkg/browser"
)
func main() {
config := proxydebugger.Config{}
openBrowser := true
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := proxydebugger.New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor 协议调试代理已启动\n")
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
fmt.Printf("调试界面: %s\n", server.UIURL())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Close(shutdownContext); err != nil {
log.Printf("关闭调试代理失败:%v", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
## Start
Run the following command from the repository root:
```bash
go run ./cmd/cursor-proxy-debugger
```
Default addresses:
- HTTP/HTTPS proxy: `127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9091`
- MITM target: `api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
2. Open Cursor's Network settings and enable HTTP/1.1.
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
## Build
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## Options
```text
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
-target-host Host to decrypt and capture; default: api2.cursor.sh
-max-exchanges Maximum number of exchanges retained in memory; default: 200
-open Open the browser after startup; default: true
```
## Data Handling
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
- Captured traffic is stored only in process memory and is discarded when the process exits.
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
+58
View File
@@ -0,0 +1,58 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
## 启动
在仓库根目录运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认监听:
- HTTP/HTTPS 代理:`127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9091`
- MITM 目标:`api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
## 构建
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## 参数
```text
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
-target-host 需要解密的目标主机,默认 api2.cursor.sh
-max-exchanges 内存中保留的最大请求数,默认 200
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。
- `Authorization``Cookie``Set-Cookie` 等 HTTP 头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
+88
View File
@@ -0,0 +1,88 @@
package proxydebugger
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
+236
View File
@@ -0,0 +1,236 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
agentprotocol "cursor/internal/backend/agent/protocol"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const maxConnectFrameBytes = 64 << 20
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
var message proto.Message
switch path {
case "/aiserver.v1.BidiService/BidiAppend":
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, marshalErr
default:
message = nil
}
if message == nil {
return "", "", "", nil
}
return marshalProtoJSON(message), activeOneofName(message), "", nil
}
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
return nil
}
}
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
+421
View File
@@ -0,0 +1,421 @@
package proxydebugger
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"cursor/internal/certs"
"github.com/elazarl/goproxy"
)
type exchangeContext struct {
id string
}
// Server runs the HTTPS debugging proxy and its local web UI.
type Server struct {
config Config
certManager *certs.Manager
store *exchangeStore
counter atomic.Uint64
proxyServer *http.Server
uiServer *http.Server
proxyLn net.Listener
uiLn net.Listener
runMu sync.Mutex
}
// New creates a standalone Cursor protocol debugger.
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.UIAddr); err != nil {
return nil, err
}
manager, err := certs.NewEmbeddedManager()
if err != nil {
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
}
server := &Server{
config: config,
certManager: manager,
store: newExchangeStore(config.MaxExchanges),
}
proxyHandler, err := server.newProxyHandler()
if err != nil {
return nil, err
}
server.proxyServer = &http.Server{
Handler: proxyHandler,
ErrorLog: log.New(io.Discard, "", 0),
}
server.uiServer = &http.Server{Handler: server.newUIHandler()}
return server, nil
}
// Start starts both listeners without modifying Cursor or system proxy settings.
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.proxyLn != nil || server.uiLn != nil {
return errors.New("调试代理已经启动")
}
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
if err != nil {
return fmt.Errorf("启动代理监听失败:%w", err)
}
uiListener, err := net.Listen("tcp", server.config.UIAddr)
if err != nil {
_ = proxyListener.Close()
return fmt.Errorf("启动调试界面失败:%w", err)
}
server.proxyLn = proxyListener
server.uiLn = uiListener
go func() { _ = server.proxyServer.Serve(proxyListener) }()
go func() { _ = server.uiServer.Serve(uiListener) }()
return nil
}
// Close stops both listeners.
func (server *Server) Close(ctx context.Context) error {
server.runMu.Lock()
proxyServer := server.proxyServer
uiServer := server.uiServer
server.proxyLn = nil
server.uiLn = nil
server.runMu.Unlock()
var errorsList []error
if proxyServer != nil {
if err := proxyServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
if uiServer != nil {
if err := uiServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
func (server *Server) UIAddr() string { return server.config.UIAddr }
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = false
proxy.AllowHTTP2 = true
proxy.Logger = log.New(io.Discard, "", 0)
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
id := exchangeID(context)
if id == "" {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = connectionErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
proxy.Tr = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
caCertificate, err := server.certManager.CATLSCertificate()
if err != nil {
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
}
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
mitmAction := &goproxy.ConnectAction{
Action: goproxy.ConnectMitm,
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
return baseTLSConfig(host, context)
},
}
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if server.matchesTargetHost(host) {
return mitmAction, host
}
return goproxy.OkConnect, host
})
proxy.OnRequest().DoFunc(server.captureRequest)
proxy.OnResponse().DoFunc(server.captureResponse)
return proxy, nil
}
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
return request, nil
}
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: request.URL.String(),
Host: request.URL.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: request.Header.Get("Content-Type"),
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
context.UserData = exchangeContext{id: id}
if request.Body == nil {
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
return request, nil
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"aiserver.v1.BidiRequestId",
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
},
)
return request, nil
}
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
id := exchangeID(context)
if id == "" || response == nil {
return response
}
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = response.Header.Get("Content-Type")
exchange.Response.ContentCodec = responseContentCodec(response.Header)
})
if response.Body == nil {
server.finishResponseBody(id, nil, 0, false, nil)
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"agent.v1.AgentServerMessage",
response.Header.Get("Connect-Content-Encoding"),
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, captured, size, truncated, readErr)
},
)
return response
}
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
func requestContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func responseContentCodec(headers http.Header) string {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
if request == nil {
return false
}
host := request.Host
if request.URL != nil && request.URL.Host != "" {
host = request.URL.Host
}
return server.matchesTargetHost(host)
}
func (server *Server) matchesTargetHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
return host == target
}
func exchangeID(context *goproxy.ProxyCtx) string {
if context == nil {
return ""
}
value, ok := context.UserData.(exchangeContext)
if !ok {
return ""
}
return value.id
}
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试界面监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试界面只能监听本机回环地址")
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package proxydebugger
import (
"sort"
"sync"
"time"
)
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
}
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.mu.Lock()
if exchange := store.exchanges[id]; exchange != nil {
apply(exchange)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
func (store *exchangeStore) summaries() []ExchangeSummary {
store.mu.RLock()
defer store.mu.RUnlock()
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result
}
func (store *exchangeStore) get(id string) (Exchange, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange == nil {
return Exchange{}, false
}
return cloneExchange(*exchange), true
}
func (store *exchangeStore) clear() {
store.mu.Lock()
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "cleared"})
}
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
+110
View File
@@ -0,0 +1,110 @@
package proxydebugger
import "time"
const (
defaultProxyAddr = "127.0.0.1:9090"
defaultUIAddr = "127.0.0.1:9091"
defaultTargetHost = "api2.cursor.sh"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
)
// Config controls the standalone proxy debugger.
type Config struct {
ProxyAddr string
UIAddr string
TargetHost string
MaxExchanges int
MaxCaptureBytes int
MaxFrames int
}
func (config Config) normalized() Config {
if config.ProxyAddr == "" {
config.ProxyAddr = defaultProxyAddr
}
if config.UIAddr == "" {
config.UIAddr = defaultUIAddr
}
if config.TargetHost == "" {
config.TargetHost = defaultTargetHost
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
return config
}
// ExchangeSummary is the compact request-list representation.
type ExchangeSummary struct {
ID string `json:"id"`
StartedAt time.Time `json:"startedAt"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Path string `json:"path"`
Status int `json:"status"`
State string `json:"state"`
DurationMS int64 `json:"durationMs"`
RequestBytes int64 `json:"requestBytes"`
ResponseBytes int64 `json:"responseBytes"`
RequestID string `json:"requestId,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
ResponseKind string `json:"responseKind,omitempty"`
FrameCount int `json:"frameCount"`
Error string `json:"error,omitempty"`
}
// Exchange contains the request and response detail shown by the debugger.
type Exchange struct {
ExchangeSummary
Request Payload `json:"request"`
Response Payload `json:"response"`
}
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
type Payload struct {
Headers []Header `json:"headers"`
ContentType string `json:"contentType,omitempty"`
ContentCodec string `json:"contentCodec,omitempty"`
Size int64 `json:"size"`
RawHex string `json:"rawHex,omitempty"`
RawTruncated bool `json:"rawTruncated,omitempty"`
DecodedJSON string `json:"decodedJson,omitempty"`
DecodeError string `json:"decodeError,omitempty"`
Frames []FrameView `json:"frames,omitempty"`
}
// Header is a stable, sorted HTTP header pair.
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// FrameView describes one Connect streaming envelope.
type FrameView struct {
Index int `json:"index"`
Flags uint8 `json:"flags"`
Length int `json:"length"`
Compressed bool `json:"compressed"`
EndStream bool `json:"endStream"`
Kind string `json:"kind,omitempty"`
MessageType string `json:"messageType,omitempty"`
RequestID string `json:"requestId,omitempty"`
JSON string `json:"json,omitempty"`
RawHex string `json:"rawHex,omitempty"`
Error string `json:"error,omitempty"`
}
type storeEvent struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
+112
View File
@@ -0,0 +1,112 @@
package proxydebugger
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
"cursor/internal/certs"
)
//go:embed web/*
var webAssets embed.FS
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{
"proxyAddr": server.config.ProxyAddr,
"uiAddr": server.config.UIAddr,
"targetHost": server.config.TargetHost,
"running": true,
})
}
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.store.summaries())
}
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok := server.store.get(id)
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
server.store.clear()
writer.WriteHeader(http.StatusNoContent)
}
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
_, _ = writer.Write(certs.EmbeddedCACertPEM())
}
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
next.ServeHTTP(writer, request)
})
}
+452
View File
@@ -0,0 +1,452 @@
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
const state = {
status: null,
exchanges: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
endpoint: "all",
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "frames",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
proxyAddress: document.querySelector("#proxy-address"),
targetHost: document.querySelector("#target-host"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
endpointFilter: document.querySelector("#endpoint-filter"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("/api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
elements.targetHost.textContent = state.status.targetHost;
}
async function refreshList() {
state.exchanges = await fetchJSON("/api/exchanges");
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("/api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
elements.requestList.innerHTML = exchanges
.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
}
function renderDetailError(error) {
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(payload, tab) {
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
if (tab === "headers") return renderHeaders(payload.headers);
if (tab === "frames") return renderFrames(payload.frames);
if (tab === "raw") {
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
}
if (payload.decodedJson) {
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
}
if (payload.frames?.length) return renderFrames(payload.frames);
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function renderFrames(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
return `<div class="frame-list">${items
.map((frame) => {
const kind = frame.kind || frame.messageType || t("notices.unknown");
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
const content = frame.json
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
<summary>
<span class="frame-index">#${frame.index}</span>
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
<span class="frame-size">${formatBytes(frame.length)}</span>
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
</summary>
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
</details>`;
})
.join("")}</div>`;
}
function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
function currentCopyText(side) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
function renderPauseState() {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState();
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState();
renderList();
renderTrafficSummary();
renderDetail();
}
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("/api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
+178
View File
@@ -0,0 +1,178 @@
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "代理运行中",
"status.stopped": "代理已停止",
"actions.downloadCA": "下载代理 CA 证书",
"actions.caCertificate": "CA 证书",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Proxy running",
"status.stopped": "Proxy stopped",
"actions.downloadCA": "Download proxy CA certificate",
"actions.caCertificate": "CA Certificate",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="proxy-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
</div>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="target-host"></span>
</footer>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+921
View File
@@ -0,0 +1,921 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#target-host {
display: none;
}
}