diff --git a/CHANGELOG.md b/CHANGELOG.md index d91c7c2..d1f9212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-08-21 + +### Added + +- Durable SQLite sessions in `~/.config/jsonrpc-debugger/sqlite.db`, with a session picker and paged history over the agent API. +- `debugger.listSessions`, `debugger.createSession`, and `debugger.selectSession` control methods. +- A `Ctrl-B` command prefix and `Ctrl-B ?` keybind help. +- Persistent line annotations with per-ID deletion over the agent API. +- Amber scrollbar markers and `Ctrl-B a` annotation prompts for visual selections. +- Inline notes for single lines and diagnostic rows for multiline ranges. +- Named session prompts, `Ctrl-B R` rename, and `debugger.renameSession`. +- Focused-panel fullscreen with `Ctrl-B z` and agent control. + +### Changed + +- Global TUI commands now live behind `Ctrl-B`. Actions for a focused intercepted request remain direct. +- Line highlights are temporary references. Annotations persist independently in session history. +- Request filters match method names and IDs. + ## [0.2.0] - 2026-08-20 ### Added diff --git a/Cargo.lock b/Cargo.lock index c0df84c..7f495ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,10 +212,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.25" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -256,7 +257,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -377,7 +378,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -411,12 +412,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "flate2" version = "1.1.1" @@ -439,6 +458,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -571,7 +596,34 @@ checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", ] [[package]] @@ -800,7 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.3", ] [[package]] @@ -841,17 +893,18 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "jsonrpc-debugger" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -860,6 +913,7 @@ dependencies = [ "flate2", "ratatui", "reqwest", + "rusqlite", "serde", "serde_json", "tokio", @@ -874,6 +928,17 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -908,7 +973,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.3", ] [[package]] @@ -1044,7 +1109,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1117,7 +1182,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1281,6 +1346,31 @@ dependencies = [ "winreg", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.9.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1382,7 +1472,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1422,9 +1512,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -1487,6 +1577,18 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stability" version = "0.2.1" @@ -1494,7 +1596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1534,7 +1636,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.101", ] [[package]] @@ -1548,6 +1650,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "0.1.2" @@ -1562,7 +1675,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1605,7 +1718,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", ] [[package]] @@ -1616,7 +1738,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -1655,7 +1788,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1739,7 +1872,7 @@ dependencies = [ "log", "rand", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] @@ -1892,48 +2025,32 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1941,31 +2058,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 2.0.101", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -2186,7 +2303,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -2207,7 +2324,7 @@ checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -2227,7 +2344,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -2261,5 +2378,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] diff --git a/Cargo.toml b/Cargo.toml index a33d9dd..feb25e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jsonrpc-debugger" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = ["Shane Jonas "] description = "A terminal-based JSON-RPC debugger with interception capabilities" @@ -13,7 +13,7 @@ keywords = ["jsonrpc", "debugger", "proxy", "tui", "terminal"] categories = ["command-line-utilities", "development-tools::debugging", "network-programming"] exclude = [ ".github/*", - "*.md", + "CHANGELOG.md", "tests/*", ] @@ -31,3 +31,4 @@ clap = { version = "4.0", features = ["derive"] } warp = "0.3" uuid = { version = "1.17.0", features = ["v4"] } flate2 = "1.0" +rusqlite = { version = "0.40", features = ["bundled"] } diff --git a/README.md b/README.md index 167b9de..32434f0 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,35 @@ The TUI shows request history beside the selected request and response. It suppo | Select a line | Click its line number | | Select a line range | Click, then Shift-click | | Copy the focused panel as Markdown | `Enter` | -| Pause new requests | `p` | +| Open commands / keybinds | `Ctrl-B` / `Ctrl-B ?` | +| Fullscreen the focused panel | `Ctrl-B z` | +| Open saved sessions / start a new one | `Ctrl-B s` / `Ctrl-B n` | +| Rename the current session | `Ctrl-B R` | +| Annotate a Vim selection | `v`, select lines, then `Ctrl-B a` | +| Delete the focused annotation | `Ctrl-B d` | +| Pause new requests | `Ctrl-B p` | | Allow or block an intercepted request | `a` / `b` | | Edit a request body or headers | `e` / `h` | -| Create a request or custom response | `c` | -| Quit | `q` | +| Complete an intercepted request | `c` | +| Create a request | `Ctrl-B c` | +| Quit | `Ctrl-B q` or `Ctrl-C` | The request list copies as a Markdown table. Request bodies, responses, headers, and status copy as Markdown. The inline editor supports normal Vim motions and operators such as `w`, `b`, `e`, `cw`, `dw`, `dd`, `u`, and `p`. Save with `:w`; cancel with `:q!`. +History and line annotations survive restarts in `~/.config/jsonrpc-debugger/sqlite.db`. One-line notes sit beside their source line. Range notes sit below the selection. Amber scrollbar ticks show annotations above and below the current view. Set `XDG_CONFIG_HOME` or `JSONRPC_DEBUGGER_CONFIG_DIR` to move the database. + ## Let an agent drive it The control plane is itself a JSON-RPC 2.0 server. An agent can operate the debugger while you watch the same actions happen in the TUI. +Print the agent skill bundled with your installed version: + +```bash +jsonrpc-debugger --skill +``` + Ask the running debugger what it supports: ```bash @@ -87,19 +102,22 @@ curl http://127.0.0.1:8081 \ An agent can: - Read state, history, pending requests, and numbered panel content. +- List old sessions and page through their persistent history without changing the TUI. - Wait for revisions without polling. - Send requests through the debugger. - Select exchanges, focus panels, scroll, and highlight line ranges. +- Add persistent line annotations and remove them by ID. - Change the target or filter and control interception. -- Export a session or replay one without forwarding its requests. +- Create, select, or rename sessions. +- Export portable history or replay it without forwarding requests. -Line selections are shared. Click a suspicious response line, tell the agent to inspect the selected line, and it reads the same reference from `debugger.getState`. The agent can reveal another line or range and bring it into view for you. +Line selections are shared but temporary. Annotations stick to their exchange until a person presses `Ctrl-B d` or an agent removes one by ID. Highlights can move without erasing the notes around them. -Use the [`jsonrpc-debugger` agent skill](https://github.com/shanejonas/agent-skills/tree/main/skills/jsonrpc-debugger) for typed call and demo scripts. The complete API lives in [`openrpc.json`](openrpc.json) and is available at runtime through `rpc.discover`. +The complete API lives in [`openrpc.json`](openrpc.json) and is available at runtime through `rpc.discover`. ## Intercept requests -Press `p` or call `debugger.setPaused`. New requests wait in the debugger until a person or agent allows, blocks, edits, or completes them with a custom response. +Press `Ctrl-B p` or call `debugger.setPaused`. New requests wait in the debugger until a person or agent allows, blocks, edits, or completes them with a custom response. Those focused actions stay on direct keys because they only apply while a request is waiting. ## Develop diff --git a/openrpc.json b/openrpc.json index ce2bf29..385cc95 100644 --- a/openrpc.json +++ b/openrpc.json @@ -36,18 +36,55 @@ "name": "debugger.getPanel", "summary": "Read numbered request or response panel lines", "params": [ - { "name": "panel", "required": true, "schema": { "type": "string", "enum": ["request", "response"] } } + { "name": "panel", "required": true, "schema": { "type": "string", "enum": ["request", "response"] } }, + { "name": "exchangeIndex", "required": false, "schema": { "type": "integer", "minimum": 0 } }, + { "name": "tab", "required": false, "schema": { "type": "string", "enum": ["headers", "body"] } } ], "result": { "name": "result", "schema": { "type": "object" } } }, { "name": "debugger.getHistory", - "summary": "Read recent JSON-RPC exchanges", + "summary": "Read persistent JSON-RPC exchanges from the active or named session", "params": [ - { "name": "limit", "required": false, "schema": { "type": "integer", "minimum": 0, "maximum": 1000 } } + { "name": "limit", "required": false, "schema": { "type": "integer", "minimum": 0, "maximum": 1000 } }, + { "name": "sessionId", "required": false, "schema": { "type": "string", "minLength": 1 } }, + { "name": "before", "required": false, "schema": { "type": "integer", "minimum": 0 } } ], "result": { "name": "result", "schema": { "type": "array" } } }, + { + "name": "debugger.listSessions", + "summary": "List persistent debugger sessions newest first", + "params": [ + { "name": "limit", "required": false, "schema": { "type": "integer", "minimum": 0, "maximum": 1000 } } + ], + "result": { "name": "result", "schema": { "type": "array", "items": { "$ref": "#/components/schemas/SessionSummary" } } } + }, + { + "name": "debugger.createSession", + "summary": "Create and select an empty debugger session", + "params": [ + { "name": "name", "required": false, "schema": { "type": "string" } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, + { + "name": "debugger.selectSession", + "summary": "Select a persistent session in the TUI", + "params": [ + { "name": "sessionId", "required": true, "schema": { "type": "string", "minLength": 1 } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, + { + "name": "debugger.renameSession", + "summary": "Rename a persistent debugger session", + "params": [ + { "name": "sessionId", "required": true, "schema": { "type": "string", "minLength": 1 } }, + { "name": "name", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 80 } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, { "name": "debugger.exportSession", "summary": "Export portable debugger history", @@ -86,6 +123,14 @@ ], "result": { "name": "result", "schema": { "type": "object" } } }, + { + "name": "debugger.setFullscreen", + "summary": "Expand or restore the focused TUI panel", + "params": [ + { "name": "fullscreen", "required": true, "schema": { "type": "boolean" } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, { "name": "debugger.revealLines", "summary": "Focus and reveal highlighted request or response lines", @@ -102,6 +147,27 @@ "params": [], "result": { "name": "result", "schema": { "type": "object" } } }, + { + "name": "debugger.annotateLines", + "summary": "Persist an annotation without changing the visible TUI state", + "params": [ + { "name": "panel", "required": true, "schema": { "type": "string", "enum": ["request", "response"] } }, + { "name": "startLine", "required": true, "schema": { "type": "integer", "minimum": 1 } }, + { "name": "endLine", "required": false, "schema": { "type": "integer", "minimum": 1 } }, + { "name": "message", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 160 } }, + { "name": "exchangeIndex", "required": false, "schema": { "type": "integer", "minimum": 0 } }, + { "name": "tab", "required": false, "schema": { "type": "string", "enum": ["headers", "body"] } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, + { + "name": "debugger.removeAnnotation", + "summary": "Delete one annotation by ID", + "params": [ + { "name": "annotationId", "required": true, "schema": { "type": "string", "minLength": 1 } } + ], + "result": { "name": "result", "schema": { "type": "object" } } + }, { "name": "debugger.scrollPanel", "summary": "Scroll a TUI panel by a signed line count", @@ -121,7 +187,7 @@ }, { "name": "debugger.setFilter", - "summary": "Filter history by method name", + "summary": "Filter history by method name or request ID", "params": [ { "name": "text", "required": true, "schema": { "type": "string" } } ], @@ -156,6 +222,19 @@ ], "components": { "schemas": { + "SessionSummary": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "target", "createdAtMs", "updatedAtMs", "exchangeCount"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "target": { "type": "string" }, + "createdAtMs": { "type": "integer", "minimum": 0 }, + "updatedAtMs": { "type": "integer", "minimum": 0 }, + "exchangeCount": { "type": "integer", "minimum": 0 } + } + }, "Session": { "type": "object", "additionalProperties": false, diff --git a/skills/jsonrpc-debugger/SKILL.md b/skills/jsonrpc-debugger/SKILL.md new file mode 100644 index 0000000..035589c --- /dev/null +++ b/skills/jsonrpc-debugger/SKILL.md @@ -0,0 +1,109 @@ +--- +name: jsonrpc-debugger +description: Control a running jsonrpc-debugger TUI through its localhost JSON-RPC control plane. Use when an agent needs to inspect debugger state or durable history, send or intercept requests, drive visible focus and selections, annotate request or response lines, or demonstrate a JSON-RPC flow. +--- + +# JSON-RPC Debugger + +Drive the live debugger the user sees. Treat the TUI as a shared screen: preserve unrelated state and leave it usable. + +## Connect + +1. Find the existing debugger process and its proxy port. The control port defaults to the proxy port plus one unless `--control-port` overrides it. +2. Probe the control endpoint with `rpc.discover`. Do not confuse it with the proxy port. +3. Read `debugger.getState` before changing anything. Record its session, target, filter, focus, selection, annotations, mode, pending count, and revision. + +Drive the existing live process when its control endpoint responds. Do not start another debugger unless the user asks. + +Use any JSON-RPC client. This shell helper is enough: + +```bash +CONTROL_URL=http://127.0.0.1:8081 +rpc() { + local method="$1" params="${2-}" + if [ -z "$params" ]; then params='{}'; fi + curl -fsS "$CONTROL_URL" \ + -H 'content-type: application/json' \ + --data "$(printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"%s\",\"params\":%s}' "$method" "$params")" +} + +rpc rpc.discover +rpc debugger.getState +rpc debugger.setFocus '{"panel":"history"}' +``` + +Treat a JSON-RPC `error` envelope as failure even when HTTP returns 200. The runtime OpenRPC document returned by `rpc.discover` is the authority for methods and parameters. + +## Inspect Before Acting + +- Use `debugger.getHistory` for recent traffic. Pass `sessionId` to inspect an older session without changing the TUI. +- Use `debugger.listSessions` when the relevant traffic may be from an earlier run. +- Use `debugger.waitForChange` with the last revision instead of polling. +- Use `debugger.getPending` before touching interception state. +- Never resolve a pending request you did not create unless the user explicitly asks. +- For inspection requests, report the evidence without mutating the TUI. + +## Drive the Shared View + +Use `debugger.selectExchange`, `debugger.setFocus`, `debugger.setFilter`, and `debugger.scrollPanel` to show the user what matters. Clear temporary filters afterward. + +Use `debugger.setFullscreen` to expand or restore the focused panel. Set focus first, then pass the desired `fullscreen` boolean. Read the current state from `debugger.getState.fullscreen`. + +When the user says “this line” or “the selected line,” read `debugger.getState.lineSelection`. It contains the panel, one-based line range, and exact text. + +To point at evidence: + +1. Read numbered text with `debugger.getPanel`. +2. Find the exact request or response lines. +3. Add a durable note with `debugger.annotateLines`. +4. Call `debugger.revealLines` only when you intend to focus, center, and highlight that evidence for the user. + +`debugger.annotateLines` does not select, focus, scroll, switch tabs, or highlight. Pass `exchangeIndex` and `tab` for background annotations. Messages must be one line and at most 160 characters. Remove only annotations you created, using their returned ID with `debugger.removeAnnotation`. + +`debugger.sendRequest` sends a complete target JSON-RPC request through the proxy. Keep human-facing request IDs unique, semantic, and at most 12 characters. + +## Run Dense Audits + +1. Freeze the range and calculate the expected interval count before creating traffic. +2. Reuse a suitable durable session, or create one clearly named session when the user asks for a fresh run. +3. Send intervals oldest-to-newest, one at a time. Retry failures with backoff before advancing. +4. Inspect and annotate each completed response without selecting exchanges or changing focus, filter, scroll, tabs, or highlights. +5. Verify coverage, unique IDs, target JSON-RPC errors, and pending count. +6. Tell the user the audit is ready, rank the interesting findings, then reveal them one at a time during a guided walkthrough. + +Do not visually select findings during the background audit. Persistent annotations and temporary highlights are separate tools. + +## Intercept Requests + +Interception requires concurrent calls: + +1. Call `debugger.setPaused` with `paused: true`. +2. Start `debugger.sendRequest` without awaiting it. +3. Wait for a new revision, then read `debugger.getPending` until that request appears. +4. Resolve its internal pending `id` with `debugger.resolvePending` using `allow`, `block`, or `complete`. +5. Await the original send call. +6. Disable pause and verify the pending count returns to zero. + +Use `allow` to forward the original or a replacement request, `block` for a debugger-generated error, and `complete` for a supplied response without forwarding. + +## Work With Sessions + +History and annotations survive restarts in `~/.config/jsonrpc-debugger/sqlite.db` by default. `XDG_CONFIG_HOME` and `JSONRPC_DEBUGGER_CONFIG_DIR` can move it. + +- `debugger.listSessions` lists durable sessions newest first. +- `debugger.getHistory` reads a session without selecting it and supports `limit` and `before` pagination. +- `debugger.selectSession` makes a session visible and restores its target. +- `debugger.createSession` creates and selects an empty session. Do not create one merely to inspect history. +- `debugger.renameSession` renames a session without selecting it. +- `debugger.exportSession` returns portable JSON. +- `debugger.replaySession` appends portable history without forwarding requests. + +Session changes fail while intercepted requests are pending. + +## Finish Cleanly + +- Resolve every request you created. +- Restore pause, target, and temporary filter state when changed for the task. +- Preserve user-created selections and annotations. +- Preserve history unless the user explicitly requests deletion. +- Report the final session, mode, pending count, selected exchange, and target-side JSON-RPC errors. diff --git a/src/app.rs b/src/app.rs index d00dc26..bbb3700 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,3 @@ -use ratatui::widgets::TableState; use std::collections::HashMap; use tokio::sync::{mpsc, oneshot}; @@ -44,6 +43,9 @@ pub enum InputMode { Normal, EditingTarget, FilteringRequests, + AnnotatingSelection, + NamingSession, + RenamingSession, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -54,6 +56,31 @@ pub enum Focus { StatusHeader, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailTab { + Headers, + Body, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Overlay { + None, + Prefix, + Help, + Sessions, +} + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionSummary { + pub id: String, + pub name: String, + pub target: String, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub exchange_count: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LineSelection { pub panel: Focus, @@ -63,6 +90,18 @@ pub struct LineSelection { pub text: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LineAnnotation { + pub id: String, + pub exchange_index: usize, + pub panel: Focus, + pub tab: DetailTab, + pub start_line: usize, + pub end_line: usize, + pub message: String, + pub text: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditorTarget { PendingRequest, @@ -676,10 +715,12 @@ pub struct App { pub exchanges: Vec, pub selected_exchange: usize, pub filter_text: String, - pub table_state: TableState, + pub history_scroll: Option, pub details_scroll: usize, pub request_details_scroll: usize, pub response_details_scroll: usize, + pub request_details_cursor_line: usize, + pub response_details_cursor_line: usize, pub details_tab: usize, pub request_details_tab: usize, pub response_details_tab: usize, @@ -697,9 +738,17 @@ pub struct App { pub request_tab: usize, // 0 = Headers, 1 = Body pub response_tab: usize, // 0 = Headers, 1 = Body pub line_selection: Option, + pub visual_selection_active: bool, + pub annotations: Vec, + pub active_annotation_id: Option, pub editor: Option, pub notice: Option, pub control_port: u16, + pub overlay: Overlay, + pub panel_fullscreen: bool, + pub session: Option, + pub sessions: Vec, + pub selected_session: usize, revision: u64, } @@ -740,6 +789,14 @@ fn display_id(id: Option<&serde_json::Value>) -> String { } } +pub fn request_matches_filter( + method: Option<&str>, + id: Option<&serde_json::Value>, + filter: &str, +) -> bool { + filter.is_empty() || method.unwrap_or("").contains(filter) || display_id(id).contains(filter) +} + fn exchange_duration(exchange: &JsonRpcExchange) -> String { let (Some(request), Some(response)) = (&exchange.request, &exchange.response) else { return "-".to_string(); @@ -837,17 +894,16 @@ impl Default for App { #[allow(dead_code)] impl App { pub fn new() -> Self { - let mut table_state = TableState::default(); - table_state.select(Some(0)); - Self { exchanges: Vec::new(), selected_exchange: 0, filter_text: String::new(), - table_state, + history_scroll: None, details_scroll: 0, request_details_scroll: 0, response_details_scroll: 0, + request_details_cursor_line: 1, + response_details_cursor_line: 1, details_tab: 0, request_details_tab: 0, response_details_tab: 0, @@ -869,9 +925,17 @@ impl App { request_tab: 1, // Body selected by default response_tab: 1, // Body selected by default line_selection: None, + visual_selection_active: false, + annotations: Vec::new(), + active_annotation_id: None, editor: None, notice: None, control_port: 8081, + overlay: Overlay::None, + panel_fullscreen: false, + session: None, + sessions: Vec::new(), + selected_session: 0, revision: 0, } } @@ -883,21 +947,94 @@ impl App { } pub fn check_for_new_messages(&mut self) -> bool { + let new_messages = self.take_new_messages(); + let received_messages = !new_messages.is_empty(); + for message in new_messages { + self.add_message(message); + } + + received_messages + } + + pub fn take_new_messages(&mut self) -> Vec { let Some(receiver) = &mut self.message_receiver else { - return false; + return Vec::new(); }; - let mut new_messages = Vec::new(); + let mut messages = Vec::new(); while let Ok(message) = receiver.try_recv() { - new_messages.push(message); + messages.push(message); } - let received_messages = !new_messages.is_empty(); - for message in new_messages { - self.add_message(message); + messages + } + + pub fn activate_session( + &mut self, + session: SessionSummary, + exchanges: Vec, + annotations: Vec, + ) { + self.exchanges = exchanges; + self.selected_exchange = self.exchanges.len().saturating_sub(1); + self.history_scroll = None; + self.session = Some(session); + self.overlay = Overlay::None; + self.line_selection = None; + self.visual_selection_active = false; + self.annotations = annotations; + self.active_annotation_id = None; + self.reset_details_scroll(); + self.request_details_scroll = 0; + self.response_details_scroll = 0; + self.reset_detail_cursors(); + self.mark_changed(); + } + + pub fn show_prefix(&mut self) { + self.overlay = Overlay::Prefix; + self.mark_changed(); + } + + pub fn show_help(&mut self) { + self.overlay = Overlay::Help; + self.mark_changed(); + } + + pub fn show_sessions(&mut self, sessions: Vec) { + self.selected_session = self + .session + .as_ref() + .and_then(|active| sessions.iter().position(|session| session.id == active.id)) + .unwrap_or(0); + self.sessions = sessions; + self.overlay = Overlay::Sessions; + self.mark_changed(); + } + + pub fn close_overlay(&mut self) { + if self.overlay == Overlay::None { + return; } + self.overlay = Overlay::None; + self.mark_changed(); + } - received_messages + pub fn select_next_session(&mut self) { + if self.sessions.is_empty() { + return; + } + self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1); + self.mark_changed(); + } + + pub fn select_previous_session(&mut self) { + let selected = self.selected_session.saturating_sub(1); + if selected == self.selected_session { + return; + } + self.selected_session = selected; + self.mark_changed(); } pub fn add_message(&mut self, mut message: JsonRpcMessage) { @@ -951,6 +1088,9 @@ impl App { } } } + if let Some(session) = &mut self.session { + session.exchange_count = self.exchanges.len(); + } self.mark_changed(); } @@ -963,27 +1103,59 @@ impl App { .iter() .enumerate() .filter(|(_, exchange)| { - self.filter_text.is_empty() - || exchange - .method - .as_deref() - .unwrap_or("") - .contains(&self.filter_text) + request_matches_filter( + exchange.method.as_deref(), + exchange.id.as_ref(), + &self.filter_text, + ) }) .map(|(index, _)| index) .collect() } + pub fn history_scroll_offset(&self, visible_rows: usize) -> usize { + let indices = self.filtered_exchange_indices(); + let selected = indices + .iter() + .position(|index| *index == self.selected_exchange) + .unwrap_or(0); + let visible_rows = visible_rows.max(1); + let followed = selected.saturating_sub(visible_rows.saturating_sub(1)); + let max_scroll = indices.len().saturating_sub(visible_rows); + self.history_scroll.unwrap_or(followed).min(max_scroll) + } + + pub fn scroll_history(&mut self, lines: i64, visible_rows: usize) { + let previous = self.history_scroll_offset(visible_rows); + let distance = usize::try_from(lines.unsigned_abs()).unwrap_or(usize::MAX); + let max_scroll = self + .filtered_exchange_indices() + .len() + .saturating_sub(visible_rows.max(1)); + let current = if lines >= 0 { + previous.saturating_add(distance).min(max_scroll) + } else { + previous.saturating_sub(distance) + }; + self.history_scroll = Some(current); + if current != previous { + self.mark_changed(); + } + } + pub fn select_exchange(&mut self, index: usize) { if index >= self.exchanges.len() { return; } self.selected_exchange = index; - self.table_state.select(Some(index)); + self.history_scroll = None; self.request_details_scroll = 0; self.response_details_scroll = 0; + self.reset_detail_cursors(); self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } @@ -994,9 +1166,27 @@ impl App { end_line: usize, text: Vec, ) { + self.visual_selection_active = false; + self.active_annotation_id = None; self.select_lines_from_anchor(panel, start_line, start_line, end_line, text); } + pub fn start_visual_selection(&mut self) { + if self.visual_selection_active { + return; + } + self.visual_selection_active = true; + self.mark_changed(); + } + + pub fn finish_visual_selection(&mut self) { + if !self.visual_selection_active { + return; + } + self.visual_selection_active = false; + self.mark_changed(); + } + pub fn select_lines_from_anchor( &mut self, panel: Focus, @@ -1010,6 +1200,17 @@ impl App { } self.focus = panel; + self.active_annotation_id = None; + let cursor_line = if anchor_line == start_line { + end_line + } else { + start_line + }; + match panel { + Focus::RequestSection => self.request_details_cursor_line = cursor_line, + Focus::ResponseSection => self.response_details_cursor_line = cursor_line, + Focus::MessageList | Focus::StatusHeader => {} + } self.line_selection = Some(LineSelection { panel, anchor_line, @@ -1055,13 +1256,167 @@ impl App { } pub fn clear_line_selection(&mut self) { - if self.line_selection.is_none() { + if self.line_selection.is_none() && !self.visual_selection_active { return; } self.line_selection = None; + self.visual_selection_active = false; + self.mark_changed(); + } + + pub fn add_annotation(&mut self, annotation: LineAnnotation) { + self.annotations.push(annotation); self.mark_changed(); } + pub fn focus_annotation(&mut self, id: &str) { + let Some(annotation) = self + .annotations + .iter() + .find(|annotation| annotation.id == id) + .cloned() + else { + return; + }; + self.reveal_lines( + annotation.panel, + annotation.start_line, + annotation.end_line, + annotation.text, + ); + self.active_annotation_id = Some(annotation.id); + self.mark_changed(); + } + + pub fn remove_annotation(&mut self, id: &str) -> bool { + let before = self.annotations.len(); + self.annotations.retain(|annotation| annotation.id != id); + if self.annotations.len() == before { + return false; + } + if self.active_annotation_id.as_deref() == Some(id) { + self.active_annotation_id = None; + } + self.mark_changed(); + true + } + + pub fn detail_tab(&self, panel: Focus) -> Option { + match panel { + Focus::RequestSection => Some(if self.request_tab == 0 { + DetailTab::Headers + } else { + DetailTab::Body + }), + Focus::ResponseSection => Some(if self.response_tab == 0 { + DetailTab::Headers + } else { + DetailTab::Body + }), + Focus::MessageList | Focus::StatusHeader => None, + } + } + + pub fn visible_annotations( + &self, + panel: Focus, + ) -> impl DoubleEndedIterator { + let tab = self.detail_tab(panel); + self.annotations.iter().filter(move |annotation| { + annotation.exchange_index == self.selected_exchange + && annotation.panel == panel + && Some(annotation.tab) == tab + }) + } + + pub fn selection_overlaps_annotation(&self) -> bool { + let Some(selection) = &self.line_selection else { + return false; + }; + self.visible_annotations(selection.panel).any(|annotation| { + selection.start_line <= annotation.end_line + && annotation.start_line <= selection.end_line + }) + } + + pub fn annotation_at_cursor(&self) -> Option<&LineAnnotation> { + let cursor = self.detail_cursor_line(self.focus)?; + self.visible_annotations(self.focus) + .rev() + .find(|annotation| (annotation.start_line..=annotation.end_line).contains(&cursor)) + } + + pub fn annotation_to_delete(&self) -> Option<&LineAnnotation> { + self.active_annotation_id + .as_deref() + .and_then(|id| { + self.visible_annotations(self.focus) + .find(|annotation| annotation.id == id) + }) + .or_else(|| self.annotation_at_cursor()) + } + + pub fn detail_cursor_line(&self, panel: Focus) -> Option { + match panel { + Focus::RequestSection => Some(self.request_details_cursor_line), + Focus::ResponseSection => Some(self.response_details_cursor_line), + Focus::MessageList | Focus::StatusHeader => None, + } + } + + pub fn move_detail_cursor( + &mut self, + panel: Focus, + lines: i64, + total_lines: usize, + visible_lines: usize, + ) { + let total_lines = total_lines.max(1); + let visible_lines = visible_lines.max(1); + let distance = usize::try_from(lines.unsigned_abs()).unwrap_or(usize::MAX); + let previous_focus = self.focus; + self.focus = panel; + + let (cursor, scroll) = match panel { + Focus::RequestSection => ( + &mut self.request_details_cursor_line, + &mut self.request_details_scroll, + ), + Focus::ResponseSection => ( + &mut self.response_details_cursor_line, + &mut self.response_details_scroll, + ), + Focus::MessageList | Focus::StatusHeader => return, + }; + let previous_cursor = *cursor; + let previous_scroll = *scroll; + *cursor = (*cursor).clamp(1, total_lines); + *cursor = if lines >= 0 { + cursor.saturating_add(distance).min(total_lines) + } else { + cursor.saturating_sub(distance).max(1) + }; + + let cursor_index = cursor.saturating_sub(1); + let max_scroll = total_lines.saturating_sub(visible_lines); + *scroll = (*scroll).min(max_scroll); + if cursor_index < *scroll { + *scroll = cursor_index; + } else if cursor_index >= scroll.saturating_add(visible_lines) { + *scroll = cursor_index.saturating_add(1).saturating_sub(visible_lines); + } + + if previous_focus != self.focus || previous_cursor != *cursor || previous_scroll != *scroll + { + self.mark_changed(); + } + } + + fn reset_detail_cursors(&mut self) { + self.request_details_cursor_line = 1; + self.response_details_cursor_line = 1; + } + pub fn set_focus(&mut self, focus: Focus) { if self.focus == focus { return; @@ -1070,6 +1425,14 @@ impl App { self.mark_changed(); } + pub fn set_panel_fullscreen(&mut self, fullscreen: bool) { + if self.panel_fullscreen == fullscreen { + return; + } + self.panel_fullscreen = fullscreen; + self.mark_changed(); + } + pub fn revision(&self) -> u64 { self.revision } @@ -1084,6 +1447,9 @@ impl App { } self.exchanges.extend(exchanges); + if let Some(session) = &mut self.session { + session.exchange_count = self.exchanges.len(); + } self.select_exchange(self.exchanges.len() - 1); } @@ -1113,6 +1479,14 @@ impl App { } pub fn focused_markdown(&self) -> Option { + if let Some(selection) = self + .line_selection + .as_ref() + .filter(|selection| self.visual_selection_active || selection.panel == self.focus) + { + return Some(format!("```text\n{}\n```", selection.text.join("\n"))); + } + match self.focus { Focus::MessageList => Some(self.requests_markdown()), Focus::RequestSection => self.request_markdown(), @@ -1190,14 +1564,17 @@ impl App { pub fn select_next(&mut self) { if !self.exchanges.is_empty() { self.selected_exchange = (self.selected_exchange + 1) % self.exchanges.len(); - self.table_state.select(Some(self.selected_exchange)); + self.history_scroll = None; self.reset_details_scroll(); self.request_details_scroll = 0; self.response_details_scroll = 0; + self.reset_detail_cursors(); self.details_tab = 0; self.request_details_tab = 0; self.response_details_tab = 0; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } } @@ -1209,14 +1586,17 @@ impl App { } else { self.selected_exchange - 1 }; - self.table_state.select(Some(self.selected_exchange)); + self.history_scroll = None; self.reset_details_scroll(); self.request_details_scroll = 0; self.response_details_scroll = 0; + self.reset_detail_cursors(); self.details_tab = 0; self.request_details_tab = 0; self.response_details_tab = 0; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } } @@ -1307,9 +1687,6 @@ impl App { Focus::ResponseSection => Focus::StatusHeader, Focus::StatusHeader => Focus::MessageList, }; - self.reset_details_scroll(); - self.request_details_scroll = 0; - self.response_details_scroll = 0; self.mark_changed(); } @@ -1320,9 +1697,6 @@ impl App { Focus::ResponseSection => Focus::RequestSection, Focus::StatusHeader => Focus::ResponseSection, }; - self.reset_details_scroll(); - self.request_details_scroll = 0; - self.response_details_scroll = 0; self.mark_changed(); } @@ -1345,28 +1719,40 @@ impl App { pub fn next_request_tab(&mut self) { self.request_tab = 1 - self.request_tab; // Toggle between 0 and 1 self.request_details_scroll = 0; + self.request_details_cursor_line = 1; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } pub fn previous_request_tab(&mut self) { self.request_tab = 1 - self.request_tab; // Toggle between 0 and 1 self.request_details_scroll = 0; + self.request_details_cursor_line = 1; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } pub fn next_response_tab(&mut self) { self.response_tab = 1 - self.response_tab; // Toggle between 0 and 1 self.response_details_scroll = 0; + self.response_details_cursor_line = 1; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } pub fn previous_response_tab(&mut self) { self.response_tab = 1 - self.response_tab; // Toggle between 0 and 1 self.response_details_scroll = 0; + self.response_details_cursor_line = 1; self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.mark_changed(); } @@ -1383,6 +1769,7 @@ impl App { pub fn apply_filter(&mut self) { self.filter_text = self.input_buffer.clone(); + self.history_scroll = None; self.input_mode = InputMode::Normal; self.input_buffer.clear(); self.mark_changed(); @@ -1400,6 +1787,39 @@ impl App { self.input_buffer.clear(); } + pub fn start_naming_session(&mut self) { + self.input_mode = InputMode::NamingSession; + self.input_buffer.clear(); + } + + pub fn start_annotating_selection(&mut self) { + if !self.visual_selection_active || self.line_selection.is_none() { + return; + } + self.input_mode = InputMode::AnnotatingSelection; + self.input_buffer.clear(); + } + + pub fn start_renaming_session(&mut self) { + let Some(session) = &self.session else { + return; + }; + self.input_mode = InputMode::RenamingSession; + self.input_buffer = session.name.clone(); + } + + pub fn rename_session(&mut self, id: &str, name: String) { + if let Some(session) = &mut self.session { + if session.id == id { + session.name = name.clone(); + } + } + if let Some(session) = self.sessions.iter_mut().find(|session| session.id == id) { + session.name = name; + } + self.mark_changed(); + } + pub fn confirm_target_edit(&mut self) { if !self.input_buffer.trim().is_empty() { self.proxy_config.target_url = self.input_buffer.trim().to_string(); @@ -1410,17 +1830,13 @@ impl App { } pub fn handle_input_char(&mut self, c: char) { - if self.input_mode == InputMode::EditingTarget - || self.input_mode == InputMode::FilteringRequests - { + if self.input_mode != InputMode::Normal { self.input_buffer.push(c); } } pub fn handle_backspace(&mut self) { - if self.input_mode == InputMode::EditingTarget - || self.input_mode == InputMode::FilteringRequests - { + if self.input_mode != InputMode::Normal { self.input_buffer.pop(); } } @@ -1668,10 +2084,13 @@ impl App { // Pause/Intercept functionality pub fn toggle_pause_mode(&mut self) { self.line_selection = None; + self.visual_selection_active = false; + self.active_annotation_id = None; self.app_mode = match self.app_mode { AppMode::Normal => AppMode::Paused, - AppMode::Paused => AppMode::Normal, - AppMode::Intercepting => AppMode::Normal, + AppMode::Paused if self.pending_requests.is_empty() => AppMode::Normal, + AppMode::Paused => AppMode::Intercepting, + AppMode::Intercepting => AppMode::Paused, }; self.mark_changed(); } @@ -1923,6 +2342,10 @@ impl App { } pub fn prepare_new_request(&self, request_json: String) -> Result { + if !self.is_running { + return Err("Proxy is stopped. Press Ctrl-B x to start it.".to_string()); + } + let parsed: serde_json::Value = serde_json::from_str(&request_json).map_err(|e| format!("Invalid JSON: {}", e))?; @@ -1937,7 +2360,9 @@ impl App { // Check if target URL is empty if self.proxy_config.target_url.trim().is_empty() { - return Err("Target URL is not set. Press 't' to set a target URL first.".to_string()); + return Err( + "Target URL is not set. Press Ctrl-B t to set a target URL first.".to_string(), + ); } let url = if matches!(self.app_mode, AppMode::Paused | AppMode::Intercepting) { diff --git a/src/control.rs b/src/control.rs index 883cd7d..f92497f 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,11 +1,13 @@ use crate::app::{ - App, AppMode, Focus, JsonRpcExchange, JsonRpcMessage, MessageDirection, TransportType, + App, AppMode, DetailTab, Focus, JsonRpcExchange, JsonRpcMessage, LineAnnotation, + MessageDirection, Overlay, SessionSummary, TransportType, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ collections::HashMap, convert::Infallible, + future::Future, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::{mpsc, oneshot}; @@ -21,9 +23,26 @@ pub enum ControlAction { }, GetPanel { focus: Focus, + exchange_index: Option, + tab: Option, }, GetHistory { limit: usize, + session_id: Option, + before: Option, + }, + ListSessions { + limit: usize, + }, + CreateSession { + name: Option, + }, + SelectSession { + id: String, + }, + RenameSession { + id: String, + name: String, }, ExportSession, ReplaySession { @@ -38,12 +57,26 @@ pub enum ControlAction { SetFocus { focus: Focus, }, + SetFullscreen { + fullscreen: bool, + }, RevealLines { focus: Focus, start_line: usize, end_line: usize, }, + AnnotateLines { + focus: Focus, + exchange_index: Option, + tab: Option, + start_line: usize, + end_line: usize, + message: String, + }, ClearLineSelection, + RemoveAnnotation { + id: String, + }, ScrollPanel { focus: Focus, lines: i64, @@ -148,7 +181,10 @@ impl ControlError { } } -pub async fn serve(port: u16, sender: mpsc::UnboundedSender) -> Result<(), String> { +pub fn bind( + port: u16, + sender: mpsc::UnboundedSender, +) -> Result + 'static, String> { let sender = warp::any().map(move || sender.clone()); let route = warp::post() .and(warp::path::end()) @@ -157,9 +193,10 @@ pub async fn serve(port: u16, sender: mpsc::UnboundedSender) -> .and(sender) .and_then(handle_request); - warp::serve(route).try_bind(([127, 0, 0, 1], port)).await; - - Ok(()) + let (_, server) = warp::serve(route) + .try_bind_ephemeral(([127, 0, 0, 1], port)) + .map_err(|error| format!("bind control port {port}: {error}"))?; + Ok(server) } async fn handle_request( @@ -232,9 +269,28 @@ fn parse_request(request: &Value) -> Result { }), "debugger.getPanel" => Ok(ControlAction::GetPanel { focus: parse_detail_focus(required_string(params, 0, "panel")?)?, + exchange_index: optional_usize(params, 1, "exchangeIndex")?, + tab: optional_string(params, 2, "tab")? + .map(parse_detail_tab) + .transpose()?, }), "debugger.getHistory" => Ok(ControlAction::GetHistory { limit: optional_usize(params, 0, "limit")?.unwrap_or(100).min(1000), + session_id: optional_string(params, 1, "sessionId")?.map(str::to_string), + before: optional_usize(params, 2, "before")?, + }), + "debugger.listSessions" => Ok(ControlAction::ListSessions { + limit: optional_usize(params, 0, "limit")?.unwrap_or(100).min(1000), + }), + "debugger.createSession" => Ok(ControlAction::CreateSession { + name: optional_string(params, 0, "name")?.map(str::to_string), + }), + "debugger.selectSession" => Ok(ControlAction::SelectSession { + id: required_string(params, 0, "sessionId")?.to_string(), + }), + "debugger.renameSession" => Ok(ControlAction::RenameSession { + id: required_string(params, 0, "sessionId")?.to_string(), + name: required_string(params, 1, "name")?.to_string(), }), "debugger.exportSession" => Ok(ControlAction::ExportSession), "debugger.replaySession" => Ok(ControlAction::ReplaySession { @@ -251,6 +307,9 @@ fn parse_request(request: &Value) -> Result { "debugger.setFocus" => Ok(ControlAction::SetFocus { focus: parse_focus(required_string(params, 0, "panel")?)?, }), + "debugger.setFullscreen" => Ok(ControlAction::SetFullscreen { + fullscreen: required_bool(params, 0, "fullscreen")?, + }), "debugger.revealLines" => { let start_line = required_usize(params, 1, "startLine")?; Ok(ControlAction::RevealLines { @@ -259,7 +318,23 @@ fn parse_request(request: &Value) -> Result { end_line: optional_usize(params, 2, "endLine")?.unwrap_or(start_line), }) } + "debugger.annotateLines" => { + let start_line = required_usize(params, 1, "startLine")?; + Ok(ControlAction::AnnotateLines { + focus: parse_detail_focus(required_string(params, 0, "panel")?)?, + exchange_index: optional_usize(params, 4, "exchangeIndex")?, + tab: optional_string(params, 5, "tab")? + .map(parse_detail_tab) + .transpose()?, + start_line, + end_line: optional_usize(params, 2, "endLine")?.unwrap_or(start_line), + message: required_string(params, 3, "message")?.to_string(), + }) + } "debugger.clearLineSelection" => Ok(ControlAction::ClearLineSelection), + "debugger.removeAnnotation" => Ok(ControlAction::RemoveAnnotation { + id: required_string(params, 0, "annotationId")?.to_string(), + }), "debugger.scrollPanel" => Ok(ControlAction::ScrollPanel { focus: parse_scroll_focus(required_string(params, 0, "panel")?)?, lines: required_i64(params, 1, "lines")?, @@ -331,6 +406,20 @@ fn required_string<'a>( .ok_or_else(|| ControlError::invalid_params(format!("{name} must be a string"))) } +fn optional_string<'a>( + params: &'a Value, + index: usize, + name: &str, +) -> Result, ControlError> { + let Some(value) = optional(params, index, name) else { + return Ok(None); + }; + value + .as_str() + .map(Some) + .ok_or_else(|| ControlError::invalid_params(format!("{name} must be a string"))) +} + fn required_bool(params: &Value, index: usize, name: &str) -> Result { required(params, index, name)? .as_bool() @@ -412,6 +501,14 @@ fn parse_detail_focus(panel: &str) -> Result { } } +fn parse_detail_tab(tab: &str) -> Result { + match tab { + "headers" => Ok(DetailTab::Headers), + "body" => Ok(DetailTab::Body), + _ => Err(ControlError::invalid_params("tab must be headers or body")), + } +} + fn parse_scroll_focus(panel: &str) -> Result { match panel { "history" => Ok(Focus::MessageList), @@ -440,6 +537,12 @@ pub fn state(app: &App) -> Value { "text": selection.text.join("\n"), }) }); + let annotations = app + .annotations + .iter() + .filter(|annotation| annotation.exchange_index == app.selected_exchange) + .map(annotation_value) + .collect::>(); json!({ "revision": app.revision(), "running": app.is_running, @@ -449,7 +552,15 @@ pub fn state(app: &App) -> Value { "target": app.proxy_config.target_url, "filter": app.filter_text, "focus": focus_name(app.focus), + "fullscreen": app.panel_fullscreen, "lineSelection": line_selection, + "visualSelectionActive": app.visual_selection_active, + "annotations": annotations, + "activeAnnotationId": app.active_annotation_id, + "cursor": { + "requestLine": app.request_details_cursor_line, + "responseLine": app.response_details_cursor_line, + }, "scroll": { "request": app.request_details_scroll, "response": app.response_details_scroll, @@ -461,6 +572,8 @@ pub fn state(app: &App) -> Value { "selectedExchange": app.selected_exchange, "exchangeCount": app.exchanges.len(), "pendingCount": app.pending_requests.len(), + "overlay": overlay_name(app.overlay), + "session": app.session, }) } @@ -483,18 +596,20 @@ pub fn panel(focus: Focus, lines: Vec) -> Value { }) } -pub fn history(app: &App, limit: usize) -> Value { - let start = app.exchanges.len().saturating_sub(limit); +pub fn stored_history(exchanges: Vec<(usize, JsonRpcExchange)>) -> Value { Value::Array( - app.exchanges - .iter() - .enumerate() - .skip(start) - .map(|(index, exchange)| exchange_value(index, exchange)) + exchanges + .into_iter() + .map(|(index, exchange)| exchange_value(index, &exchange)) .collect(), ) } +pub fn sessions(sessions: Vec) -> Value { + serde_json::to_value(sessions).expect("session summaries are serializable") +} + +#[cfg(test)] pub fn export_session(app: &App) -> Session { Session { schema_version: 1, @@ -677,6 +792,15 @@ fn exchange_value(index: usize, exchange: &JsonRpcExchange) -> Value { }) } +fn overlay_name(overlay: Overlay) -> &'static str { + match overlay { + Overlay::None => "none", + Overlay::Prefix => "commands", + Overlay::Help => "help", + Overlay::Sessions => "sessions", + } +} + fn message_value(message: &JsonRpcMessage) -> Value { json!({ "body": message_body(message), @@ -730,6 +854,26 @@ fn focus_name(focus: Focus) -> &'static str { } } +fn annotation_value(annotation: &LineAnnotation) -> Value { + json!({ + "id": annotation.id, + "exchangeIndex": annotation.exchange_index, + "panel": focus_name(annotation.panel), + "tab": match annotation.tab { + DetailTab::Headers => "headers", + DetailTab::Body => "body", + }, + "startLine": annotation.start_line, + "endLine": annotation.end_line, + "message": annotation.message, + "text": annotation.text.join("\n"), + }) +} + +pub fn annotation(annotation: &LineAnnotation) -> Value { + annotation_value(annotation) +} + fn transport_name(transport: &TransportType) -> &'static str { match transport { TransportType::Http => "http", @@ -769,6 +913,17 @@ mod tests { focus: Focus::RequestSection }) )); + + let fullscreen = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "debugger.setFullscreen", + "params": {"fullscreen": true}, + }); + assert!(matches!( + parse_request(&fullscreen), + Ok(ControlAction::SetFullscreen { fullscreen: true }) + )); } #[test] @@ -778,7 +933,7 @@ mod tests { assert_eq!(document["openrpc"], "1.3.2"); assert_eq!(document["servers"][0]["url"], "http://127.0.0.1:8081"); assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(document["methods"].as_array().unwrap().len(), 18); + assert_eq!(document["methods"].as_array().unwrap().len(), 25); assert!(document["methods"] .as_array() .unwrap() @@ -789,10 +944,31 @@ mod tests { .unwrap() .iter() .any(|method| method["name"] == "debugger.replaySession")); + assert!(document["methods"] + .as_array() + .unwrap() + .iter() + .any(|method| method["name"] == "debugger.renameSession")); + assert!(document["methods"] + .as_array() + .unwrap() + .iter() + .any(|method| method["name"] == "debugger.removeAnnotation")); + assert!(document["methods"] + .as_array() + .unwrap() + .iter() + .any(|method| method["name"] == "debugger.setFullscreen")); } #[test] fn parses_line_selection_and_scrolling() { + let panel = json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "debugger.getPanel", + "params": {"panel": "response", "exchangeIndex": 7, "tab": "headers"}, + }); let reveal = json!({ "jsonrpc": "2.0", "id": 1, @@ -805,6 +981,34 @@ mod tests { "method": "debugger.scrollPanel", "params": {"panel": "response", "lines": -4}, }); + let annotate = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "debugger.annotateLines", + "params": { + "panel": "response", + "startLine": 10, + "endLine": 14, + "message": "This fee is unusually high", + "exchangeIndex": 7, + "tab": "body", + }, + }); + let remove = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "debugger.removeAnnotation", + "params": {"annotationId": "note-1"}, + }); + + assert!(matches!( + parse_request(&panel), + Ok(ControlAction::GetPanel { + focus: Focus::ResponseSection, + exchange_index: Some(7), + tab: Some(DetailTab::Headers), + }) + )); assert!(matches!( parse_request(&reveal), @@ -821,6 +1025,21 @@ mod tests { lines: -4, }) )); + assert!(matches!( + parse_request(&annotate), + Ok(ControlAction::AnnotateLines { + focus: Focus::ResponseSection, + exchange_index: Some(7), + tab: Some(DetailTab::Body), + start_line: 10, + end_line: 14, + message, + }) if message == "This fee is unusually high" + )); + assert!(matches!( + parse_request(&remove), + Ok(ControlAction::RemoveAnnotation { id }) if id == "note-1" + )); } #[test] @@ -842,6 +1061,24 @@ mod tests { "exchanges": [], }}, }); + let history = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "debugger.getHistory", + "params": {"limit": 25, "sessionId": "saved", "before": 40}, + }); + let select = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "debugger.selectSession", + "params": {"sessionId": "saved"}, + }); + let rename = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "debugger.renameSession", + "params": {"sessionId": "saved", "name": "Refund investigation"}, + }); assert!(matches!( parse_request(&wait), @@ -854,18 +1091,53 @@ mod tests { parse_request(&replay), Ok(ControlAction::ReplaySession { .. }) )); + assert!(matches!( + parse_request(&history), + Ok(ControlAction::GetHistory { + limit: 25, + session_id: Some(id), + before: Some(40), + }) if id == "saved" + )); + assert!(matches!( + parse_request(&select), + Ok(ControlAction::SelectSession { id }) if id == "saved" + )); + assert!(matches!( + parse_request(&rename), + Ok(ControlAction::RenameSession { id, name }) + if id == "saved" && name == "Refund investigation" + )); } #[test] - fn state_exposes_the_shared_line_reference() { + fn annotation_and_shared_line_reference_are_independent() { let mut app = App::new(); + app.add_annotation(LineAnnotation { + id: "annotation-1".to_string(), + exchange_index: 0, + panel: Focus::ResponseSection, + tab: DetailTab::Body, + start_line: 3, + end_line: 4, + message: "Compare these values".to_string(), + text: vec!["first".to_string(), "second".to_string()], + }); + + let annotated = state(&app); + assert_eq!(annotated["focus"], "history"); + assert_eq!(annotated["fullscreen"], false); + assert_eq!(annotated["scroll"]["response"], 0); + assert!(annotated["lineSelection"].is_null()); + assert!(annotated["activeAnnotationId"].is_null()); + assert_eq!(annotated["annotations"][0]["id"], "annotation-1"); + app.reveal_lines( Focus::ResponseSection, 3, 4, vec!["first".to_string(), "second".to_string()], ); - let state = state(&app); assert_eq!(state["focus"], "response"); @@ -874,6 +1146,11 @@ mod tests { assert_eq!(state["lineSelection"]["startLine"], 3); assert_eq!(state["lineSelection"]["endLine"], 4); assert_eq!(state["lineSelection"]["text"], "first\nsecond"); + assert_eq!(state["annotations"][0]["id"], "annotation-1"); + assert_eq!(state["annotations"][0]["message"], "Compare these values"); + assert_eq!(state["annotations"][0]["text"], "first\nsecond"); + assert!(state["activeAnnotationId"].is_null()); + assert_eq!(state["cursor"]["responseLine"], 4); let panel = panel( Focus::ResponseSection, @@ -936,4 +1213,13 @@ mod tests { }) .is_err()); } + + #[tokio::test] + async fn bind_rejects_an_occupied_control_port() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sender, _receiver) = mpsc::unbounded_channel(); + + assert!(bind(port, sender).is_err()); + } } diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..c3a44f5 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,787 @@ +use crate::{ + app::{ + DetailTab, Focus, JsonRpcExchange, JsonRpcMessage, LineAnnotation, MessageDirection, + SessionSummary, + }, + control::{Session, SessionExchange, SessionMessage}, +}; +use anyhow::{anyhow, bail, Context, Result}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use uuid::Uuid; + +const SCHEMA_VERSION: i64 = 2; + +pub struct HistoryStore { + connection: Connection, +} + +impl HistoryStore { + pub fn open_default() -> Result { + let path = history_path()?; + let parent = path + .parent() + .ok_or_else(|| anyhow!("history path has no parent"))?; + fs::create_dir_all(parent).context("create history directory")?; + + let store = Self::open(&path)?; + set_private_permissions(parent, &path)?; + Ok(store) + } + + pub fn open(path: &Path) -> Result { + let connection = Connection::open(path).context("open history database")?; + Self::from_connection(connection) + } + + #[cfg(test)] + pub fn in_memory() -> Result { + Self::from_connection(Connection::open_in_memory()?) + } + + fn from_connection(connection: Connection) -> Result { + connection.execute_batch("PRAGMA foreign_keys = ON;")?; + let version = + connection.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))?; + if version > SCHEMA_VERSION { + bail!("history database version {version} is newer than this debugger supports"); + } + + connection.execute_batch( + " + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + target TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS exchanges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + rpc_id TEXT NOT NULL, + method TEXT, + complete INTEGER NOT NULL, + exchange_json TEXT NOT NULL, + UNIQUE(session_id, sequence) + ); + CREATE INDEX IF NOT EXISTS exchanges_session_sequence + ON exchanges(session_id, sequence); + CREATE INDEX IF NOT EXISTS exchanges_pending_rpc_id + ON exchanges(rpc_id, complete, id DESC); + CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + exchange_index INTEGER NOT NULL, + panel TEXT NOT NULL, + tab TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + message TEXT NOT NULL, + text_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS annotations_session_exchange + ON annotations(session_id, exchange_index, created_at_ms); + PRAGMA user_version = 2; + ", + )?; + + Ok(Self { connection }) + } + + pub fn create_session(&mut self, name: Option<&str>, target: &str) -> Result { + let now = database_timestamp_ms(SystemTime::now()); + let count = self + .connection + .query_row("SELECT COUNT(*) FROM sessions", [], |row| { + row.get::<_, i64>(0) + })?; + let name = name.map(str::trim).filter(|name| !name.is_empty()); + if let Some(name) = name { + validate_session_name(name)?; + } + let name = name + .map(str::to_string) + .unwrap_or_else(|| format!("Session {}", count + 1)); + let id = Uuid::new_v4().to_string(); + + self.connection.execute( + "INSERT INTO sessions (id, name, target, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?4)", + params![id, name, target, now], + )?; + + self.session(&id)? + .ok_or_else(|| anyhow!("created session disappeared")) + } + + pub fn session(&self, id: &str) -> Result> { + self.connection + .query_row( + "SELECT s.id, s.name, s.target, s.created_at_ms, s.updated_at_ms, + COUNT(e.id) + FROM sessions s + LEFT JOIN exchanges e ON e.session_id = s.id + WHERE s.id = ?1 + GROUP BY s.id", + [id], + session_summary, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_sessions(&self, limit: usize) -> Result> { + let mut statement = self.connection.prepare( + "SELECT s.id, s.name, s.target, s.created_at_ms, s.updated_at_ms, + COUNT(e.id) + FROM sessions s + LEFT JOIN exchanges e ON e.session_id = s.id + GROUP BY s.id + ORDER BY s.updated_at_ms DESC + LIMIT ?1", + )?; + let rows = statement.query_map([sqlite_limit(limit)], session_summary)?; + rows.collect::>>() + .map_err(Into::into) + } + + pub fn load_session( + &self, + id: &str, + ) -> Result<(SessionSummary, Vec, Vec)> { + let session = self + .session(id)? + .ok_or_else(|| anyhow!("session not found: {id}"))?; + let exchanges = self + .history(id, usize::MAX, None)? + .into_iter() + .map(|(_, exchange)| exchange) + .collect(); + Ok((session, exchanges, self.annotations(id)?)) + } + + pub fn annotations(&self, session_id: &str) -> Result> { + let mut statement = self.connection.prepare( + "SELECT id, exchange_index, panel, tab, start_line, end_line, message, text_json + FROM annotations + WHERE session_id = ?1 + ORDER BY created_at_ms, id", + )?; + let rows = statement.query_map([session_id], |row| { + let panel = match row.get::<_, String>(2)?.as_str() { + "request" => Focus::RequestSection, + "response" => Focus::ResponseSection, + value => return Err(invalid_annotation_column(2, value)), + }; + let tab = match row.get::<_, String>(3)?.as_str() { + "headers" => DetailTab::Headers, + "body" => DetailTab::Body, + value => return Err(invalid_annotation_column(3, value)), + }; + let text_json = row.get::<_, String>(7)?; + let text = serde_json::from_str(&text_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + Ok(LineAnnotation { + id: row.get(0)?, + exchange_index: row.get::<_, i64>(1)?.max(0) as usize, + panel, + tab, + start_line: row.get::<_, i64>(4)?.max(1) as usize, + end_line: row.get::<_, i64>(5)?.max(1) as usize, + message: row.get(6)?, + text, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + pub fn add_annotation(&self, session_id: &str, annotation: &LineAnnotation) -> Result<()> { + let panel = annotation_panel(annotation.panel)?; + self.connection.execute( + "INSERT INTO annotations ( + id, session_id, exchange_index, panel, tab, start_line, end_line, + message, text_json, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + annotation.id, + session_id, + sqlite_index(annotation.exchange_index), + panel, + annotation_tab(annotation.tab), + sqlite_index(annotation.start_line), + sqlite_index(annotation.end_line), + annotation.message, + serde_json::to_string(&annotation.text)?, + database_timestamp_ms(SystemTime::now()), + ], + )?; + self.touch_session(session_id)?; + Ok(()) + } + + pub fn remove_annotation(&self, session_id: &str, id: &str) -> Result { + let removed = self.connection.execute( + "DELETE FROM annotations WHERE session_id = ?1 AND id = ?2", + params![session_id, id], + )?; + if removed > 0 { + self.touch_session(session_id)?; + } + Ok(removed > 0) + } + + fn touch_session(&self, session_id: &str) -> Result<()> { + self.connection.execute( + "UPDATE sessions SET updated_at_ms = ?2 WHERE id = ?1", + params![session_id, database_timestamp_ms(SystemTime::now())], + )?; + Ok(()) + } + + pub fn history( + &self, + session_id: &str, + limit: usize, + before_index: Option, + ) -> Result> { + let mut rows = if let Some(before_index) = before_index { + let mut statement = self.connection.prepare( + "SELECT sequence, exchange_json + FROM exchanges + WHERE session_id = ?1 AND sequence <= ?2 + ORDER BY sequence DESC + LIMIT ?3", + )?; + collect_exchanges( + &mut statement, + params![session_id, sqlite_index(before_index), sqlite_limit(limit)], + )? + } else { + let mut statement = self.connection.prepare( + "SELECT sequence, exchange_json + FROM exchanges + WHERE session_id = ?1 + ORDER BY sequence DESC + LIMIT ?2", + )?; + collect_exchanges(&mut statement, params![session_id, sqlite_limit(limit)])? + }; + rows.reverse(); + Ok(rows) + } + + pub fn record_message( + &mut self, + active_session_id: &str, + message: &JsonRpcMessage, + ) -> Result { + let transaction = self.connection.transaction()?; + let session_id = match message.direction { + MessageDirection::Request => active_session_id.to_string(), + MessageDirection::Response => pending_session(&transaction, message)? + .unwrap_or_else(|| active_session_id.to_string()), + }; + + match message.direction { + MessageDirection::Request => insert_message(&transaction, &session_id, message)?, + MessageDirection::Response => update_response(&transaction, &session_id, message)?, + } + transaction.execute( + "UPDATE sessions SET updated_at_ms = ?2 WHERE id = ?1", + params![session_id, database_timestamp_ms(message.timestamp)], + )?; + transaction.commit()?; + Ok(session_id) + } + + pub fn append_exchanges( + &mut self, + session_id: &str, + exchanges: &[JsonRpcExchange], + ) -> Result<()> { + if exchanges.is_empty() { + return Ok(()); + } + + let transaction = self.connection.transaction()?; + for (sequence, exchange) in + (next_sequence(&transaction, session_id)?..).zip(exchanges.iter()) + { + insert_exchange(&transaction, session_id, sequence, exchange)?; + } + transaction.execute( + "UPDATE sessions SET updated_at_ms = ?2 WHERE id = ?1", + params![session_id, database_timestamp_ms(SystemTime::now())], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn update_target(&self, session_id: &str, target: &str) -> Result<()> { + self.connection.execute( + "UPDATE sessions SET target = ?2, updated_at_ms = ?3 WHERE id = ?1", + params![session_id, target, database_timestamp_ms(SystemTime::now())], + )?; + Ok(()) + } + + pub fn rename_session(&self, session_id: &str, name: &str) -> Result { + let name = name.trim(); + validate_session_name(name)?; + let renamed = self.connection.execute( + "UPDATE sessions SET name = ?2, updated_at_ms = ?3 WHERE id = ?1", + params![session_id, name, database_timestamp_ms(SystemTime::now())], + )?; + Ok(renamed > 0) + } + + pub fn export_session(&self, session_id: &str) -> Result { + let session = self + .session(session_id)? + .ok_or_else(|| anyhow!("session not found: {session_id}"))?; + let exchanges = self + .history(session_id, usize::MAX, None)? + .into_iter() + .map(|(_, exchange)| SessionExchange::from(&exchange)) + .collect(); + + Ok(Session { + schema_version: 1, + exported_at_ms: timestamp_ms(SystemTime::now()), + target: session.target, + exchanges, + }) + } +} + +fn history_path() -> Result { + if let Some(path) = std::env::var_os("JSONRPC_DEBUGGER_CONFIG_DIR") { + return Ok(PathBuf::from(path).join("sqlite.db")); + } + if let Some(path) = std::env::var_os("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(path) + .join("jsonrpc-debugger") + .join("sqlite.db")); + } + if let Some(path) = std::env::var_os("HOME") { + return Ok(PathBuf::from(path).join(".config/jsonrpc-debugger/sqlite.db")); + } + + bail!("HOME is not set; set JSONRPC_DEBUGGER_CONFIG_DIR for session history") +} + +#[cfg(unix)] +fn set_private_permissions(directory: &Path, database: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?; + fs::set_permissions(database, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_directory: &Path, _database: &Path) -> Result<()> { + Ok(()) +} + +fn session_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SessionSummary { + id: row.get(0)?, + name: row.get(1)?, + target: row.get(2)?, + created_at_ms: row.get::<_, i64>(3)?.max(0) as u64, + updated_at_ms: row.get::<_, i64>(4)?.max(0) as u64, + exchange_count: row.get::<_, i64>(5)?.max(0) as usize, + }) +} + +fn collect_exchanges

( + statement: &mut rusqlite::Statement<'_>, + params: P, +) -> Result> +where + P: rusqlite::Params, +{ + let rows = statement.query_map(params, |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + rows.map(|row| { + let (sequence, json) = row?; + let exchange: SessionExchange = serde_json::from_str(&json)?; + let exchange = exchange + .try_into() + .map_err(|error: String| anyhow!(error))?; + Ok((sequence.saturating_sub(1) as usize, exchange)) + }) + .collect() +} + +fn pending_session( + transaction: &Transaction<'_>, + message: &JsonRpcMessage, +) -> Result> { + transaction + .query_row( + "SELECT session_id + FROM exchanges + WHERE rpc_id = ?1 AND complete = 0 + ORDER BY id DESC + LIMIT 1", + [rpc_id(message)], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn insert_message( + transaction: &Transaction<'_>, + session_id: &str, + message: &JsonRpcMessage, +) -> Result<()> { + let exchange = JsonRpcExchange { + id: message.id.clone(), + method: message.method.clone(), + request: Some(message.clone()), + response: None, + timestamp: message.timestamp, + transport: message.transport.clone(), + }; + let sequence = next_sequence(transaction, session_id)?; + insert_exchange(transaction, session_id, sequence, &exchange) +} + +fn update_response( + transaction: &Transaction<'_>, + session_id: &str, + message: &JsonRpcMessage, +) -> Result<()> { + let pending = transaction + .query_row( + "SELECT id, exchange_json + FROM exchanges + WHERE session_id = ?1 AND rpc_id = ?2 AND complete = 0 + ORDER BY id DESC + LIMIT 1", + params![session_id, rpc_id(message)], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + let Some((id, json)) = pending else { + let exchange = JsonRpcExchange { + id: message.id.clone(), + method: None, + request: None, + response: Some(message.clone()), + timestamp: message.timestamp, + transport: message.transport.clone(), + }; + let sequence = next_sequence(transaction, session_id)?; + return insert_exchange(transaction, session_id, sequence, &exchange); + }; + + let mut exchange: SessionExchange = serde_json::from_str(&json)?; + exchange.response = Some(SessionMessage::from(message)); + transaction.execute( + "UPDATE exchanges SET complete = 1, exchange_json = ?2 WHERE id = ?1", + params![id, serde_json::to_string(&exchange)?], + )?; + Ok(()) +} + +fn next_sequence(transaction: &Transaction<'_>, session_id: &str) -> Result { + let sequence = transaction.query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM exchanges WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + Ok(sequence) +} + +fn insert_exchange( + transaction: &Transaction<'_>, + session_id: &str, + sequence: i64, + exchange: &JsonRpcExchange, +) -> Result<()> { + let value = SessionExchange::from(exchange); + transaction.execute( + "INSERT INTO exchanges + (session_id, sequence, rpc_id, method, complete, exchange_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + session_id, + sequence, + exchange + .id + .as_ref() + .map(serde_json::to_string) + .transpose()? + .unwrap_or_else(|| "null".to_string()), + exchange.method, + i64::from(exchange.response.is_some()), + serde_json::to_string(&value)?, + ], + )?; + Ok(()) +} + +fn rpc_id(message: &JsonRpcMessage) -> String { + message + .id + .as_ref() + .map(serde_json::Value::to_string) + .unwrap_or_else(|| "null".to_string()) +} + +fn annotation_panel(panel: Focus) -> Result<&'static str> { + match panel { + Focus::RequestSection => Ok("request"), + Focus::ResponseSection => Ok("response"), + Focus::MessageList | Focus::StatusHeader => bail!("annotation panel must show details"), + } +} + +fn validate_session_name(name: &str) -> Result<()> { + if name.is_empty() || name.chars().count() > 80 || name.chars().any(char::is_control) { + bail!("session name must be one line containing 1 to 80 characters"); + } + Ok(()) +} + +fn annotation_tab(tab: DetailTab) -> &'static str { + match tab { + DetailTab::Headers => "headers", + DetailTab::Body => "body", + } +} + +fn invalid_annotation_column(index: usize, value: &str) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Text, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid annotation value: {value}"), + ) + .into(), + ) +} + +fn database_timestamp_ms(timestamp: SystemTime) -> i64 { + i64::try_from(timestamp_ms(timestamp)).unwrap_or(i64::MAX) +} + +fn sqlite_limit(limit: usize) -> i64 { + i64::try_from(limit).unwrap_or(i64::MAX) +} + +fn sqlite_index(index: usize) -> i64 { + i64::try_from(index).unwrap_or(i64::MAX) +} + +fn timestamp_ms(timestamp: SystemTime) -> u64 { + timestamp + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TransportType; + use serde_json::json; + + fn request(id: u64) -> JsonRpcMessage { + JsonRpcMessage { + id: Some(json!(id)), + method: Some("eth_chainId".to_string()), + params: Some(json!([])), + result: None, + error: None, + timestamp: UNIX_EPOCH + std::time::Duration::from_millis(id), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + } + } + + fn response(id: u64) -> JsonRpcMessage { + JsonRpcMessage { + id: Some(json!(id)), + method: None, + params: None, + result: Some(json!("0x1")), + error: None, + timestamp: UNIX_EPOCH + std::time::Duration::from_millis(id + 1), + direction: MessageDirection::Response, + transport: TransportType::Http, + headers: None, + } + } + + fn annotation(id: &str, exchange_index: usize) -> LineAnnotation { + LineAnnotation { + id: id.to_string(), + exchange_index, + panel: Focus::ResponseSection, + tab: DetailTab::Body, + start_line: 2, + end_line: 3, + message: format!("annotation {id}"), + text: vec!["first".to_string(), "second".to_string()], + } + } + + #[test] + fn records_and_pages_session_history() { + let mut store = HistoryStore::in_memory().unwrap(); + let session = store.create_session(Some("chain"), "http://node").unwrap(); + for id in 1..=3 { + store.record_message(&session.id, &request(id)).unwrap(); + store.record_message(&session.id, &response(id)).unwrap(); + } + + let recent = store.history(&session.id, 2, None).unwrap(); + let older = store.history(&session.id, 2, Some(recent[0].0)).unwrap(); + + assert_eq!( + recent.iter().map(|(index, _)| *index).collect::>(), + vec![1, 2] + ); + assert_eq!( + older.iter().map(|(index, _)| *index).collect::>(), + vec![0] + ); + assert!(recent[1].1.response.is_some()); + } + + #[test] + fn keeps_late_responses_in_their_original_session() { + let mut store = HistoryStore::in_memory().unwrap(); + let first = store.create_session(Some("first"), "").unwrap(); + let second = store.create_session(Some("second"), "").unwrap(); + store.record_message(&first.id, &request(1)).unwrap(); + + let recorded_session = store.record_message(&second.id, &response(1)).unwrap(); + + assert_eq!(recorded_session, first.id); + assert!(store.load_session(&first.id).unwrap().1[0] + .response + .is_some()); + assert!(store.load_session(&second.id).unwrap().1.is_empty()); + } + + #[test] + fn survives_reopening_the_database() { + let path = + std::env::temp_dir().join(format!("jsonrpc-debugger-{}.sqlite3", Uuid::new_v4())); + let session_id = { + let mut store = HistoryStore::open(&path).unwrap(); + let session = store.create_session(Some("saved"), "http://node").unwrap(); + store.record_message(&session.id, &request(1)).unwrap(); + store + .add_annotation(&session.id, &annotation("saved-note", 0)) + .unwrap(); + session.id + }; + + let store = HistoryStore::open(&path).unwrap(); + assert_eq!(store.load_session(&session_id).unwrap().1.len(), 1); + assert_eq!( + store.load_session(&session_id).unwrap().2[0].id, + "saved-note" + ); + drop(store); + fs::remove_file(path).unwrap(); + } + + #[test] + fn removes_annotations_individually() { + let mut store = HistoryStore::in_memory().unwrap(); + let session = store.create_session(Some("notes"), "").unwrap(); + store + .add_annotation(&session.id, &annotation("first", 0)) + .unwrap(); + store + .add_annotation(&session.id, &annotation("second", 0)) + .unwrap(); + store + .add_annotation(&session.id, &annotation("other-exchange", 1)) + .unwrap(); + + assert!(store.remove_annotation(&session.id, "first").unwrap()); + assert!(store.remove_annotation(&session.id, "second").unwrap()); + assert_eq!( + store.annotations(&session.id).unwrap()[0].id, + "other-exchange" + ); + } + + #[test] + fn renames_a_session() { + let mut store = HistoryStore::in_memory().unwrap(); + let session = store.create_session(Some("old"), "").unwrap(); + + assert!(store.rename_session(&session.id, "Refunds").unwrap()); + assert_eq!(store.session(&session.id).unwrap().unwrap().name, "Refunds"); + assert!(store.rename_session(&session.id, "").is_err()); + } + + #[test] + fn expands_an_existing_history_database_without_losing_sessions() { + let path = + std::env::temp_dir().join(format!("jsonrpc-debugger-{}.sqlite3", Uuid::new_v4())); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + target TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + CREATE TABLE exchanges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + rpc_id TEXT NOT NULL, + method TEXT, + complete INTEGER NOT NULL, + exchange_json TEXT NOT NULL, + UNIQUE(session_id, sequence) + ); + INSERT INTO sessions VALUES ('existing', 'Existing', 'http://node', 1, 1); + PRAGMA user_version = 1;", + ) + .unwrap(); + drop(connection); + + let store = HistoryStore::open(&path).unwrap(); + assert_eq!(store.session("existing").unwrap().unwrap().name, "Existing"); + store + .add_annotation("existing", &annotation("new-note", 0)) + .unwrap(); + assert_eq!(store.annotations("existing").unwrap().len(), 1); + drop(store); + fs::remove_file(path).unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index ad9569e..d2a3570 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; pub mod control; +pub mod history; pub mod proxy; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 2c09704..5eed5ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,12 +19,20 @@ use tokio::time::Instant; mod app; mod control; +mod history; mod proxy; mod ui; -use app::{App, AppMode, EditorMode, EditorMotion, EditorOperator, EditorTarget, TextEditor}; +use app::{ + App, AppMode, EditorMode, EditorMotion, EditorOperator, EditorTarget, LineAnnotation, Overlay, + TextEditor, +}; use control::{ControlAction, ControlCommand, ControlError, PendingDecision}; +use history::HistoryStore; use proxy::{ProxyServer, ProxyState}; +use uuid::Uuid; + +const AGENT_SKILL: &str = include_str!("../skills/jsonrpc-debugger/SKILL.md"); #[derive(Parser)] #[command(name = "jsonrpc-debugger", version)] @@ -41,6 +49,10 @@ struct Cli { /// Port for the local JSON-RPC control plane (defaults to proxy port + 1) #[arg(long)] control_port: Option, + + /// Print agent instructions and exit + #[arg(long)] + skill: bool, } fn copy_to_clipboard( @@ -87,6 +99,7 @@ struct ChangeWaiter { } struct Runtime { + history: HistoryStore, message_sender: mpsc::UnboundedSender, shared_app_mode: Arc>, pending_receiver: mpsc::UnboundedReceiver, @@ -293,14 +306,28 @@ fn save_editor(app: &mut App, request_result_sender: &mpsc::UnboundedSender { + terminal_area: ratatui::layout::Rect, + proxy_server: &'a mut Option>, + message_sender: &'a mpsc::UnboundedSender, + proxy_state: &'a ProxyState, + request_result_sender: &'a mpsc::UnboundedSender>, + history: &'a mut HistoryStore, +} + async fn handle_control_command( app: &mut App, command: ControlCommand, - proxy_server: &mut Option>, - message_sender: &mpsc::UnboundedSender, - proxy_state: &ProxyState, - request_result_sender: &mpsc::UnboundedSender>, + context: ControlContext<'_>, ) { + let ControlContext { + terminal_area, + proxy_server, + message_sender, + proxy_state, + request_result_sender, + history, + } = context; let ControlCommand { action, reply } = command; let result = match action { ControlAction::Discover => Ok(control::discovery(app.control_port)), @@ -308,31 +335,77 @@ async fn handle_control_command( ControlAction::WaitForChange { .. } => { unreachable!("wait commands are registered by run_app") } - ControlAction::GetPanel { focus } => { + ControlAction::GetPanel { + focus, + exchange_index, + tab, + } => { if app.app_mode != AppMode::Normal { Err(ControlError::invalid_params( "line references require normal mode", )) } else { - match ui::detail_lines_text(app, focus) { - Some(lines) => Ok(control::panel(focus, lines)), - None => Err(ControlError::invalid_params( - "panel must be request or response", - )), + detail_lines_at(app, focus, exchange_index, tab) + .map(|lines| control::panel(focus, lines)) + } + } + ControlAction::GetHistory { + limit, + session_id, + before, + } => active_session_id(app) + .map(|active| session_id.as_deref().unwrap_or(active)) + .ok_or_else(|| ControlError::runtime("No active session")) + .and_then(|session_id| { + history + .history(session_id, limit, before) + .map(control::stored_history) + .map_err(|error| ControlError::runtime(error.to_string())) + }), + ControlAction::ListSessions { limit } => history + .list_sessions(limit) + .map(control::sessions) + .map_err(|error| ControlError::runtime(error.to_string())), + ControlAction::CreateSession { name } => { + create_session(app, history, name.as_deref()).map(|_| control::state(app)) + } + ControlAction::SelectSession { id } => match select_session(app, history, &id) { + Ok(target_changed) => { + if target_changed && app.is_running { + restart_proxy(app, proxy_server, message_sender, proxy_state).await; } + Ok(control::state(app)) } + Err(error) => Err(error), + }, + ControlAction::RenameSession { id, name } => { + rename_session(app, history, &id, &name).map(|_| control::state(app)) } - ControlAction::GetHistory { limit } => Ok(control::history(app, limit)), - ControlAction::ExportSession => Ok(serde_json::to_value(control::export_session(app)) - .expect("session values are serializable")), + ControlAction::ExportSession => active_session_id(app) + .ok_or_else(|| ControlError::runtime("No active session")) + .and_then(|session_id| { + history + .export_session(session_id) + .and_then(|session| serde_json::to_value(session).map_err(Into::into)) + .map_err(|error| ControlError::runtime(error.to_string())) + }), ControlAction::ReplaySession { session } => match control::replay_session(session) { Ok(exchanges) => { let imported = exchanges.len(); - app.append_exchanges(exchanges); - Ok(serde_json::json!({ - "imported": imported, - "state": control::state(app), - })) + active_session_id(app) + .ok_or_else(|| ControlError::runtime("No active session")) + .and_then(|session_id| { + history + .append_exchanges(session_id, &exchanges) + .map_err(|error| ControlError::runtime(error.to_string())) + }) + .map(|_| { + app.append_exchanges(exchanges); + serde_json::json!({ + "imported": imported, + "state": control::state(app), + }) + }) } Err(error) => Err(error), }, @@ -371,6 +444,10 @@ async fn handle_control_command( app.set_focus(focus); Ok(control::state(app)) } + ControlAction::SetFullscreen { fullscreen } => { + app.set_panel_fullscreen(fullscreen); + Ok(control::state(app)) + } ControlAction::RevealLines { focus, start_line, @@ -385,6 +462,15 @@ async fn handle_control_command( match ui::detail_line_text(app, focus, start_line, end_line) { Some(text) => { app.reveal_lines(focus, start_line, end_line, text); + center_detail_range( + app, + terminal_area, + focus, + start_line, + end_line, + total_lines, + None, + ); Ok(control::state(app)) } None => Err(ControlError::invalid_params(format!( @@ -393,13 +479,45 @@ async fn handle_control_command( } } } + ControlAction::AnnotateLines { + focus, + exchange_index, + tab, + start_line, + end_line, + message, + } => match build_annotation( + app, + focus, + exchange_index, + tab, + start_line, + end_line, + &message, + ) { + Ok(annotation) => { + let value = control::annotation(&annotation); + match persist_annotation(app, history, annotation) { + Ok(()) => Ok(serde_json::json!({ + "annotation": value, + "state": control::state(app), + })), + Err(error) => Err(error), + } + } + Err(error) => Err(error), + }, ControlAction::ClearLineSelection => { app.clear_line_selection(); Ok(control::state(app)) } + ControlAction::RemoveAnnotation { id } => { + remove_annotation(app, history, &id).map(|_| control::state(app)) + } ControlAction::ScrollPanel { focus, lines } => { if focus == app::Focus::MessageList { - scroll_history(app, lines); + let visible_rows = ui::panel_visible_lines(terminal_area, app, focus); + scroll_history(app, lines, visible_rows); Ok(control::state(app)) } else if app.app_mode != AppMode::Normal { Err(ControlError::invalid_params( @@ -418,7 +536,18 @@ async fn handle_control_command( } else { let changed = app.proxy_config.target_url != url; if changed { + if let Some(session_id) = active_session_id(app) { + if let Err(error) = history.update_target(session_id, url) { + return send_control_reply( + reply, + Err(ControlError::runtime(error.to_string())), + ); + } + } app.proxy_config.target_url = url.to_string(); + if let Some(session) = &mut app.session { + session.target = url.to_string(); + } app.mark_changed(); } if changed && app.is_running { @@ -457,20 +586,261 @@ async fn handle_control_command( let _ = reply.send(result); } -fn scroll_history(app: &mut App, lines: i64) { - app.focus = app::Focus::MessageList; - match app.app_mode { - AppMode::Normal => { - let indices = app.filtered_exchange_indices(); - let Some(current) = indices +fn center_detail_range( + app: &mut App, + terminal_area: ratatui::layout::Rect, + panel: app::Focus, + start_line: usize, + end_line: usize, + total_lines: usize, + annotation_id: Option<&str>, +) { + let visible_lines = ui::panel_visible_lines(terminal_area, app, panel).max(1); + let annotations = app + .visible_annotations(panel) + .filter(|annotation| { + annotation.start_line != annotation.end_line && annotation.end_line <= total_lines + }) + .collect::>(); + let annotations_before = |line: usize| { + annotations + .iter() + .filter(|annotation| annotation.end_line < line) + .count() + }; + let range_start = start_line.saturating_sub(1) + annotations_before(start_line); + let source_range_end = end_line.saturating_sub(1) + annotations_before(end_line); + let range_end = annotation_id + .and_then(|id| { + annotations .iter() - .position(|index| *index == app.selected_exchange) - else { - return; - }; - let selected = offset_index(current, lines, indices.len()); - app.select_exchange(indices[selected]); - } + .filter(|annotation| annotation.end_line == end_line) + .position(|annotation| annotation.id == id) + .map(|position| end_line + annotations_before(end_line) + position) + }) + .unwrap_or(source_range_end); + let display_total = total_lines + annotations.len(); + let range_center = range_start + range_end.saturating_sub(range_start) / 2; + let viewport_center = visible_lines.saturating_sub(1) / 2; + let display_scroll = range_center + .saturating_sub(viewport_center) + .min(display_total.saturating_sub(visible_lines)); + let source_scroll = (0..total_lines) + .take_while(|source| { + *source + + annotations + .iter() + .filter(|annotation| annotation.end_line <= *source) + .count() + <= display_scroll + }) + .last() + .unwrap_or(0); + let scroll = match panel { + app::Focus::RequestSection => &mut app.request_details_scroll, + app::Focus::ResponseSection => &mut app.response_details_scroll, + app::Focus::MessageList | app::Focus::StatusHeader => return, + }; + if *scroll == source_scroll { + return; + } + *scroll = source_scroll; + app.mark_changed(); +} + +fn send_control_reply( + reply: oneshot::Sender, + result: control::ControlResult, +) { + let _ = reply.send(result); +} + +fn active_session_id(app: &App) -> Option<&str> { + app.session.as_ref().map(|session| session.id.as_str()) +} + +fn build_annotation( + app: &App, + panel: app::Focus, + exchange_index: Option, + tab: Option, + start_line: usize, + end_line: usize, + message: &str, +) -> Result { + if app.app_mode != AppMode::Normal { + return Err(ControlError::invalid_params( + "line annotations require normal mode", + )); + } + let message = message.trim(); + if message.is_empty() || message.chars().count() > 160 || message.chars().any(char::is_control) + { + return Err(ControlError::invalid_params( + "message must be one line containing 1 to 160 characters", + )); + } + + let exchange_index = exchange_index.unwrap_or(app.selected_exchange); + let tab = tab + .or_else(|| app.detail_tab(panel)) + .ok_or_else(|| ControlError::invalid_params("annotation panel must show details"))?; + let lines = detail_lines_at(app, panel, Some(exchange_index), Some(tab))?; + let total_lines = lines.len(); + let text = (start_line > 0 && end_line >= start_line && end_line <= total_lines) + .then(|| lines[start_line - 1..end_line].to_vec()) + .ok_or_else(|| { + ControlError::invalid_params(format!("line range must be within 1..={total_lines}")) + })?; + + Ok(LineAnnotation { + id: Uuid::new_v4().to_string(), + exchange_index, + panel, + tab, + start_line, + end_line, + message: message.to_string(), + text, + }) +} + +fn detail_lines_at( + app: &App, + panel: app::Focus, + exchange_index: Option, + tab: Option, +) -> Result, ControlError> { + let exchange_index = exchange_index.unwrap_or(app.selected_exchange); + if exchange_index >= app.exchanges.len() { + return Err(ControlError::invalid_params(format!( + "Exchange index {exchange_index} does not exist" + ))); + } + let tab = tab + .or_else(|| app.detail_tab(panel)) + .ok_or_else(|| ControlError::invalid_params("panel must be request or response"))?; + ui::detail_lines_text_at(app, panel, exchange_index, tab) + .ok_or_else(|| ControlError::invalid_params("panel must be request or response")) +} + +fn annotate_visual_selection( + app: &mut App, + history: &HistoryStore, + _terminal_area: ratatui::layout::Rect, + message: &str, +) -> Result<(), ControlError> { + let selection = app + .line_selection + .as_ref() + .filter(|_| app.visual_selection_active) + .cloned() + .ok_or_else(|| ControlError::invalid_params("No visual selection"))?; + let annotation = build_annotation( + app, + selection.panel, + Some(app.selected_exchange), + app.detail_tab(selection.panel), + selection.start_line, + selection.end_line, + message, + )?; + persist_annotation(app, history, annotation)?; + app.visual_selection_active = false; + app.mark_changed(); + Ok(()) +} + +fn persist_annotation( + app: &mut App, + history: &HistoryStore, + annotation: LineAnnotation, +) -> Result<(), ControlError> { + let session_id = active_session_id(app) + .ok_or_else(|| ControlError::runtime("No active session"))? + .to_string(); + history + .add_annotation(&session_id, &annotation) + .map_err(|error| ControlError::runtime(error.to_string()))?; + app.add_annotation(annotation); + Ok(()) +} + +fn remove_annotation( + app: &mut App, + history: &HistoryStore, + annotation_id: &str, +) -> Result<(), ControlError> { + let session_id = active_session_id(app) + .ok_or_else(|| ControlError::runtime("No active session"))? + .to_string(); + let removed = history + .remove_annotation(&session_id, annotation_id) + .map_err(|error| ControlError::runtime(error.to_string()))?; + if !removed { + return Err(ControlError::invalid_params(format!( + "Annotation not found: {annotation_id}" + ))); + } + app.remove_annotation(annotation_id); + Ok(()) +} + +fn rename_session( + app: &mut App, + history: &HistoryStore, + session_id: &str, + name: &str, +) -> Result<(), ControlError> { + let name = name.trim(); + let renamed = history + .rename_session(session_id, name) + .map_err(|error| ControlError::invalid_params(error.to_string()))?; + if !renamed { + return Err(ControlError::invalid_params(format!( + "Session not found: {session_id}" + ))); + } + app.rename_session(session_id, name.to_string()); + Ok(()) +} + +fn create_session( + app: &mut App, + history: &mut HistoryStore, + name: Option<&str>, +) -> Result<(), ControlError> { + if !app.pending_requests.is_empty() { + return Err(ControlError::runtime( + "Resolve pending requests before changing sessions", + )); + } + let session = history + .create_session(name, &app.proxy_config.target_url) + .map_err(|error| ControlError::runtime(error.to_string()))?; + app.activate_session(session, Vec::new(), Vec::new()); + Ok(()) +} + +fn select_session(app: &mut App, history: &HistoryStore, id: &str) -> Result { + if !app.pending_requests.is_empty() { + return Err(ControlError::runtime( + "Resolve pending requests before changing sessions", + )); + } + let (session, exchanges, annotations) = history + .load_session(id) + .map_err(|error| ControlError::runtime(error.to_string()))?; + let target_changed = app.proxy_config.target_url != session.target; + app.proxy_config.target_url = session.target.clone(); + app.activate_session(session, exchanges, annotations); + Ok(target_changed) +} + +fn scroll_history(app: &mut App, lines: i64, visible_rows: usize) { + app.set_focus(app::Focus::MessageList); + match app.app_mode { + AppMode::Normal => app.scroll_history(lines, visible_rows), AppMode::Paused | AppMode::Intercepting => { if app.pending_requests.is_empty() { return; @@ -545,17 +915,16 @@ fn allow_pending_request( async fn main() -> Result<()> { // Parse command line arguments let cli = Cli::parse(); + if cli.skill { + print!("{AGENT_SKILL}"); + return Ok(()); + } + let control_port = cli .control_port .or_else(|| cli.port.checked_add(1)) .ok_or_else(|| anyhow::anyhow!("--control-port is required when --port is 65535"))?; - - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let target = cli.target.unwrap_or_default(); // Create message channel for proxy communication let (message_sender, message_receiver) = mpsc::unbounded_channel(); @@ -565,7 +934,6 @@ async fn main() -> Result<()> { // Create the local agent control plane. let (control_sender, control_receiver) = mpsc::unbounded_channel(); - let control_handle = tokio::spawn(control::serve(control_port, control_sender)); // Create shared state for pause/intercept let shared_app_mode = Arc::new(Mutex::new(AppMode::Normal)); @@ -574,31 +942,41 @@ async fn main() -> Result<()> { pending_sender, }; + // Bind both ports before entering the TUI. A second debugger must not send through + // or expose the control plane of an older process that owns the same ports. + let initial_server = ProxyServer::new(cli.port, target.clone(), message_sender.clone()) + .with_state(proxy_state.clone()); + let initial_proxy_server = initial_server.bind()?; + let control_server = control::bind(control_port, control_sender).map_err(anyhow::Error::msg)?; + + let mut history = HistoryStore::open_default()?; + let session = history.create_session(None, &target)?; + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let initial_proxy_handle = tokio::spawn(initial_proxy_server); + let control_handle = tokio::spawn(async move { + control_server.await; + Ok(()) + }); + // Create app with receiver, using CLI arguments let mut app = App::new_with_receiver(message_receiver); // Override default config with CLI arguments app.proxy_config.listen_port = cli.port; app.control_port = control_port; - if let Some(target) = cli.target { - app.proxy_config.target_url = target; - } - - // Start the proxy server immediately since app.is_running is true by default - let initial_server = ProxyServer::new( - app.proxy_config.listen_port, - app.proxy_config.target_url.clone(), - message_sender.clone(), - ) - .with_state(proxy_state.clone()); - let initial_proxy_handle = tokio::spawn(async move { - if let Err(_e) = initial_server.start().await { - // Silent error handling - } - }); + app.proxy_config.target_url = target; + app.activate_session(session, Vec::new(), Vec::new()); let (request_result_sender, request_result_receiver) = mpsc::unbounded_channel(); let runtime = Runtime { + history, message_sender, shared_app_mode, pending_receiver, @@ -637,7 +1015,23 @@ async fn run_app( loop { // Check for new messages from proxy - let received_messages = app.check_for_new_messages(); + let messages = app.take_new_messages(); + let received_messages = !messages.is_empty(); + for message in messages { + let active_session_id = app + .session + .as_ref() + .map(|session| session.id.as_str()) + .unwrap_or_default(); + match runtime.history.record_message(active_session_id, &message) { + Ok(session_id) if session_id == active_session_id => app.add_message(message), + Ok(_) => {} + Err(error) => { + app.notice = Some(format!("Error: save history: {error}")); + app.add_message(message); + } + } + } // Sync app mode with shared state if let Ok(mut shared_mode) = runtime.shared_app_mode.try_lock() { @@ -670,10 +1064,14 @@ async fn run_app( handle_control_command( &mut app, command, - &mut runtime.proxy_server, - &runtime.message_sender, - &runtime.proxy_state, - &runtime.request_result_sender, + ControlContext { + terminal_area: terminal.size()?, + proxy_server: &mut runtime.proxy_server, + message_sender: &runtime.message_sender, + proxy_state: &runtime.proxy_state, + request_result_sender: &runtime.request_result_sender, + history: &mut runtime.history, + }, ) .await; received_control_command = true; @@ -693,6 +1091,58 @@ async fn run_app( if event::poll(std::time::Duration::from_millis(50))? { should_draw = true; let input_event = event::read()?; + + if matches!( + &input_event, + Event::Key(key) + if key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL) + ) { + stop_proxy(&mut runtime.proxy_server).await; + return Ok(()); + } + + if app.editor.is_none() + && app.input_mode == app::InputMode::Normal + && matches!( + &input_event, + Event::Key(key) + if key.code == KeyCode::Char('b') + && key.modifiers.contains(KeyModifiers::CONTROL) + ) + { + if app.overlay == Overlay::Prefix { + app.close_overlay(); + } else { + app.show_prefix(); + } + continue; + } + + if app.overlay != Overlay::None { + match input_event { + Event::Key(key) => { + if handle_overlay_key(terminal, &mut app, &mut runtime, key).await? { + return Ok(()); + } + } + Event::Mouse(mouse) => { + handle_mouse_event( + terminal, + &mut app, + mouse, + &mut runtime.proxy_server, + &runtime.message_sender, + &runtime.proxy_state, + &mut runtime.history, + ) + .await?; + } + _ => {} + } + continue; + } + if app.editor.is_some() { let action = match input_event { Event::Key(key) => app @@ -737,6 +1187,7 @@ async fn run_app( &mut runtime.proxy_server, &runtime.message_sender, &runtime.proxy_state, + &mut runtime.history, ) .await?; continue; @@ -763,10 +1214,52 @@ async fn run_app( } continue; } + app::InputMode::AnnotatingSelection => { + match key.code { + KeyCode::Enter => { + let message = app.input_buffer.clone(); + match annotate_visual_selection( + &mut app, + &runtime.history, + terminal.size()?, + &message, + ) { + Ok(()) => { + app.cancel_editing(); + app.notice = Some("Annotation added".to_string()); + } + Err(error) => { + app.notice = Some(format!("Error: {}", error.message)); + } + } + } + KeyCode::Esc => app.cancel_editing(), + KeyCode::Backspace => app.handle_backspace(), + KeyCode::Char(c) => app.handle_input_char(c), + _ => {} + } + continue; + } app::InputMode::EditingTarget => { match key.code { KeyCode::Enter => { + let target = app.input_buffer.trim().to_string(); + let session_id = active_session_id(&app).map(str::to_string); + if !target.is_empty() { + if let Some(session_id) = session_id { + if let Err(error) = + runtime.history.update_target(&session_id, &target) + { + app.notice = + Some(format!("Error: save target: {error}")); + continue; + } + } + } app.confirm_target_edit(); + if let Some(session) = &mut app.session { + session.target = app.proxy_config.target_url.clone(); + } // If proxy is running, restart it with new target if app.is_running { restart_proxy( @@ -792,6 +1285,64 @@ async fn run_app( } continue; } + app::InputMode::NamingSession => { + match key.code { + KeyCode::Enter => { + let name = app.input_buffer.trim().to_string(); + let name = (!name.is_empty()).then_some(name); + match create_session( + &mut app, + &mut runtime.history, + name.as_deref(), + ) { + Ok(()) => { + app.cancel_editing(); + app.notice = Some("Session created".to_string()); + } + Err(error) => { + app.notice = Some(format!("Error: {}", error.message)); + } + } + } + KeyCode::Esc => app.cancel_editing(), + KeyCode::Backspace => app.handle_backspace(), + KeyCode::Char(c) => app.handle_input_char(c), + _ => {} + } + continue; + } + app::InputMode::RenamingSession => { + match key.code { + KeyCode::Enter => { + let session_id = active_session_id(&app).map(str::to_string); + let name = app.input_buffer.clone(); + match session_id { + Some(session_id) => match rename_session( + &mut app, + &runtime.history, + &session_id, + &name, + ) { + Ok(()) => { + app.cancel_editing(); + app.notice = Some("Session renamed".to_string()); + } + Err(error) => { + app.notice = Some(format!("Error: {}", error.message)); + } + }, + None => { + app.notice = Some("Error: No active session".to_string()) + } + } + } + KeyCode::Esc => app.cancel_editing(), + KeyCode::Backspace => app.handle_backspace(), + KeyCode::Char(c) => app.handle_input_char(c), + _ => {} + } + continue; + } app::InputMode::Normal => { // Continue to normal key handling below @@ -800,34 +1351,20 @@ async fn run_app( // Normal mode key handling match key.code { + KeyCode::Esc => { + app.clear_line_selection(); + } KeyCode::Enter => { if app.app_mode == AppMode::Normal { copy_focused_panel(terminal, &app)?; } } - KeyCode::Char('q') => { - // Clean shutdown - if let Some(handle) = runtime.proxy_server.take() { - handle.abort(); - // Give it a moment to clean up - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - return Ok(()); - } - KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - // Clean shutdown - if let Some(handle) = runtime.proxy_server.take() { - handle.abort(); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - return Ok(()); - } KeyCode::Up => match app.app_mode { app::AppMode::Normal => { if app.is_message_list_focused() { app.select_previous(); } else { - scroll_focused_details(&mut app, -1, terminal.size()?); + move_focused_detail_cursor(&mut app, -1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -838,14 +1375,8 @@ async fn run_app( app::AppMode::Normal => { if app.is_message_list_focused() { app.select_next(); - } else if app.is_request_section_focused() { - if app.get_selected_exchange().is_some() { - app.request_details_scroll += 1; // Allow unlimited scrolling, UI will clamp - } - } else if app.is_response_section_focused() - && app.get_selected_exchange().is_some() - { - app.response_details_scroll += 1; // Allow unlimited scrolling, UI will clamp + } else { + move_focused_detail_cursor(&mut app, 1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -920,14 +1451,8 @@ async fn run_app( app::AppMode::Normal => { if app.is_message_list_focused() { app.select_previous(); - } else if app.is_request_section_focused() { - if app.request_details_scroll > 0 { - app.request_details_scroll -= 1; - } - } else if app.is_response_section_focused() - && app.response_details_scroll > 0 - { - app.response_details_scroll -= 1; + } else { + move_focused_detail_cursor(&mut app, -1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -940,7 +1465,7 @@ async fn run_app( if app.is_message_list_focused() { app.select_next(); } else { - scroll_focused_details(&mut app, 1, terminal.size()?); + move_focused_detail_cursor(&mut app, 1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -950,7 +1475,7 @@ async fn run_app( } KeyCode::Char('u') => match app.app_mode { app::AppMode::Normal => { - scroll_focused_details(&mut app, -10, terminal.size()?); + move_focused_detail_cursor(&mut app, -10, terminal.size()?); } app::AppMode::Paused | app::AppMode::Intercepting => { app.page_up_intercept_details() @@ -958,7 +1483,7 @@ async fn run_app( }, KeyCode::Char('d') => match app.app_mode { app::AppMode::Normal => { - scroll_focused_details(&mut app, 10, terminal.size()?); + move_focused_detail_cursor(&mut app, 10, terminal.size()?); } app::AppMode::Paused | app::AppMode::Intercepting => { app.page_down_intercept_details(); @@ -967,7 +1492,7 @@ async fn run_app( KeyCode::Char('G') => { match app.app_mode { app::AppMode::Normal => { - scroll_focused_details(&mut app, i64::MAX, terminal.size()?); + move_focused_detail_cursor(&mut app, i64::MAX, terminal.size()?); } app::AppMode::Paused | app::AppMode::Intercepting => { // For intercept mode, use a large number as max_lines @@ -977,25 +1502,25 @@ async fn run_app( } KeyCode::Char('g') => match app.app_mode { app::AppMode::Normal => { - scroll_focused_details(&mut app, i64::MIN, terminal.size()?); + move_focused_detail_cursor(&mut app, i64::MIN, terminal.size()?); } app::AppMode::Paused | app::AppMode::Intercepting => { app.goto_top_intercept_details() } }, - KeyCode::Char('t') => { - app.start_editing_target(); - } KeyCode::Char('/') => { app.start_filtering_requests(); } + KeyCode::Char('v') if app.app_mode == AppMode::Normal => { + toggle_visual_selection(&mut app); + } KeyCode::Char('n') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { match app.app_mode { app::AppMode::Normal => { if app.is_message_list_focused() { app.select_next(); } else { - scroll_focused_details(&mut app, 1, terminal.size()?); + move_focused_detail_cursor(&mut app, 1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -1009,7 +1534,7 @@ async fn run_app( if app.is_message_list_focused() { app.select_previous(); } else { - scroll_focused_details(&mut app, -1, terminal.size()?); + move_focused_detail_cursor(&mut app, -1, terminal.size()?); } } app::AppMode::Paused | app::AppMode::Intercepting => { @@ -1017,31 +1542,15 @@ async fn run_app( } } } - KeyCode::Char('s') => { - let desired_running = !app.is_running; - if set_proxy_running( - &mut app, - desired_running, - &mut runtime.proxy_server, - &runtime.message_sender, - &runtime.proxy_state, - ) - .await - { - terminal.clear()?; - terminal.draw(|f| ui::draw(f, &app))?; - } - } - // Pause/Intercept key bindings - KeyCode::Char('p') => { - app.toggle_pause_mode(); - terminal.clear()?; - } - KeyCode::Char('a') => { + KeyCode::Char('a') + if app.app_mode != AppMode::Normal && !app.pending_requests.is_empty() => + { // Allow selected pending request app.allow_selected_request(); } - KeyCode::Char('e') => { + KeyCode::Char('e') + if app.app_mode != AppMode::Normal && !app.pending_requests.is_empty() => + { if let Some(content) = app.get_pending_request_json() { app.open_editor(EditorTarget::PendingRequest, content); } @@ -1083,30 +1592,22 @@ async fn run_app( } } } - KeyCode::Char('c') => { - if (app.app_mode == AppMode::Paused - || app.app_mode == AppMode::Intercepting) - && !app.pending_requests.is_empty() - { - if let Some(content) = app.get_pending_response_template() { - app.open_editor(EditorTarget::PendingResponse, content); - } - } else { - let content = r#"{ - "jsonrpc": "2.0", - "method": "your_method", - "params": [], - "id": 1 -}"# - .to_string(); - app.open_editor(EditorTarget::NewRequest, content); + KeyCode::Char('c') + if app.app_mode != AppMode::Normal && !app.pending_requests.is_empty() => + { + if let Some(content) = app.get_pending_response_template() { + app.open_editor(EditorTarget::PendingResponse, content); } } - KeyCode::Char('b') => { + KeyCode::Char('b') + if app.app_mode != AppMode::Normal && !app.pending_requests.is_empty() => + { // Block selected pending request app.block_selected_request(); } - KeyCode::Char('r') => { + KeyCode::Char('r') + if app.app_mode != AppMode::Normal && !app.pending_requests.is_empty() => + { // Resume all pending requests app.resume_all_requests(); terminal.clear()?; @@ -1171,6 +1672,144 @@ async fn run_app( } } +async fn handle_overlay_key( + terminal: &mut Terminal>, + app: &mut App, + runtime: &mut Runtime, + key: KeyEvent, +) -> Result { + match app.overlay { + Overlay::Prefix => match key.code { + _ if is_fullscreen_key(&key) => { + app.close_overlay(); + app.set_panel_fullscreen(!app.panel_fullscreen); + } + KeyCode::Char('?') => app.show_help(), + KeyCode::Char('a') if app.visual_selection_active && app.line_selection.is_some() => { + app.close_overlay(); + app.start_annotating_selection(); + } + KeyCode::Char('d') => { + app.close_overlay(); + let annotation_id = app + .annotation_to_delete() + .map(|annotation| annotation.id.clone()); + match annotation_id { + Some(id) => match remove_annotation(app, &runtime.history, &id) { + Ok(()) => app.notice = Some("Annotation deleted".to_string()), + Err(error) => app.notice = Some(format!("Error: {}", error.message)), + }, + None => app.notice = Some("No annotation under cursor".to_string()), + } + } + KeyCode::Char('s') => match runtime.history.list_sessions(1000) { + Ok(sessions) => app.show_sessions(sessions), + Err(error) => { + app.close_overlay(); + app.notice = Some(format!("Error: list sessions: {error}")); + } + }, + KeyCode::Char('n') => { + app.close_overlay(); + app.start_naming_session(); + } + KeyCode::Char('R') => { + app.close_overlay(); + app.start_renaming_session(); + } + KeyCode::Char('c') => { + app.close_overlay(); + open_new_request(app); + } + KeyCode::Char('p') => { + app.close_overlay(); + app.toggle_pause_mode(); + terminal.clear()?; + } + KeyCode::Char('t') => { + app.close_overlay(); + app.start_editing_target(); + } + KeyCode::Char('x') => { + app.close_overlay(); + let desired_running = !app.is_running; + if set_proxy_running( + app, + desired_running, + &mut runtime.proxy_server, + &runtime.message_sender, + &runtime.proxy_state, + ) + .await + { + terminal.clear()?; + } + } + KeyCode::Char('q') => { + stop_proxy(&mut runtime.proxy_server).await; + return Ok(true); + } + KeyCode::Esc => app.close_overlay(), + _ => app.close_overlay(), + }, + Overlay::Help => app.close_overlay(), + Overlay::Sessions => match key.code { + KeyCode::Up | KeyCode::Char('k') => app.select_previous_session(), + KeyCode::Down | KeyCode::Char('j') => app.select_next_session(), + KeyCode::Enter => { + let session_id = app + .sessions + .get(app.selected_session) + .map(|session| session.id.clone()); + if let Some(session_id) = session_id { + match select_session(app, &runtime.history, &session_id) { + Ok(target_changed) if target_changed && app.is_running => { + restart_proxy( + app, + &mut runtime.proxy_server, + &runtime.message_sender, + &runtime.proxy_state, + ) + .await; + } + Ok(_) => {} + Err(error) => { + app.notice = Some(format!("Error: {}", error.message)); + } + } + } + } + KeyCode::Esc | KeyCode::Char('q') => app.close_overlay(), + _ => {} + }, + Overlay::None => {} + } + + Ok(false) +} + +fn is_fullscreen_key(key: &KeyEvent) -> bool { + key.code == KeyCode::Char('z') +} + +fn open_new_request(app: &mut App) { + let content = r#"{ + "jsonrpc": "2.0", + "method": "your_method", + "params": [], + "id": 1 +}"# + .to_string(); + app.open_editor(EditorTarget::NewRequest, content); +} + +async fn stop_proxy(proxy_server: &mut Option>) { + if let Some(handle) = proxy_server.take() { + handle.abort(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} + fn register_change_waiter( app: &App, command: ControlCommand, @@ -1225,8 +1864,44 @@ async fn handle_mouse_event( proxy_server: &mut Option>, message_sender: &mpsc::UnboundedSender, proxy_state: &ProxyState, + history: &mut HistoryStore, ) -> Result<()> { let area = terminal.size()?; + + if app.overlay != Overlay::None { + match mouse.kind { + MouseEventKind::ScrollUp if app.overlay == Overlay::Sessions => { + app.select_previous_session(); + } + MouseEventKind::ScrollDown if app.overlay == Overlay::Sessions => { + app.select_next_session(); + } + MouseEventKind::Down(MouseButton::Left) => { + match ui::mouse_action(area, app, mouse.column, mouse.row) { + Some(ui::MouseAction::SelectSession(index)) => { + let session_id = app.sessions.get(index).map(|session| session.id.clone()); + if let Some(session_id) = session_id { + match select_session(app, history, &session_id) { + Ok(target_changed) if target_changed && app.is_running => { + restart_proxy(app, proxy_server, message_sender, proxy_state) + .await; + } + Ok(_) => {} + Err(error) => { + app.notice = Some(format!("Error: {}", error.message)); + } + } + } + } + Some(ui::MouseAction::CloseOverlay) => app.close_overlay(), + _ => {} + } + } + _ => {} + } + return Ok(()); + } + let hovered_focus = ui::panel_focus(area, app, mouse.column, mouse.row); match mouse.kind { MouseEventKind::Moved => { @@ -1267,7 +1942,13 @@ async fn handle_mouse_event( } ui::MouseAction::SelectExchange(index) => { app.set_focus(app::Focus::MessageList); + let history_scroll = app.history_scroll_offset(ui::panel_visible_lines( + area, + app, + app::Focus::MessageList, + )); app.select_exchange(index); + app.history_scroll = Some(history_scroll); } ui::MouseAction::SelectPending(index) => { app.set_focus(app::Focus::MessageList); @@ -1279,6 +1960,7 @@ async fn handle_mouse_event( app.set_focus(app::Focus::RequestSection); app.request_tab = tab; app.request_details_scroll = 0; + app.request_details_cursor_line = 1; app.clear_line_selection(); app.mark_changed(); } @@ -1286,10 +1968,12 @@ async fn handle_mouse_event( app.set_focus(app::Focus::ResponseSection); app.response_tab = tab; app.response_details_scroll = 0; + app.response_details_cursor_line = 1; app.clear_line_selection(); app.mark_changed(); } ui::MouseAction::SelectLine { panel, line } => { + app.finish_visual_selection(); let extend = mouse.modifiers.contains(KeyModifiers::SHIFT); let (anchor, start_line, end_line) = app.line_selection_range(panel, line, extend); let Some(text) = ui::detail_line_text(app, panel, start_line, end_line) else { @@ -1297,6 +1981,8 @@ async fn handle_mouse_event( }; app.select_lines_from_anchor(panel, anchor, start_line, end_line, text); } + ui::MouseAction::SelectAnnotation { id } => app.focus_annotation(&id), + ui::MouseAction::SelectSession(_) | ui::MouseAction::CloseOverlay => {} ui::MouseAction::Focus(focus) => app.set_focus(focus), } @@ -1307,58 +1993,151 @@ fn scroll_panel(app: &mut App, focus: app::Focus, down: bool, visible_lines: usi const LINES_PER_TICK: usize = 3; if focus == app::Focus::MessageList { - for _ in 0..LINES_PER_TICK { - match (app.app_mode, down) { - (AppMode::Normal, true) => app.select_next(), - (AppMode::Normal, false) => app.select_previous(), - (AppMode::Paused | AppMode::Intercepting, true) => app.select_next_pending(), - (AppMode::Paused | AppMode::Intercepting, false) => app.select_previous_pending(), + match app.app_mode { + AppMode::Normal => { + let lines = if down { + LINES_PER_TICK as i64 + } else { + -(LINES_PER_TICK as i64) + }; + app.scroll_history(lines, visible_lines); + } + AppMode::Paused | AppMode::Intercepting => { + for _ in 0..LINES_PER_TICK { + if down { + app.select_next_pending(); + } else { + app.select_previous_pending(); + } + } } } return; } let max_scroll = match (app.app_mode, focus) { - (AppMode::Normal, app::Focus::RequestSection) => app - .get_request_details_content_lines() - .saturating_sub(visible_lines), - (AppMode::Normal, app::Focus::ResponseSection) => app - .get_response_details_content_lines() - .saturating_sub(visible_lines), + (AppMode::Normal, app::Focus::RequestSection) => ui::detail_max_source_scroll( + app, + focus, + app.get_request_details_content_lines(), + visible_lines, + ), + (AppMode::Normal, app::Focus::ResponseSection) => ui::detail_max_source_scroll( + app, + focus, + app.get_response_details_content_lines(), + visible_lines, + ), (AppMode::Paused | AppMode::Intercepting, app::Focus::RequestSection) => app .get_intercept_details_content_lines() .saturating_sub(visible_lines), _ => return, }; - let scroll = match (app.app_mode, focus) { - (AppMode::Normal, app::Focus::RequestSection) => &mut app.request_details_scroll, - (AppMode::Normal, app::Focus::ResponseSection) => &mut app.response_details_scroll, - (AppMode::Paused | AppMode::Intercepting, app::Focus::RequestSection) => { - &mut app.intercept_details_scroll - } - _ => return, - }; - let previous_scroll = *scroll; - *scroll = if down { - scroll.saturating_add(LINES_PER_TICK).min(max_scroll) - } else { - scroll.saturating_sub(LINES_PER_TICK) + let (previous_scroll, current_scroll) = { + let scroll = match (app.app_mode, focus) { + (AppMode::Normal, app::Focus::RequestSection) => &mut app.request_details_scroll, + (AppMode::Normal, app::Focus::ResponseSection) => &mut app.response_details_scroll, + (AppMode::Paused | AppMode::Intercepting, app::Focus::RequestSection) => { + &mut app.intercept_details_scroll + } + _ => return, + }; + let previous_scroll = *scroll; + *scroll = if down { + scroll.saturating_add(LINES_PER_TICK).min(max_scroll) + } else { + scroll.saturating_sub(LINES_PER_TICK) + }; + (previous_scroll, *scroll) }; - if previous_scroll != *scroll { + if previous_scroll != current_scroll { + let moved = current_scroll.abs_diff(previous_scroll); + match (app.app_mode, focus, down) { + (AppMode::Normal, app::Focus::RequestSection, true) => { + app.request_details_cursor_line = app + .request_details_cursor_line + .saturating_add(moved) + .min(app.get_request_details_content_lines().max(1)); + } + (AppMode::Normal, app::Focus::RequestSection, false) => { + app.request_details_cursor_line = + app.request_details_cursor_line.saturating_sub(moved).max(1); + } + (AppMode::Normal, app::Focus::ResponseSection, true) => { + app.response_details_cursor_line = app + .response_details_cursor_line + .saturating_add(moved) + .min(app.get_response_details_content_lines().max(1)); + } + (AppMode::Normal, app::Focus::ResponseSection, false) => { + app.response_details_cursor_line = app + .response_details_cursor_line + .saturating_sub(moved) + .max(1); + } + _ => {} + } + extend_visual_selection(app, focus); app.mark_changed(); } } -fn scroll_focused_details(app: &mut App, lines: i64, area: ratatui::layout::Rect) { - let focus = app.focus; - let Some(total_lines) = ui::detail_line_count(app, focus) else { +fn move_focused_detail_cursor(app: &mut App, lines: i64, area: ratatui::layout::Rect) { + let panel = app.focus; + let Some(total_lines) = ui::detail_line_count(app, panel) else { + return; + }; + let visible_lines = ui::panel_visible_lines(area, app, panel); + app.move_detail_cursor(panel, lines, total_lines, visible_lines); + extend_visual_selection(app, panel); +} + +fn extend_visual_selection(app: &mut App, panel: app::Focus) { + if !app.visual_selection_active { + return; + } + let Some(anchor_line) = app + .line_selection + .as_ref() + .filter(|selection| selection.panel == panel) + .map(|selection| selection.anchor_line) + else { + return; + }; + let Some(cursor_line) = app.detail_cursor_line(panel) else { return; }; - let visible_lines = ui::panel_visible_lines(area, app, focus); - let scroll_positions = total_lines.saturating_sub(visible_lines).saturating_add(1); + let start_line = anchor_line.min(cursor_line); + let end_line = anchor_line.max(cursor_line); + let Some(text) = ui::detail_line_text(app, panel, start_line, end_line) else { + return; + }; + + app.select_lines_from_anchor(panel, anchor_line, start_line, end_line, text); +} - app.scroll_panel_lines(focus, lines, scroll_positions); +fn toggle_visual_selection(app: &mut App) { + let panel = app.focus; + if !matches!( + panel, + app::Focus::RequestSection | app::Focus::ResponseSection + ) { + return; + } + if app.visual_selection_active { + app.clear_line_selection(); + return; + } + + let Some(cursor_line) = app.detail_cursor_line(panel) else { + return; + }; + let Some(text) = ui::detail_line_text(app, panel, cursor_line, cursor_line) else { + return; + }; + app.select_lines(panel, cursor_line, cursor_line, text); + app.start_visual_selection(); } async fn restart_proxy( @@ -1428,6 +2207,12 @@ mod tests { KeyEvent::new(code, KeyModifiers::NONE) } + #[test] + fn fullscreen_prefix_key_is_z() { + assert!(is_fullscreen_key(&key(KeyCode::Char('z')))); + assert!(!is_fullscreen_key(&key(KeyCode::Esc))); + } + #[test] fn inline_editor_supports_insert_and_vim_save() { let mut editor = TextEditor::new(EditorTarget::NewRequest, "{}".to_string()); @@ -1532,7 +2317,6 @@ mod tests { transport: app::TransportType::Http, headers: None, }); - scroll_panel(&mut app, app::Focus::RequestSection, true, 2); assert_eq!(app.request_details_scroll, 3); @@ -1541,7 +2325,31 @@ mod tests { } #[test] - fn keyboard_scroll_stops_at_the_visible_bottom() { + fn mouse_wheel_scrolls_request_list_without_changing_selection() { + let mut app = App::new(); + for id in 0..6 { + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(id)), + method: Some(format!("request_{id}")), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); + } + app.select_exchange(1); + + scroll_panel(&mut app, app::Focus::MessageList, true, 2); + + assert_eq!(app.selected_exchange, 1); + assert_eq!(app.history_scroll, Some(3)); + } + + #[test] + fn keyboard_cursor_stops_at_the_bottom_and_keeps_it_visible() { let mut app = App::new(); app.add_message(app::JsonRpcMessage { id: Some(serde_json::json!(1)), @@ -1561,11 +2369,154 @@ mod tests { .unwrap() .saturating_sub(visible_lines); - scroll_focused_details(&mut app, i64::MAX, area); + let total_lines = ui::detail_line_count(&app, app.focus).unwrap(); + move_focused_detail_cursor(&mut app, i64::MAX, area); + assert_eq!(app.request_details_cursor_line, total_lines); assert_eq!(app.request_details_scroll, bottom); - scroll_focused_details(&mut app, -1, area); - assert_eq!(app.request_details_scroll, bottom.saturating_sub(1)); + move_focused_detail_cursor(&mut app, -1, area); + assert_eq!(app.request_details_cursor_line, total_lines - 1); + assert_eq!(app.request_details_scroll, bottom); + } + + #[test] + fn visual_selection_extends_from_its_keyboard_anchor() { + let mut app = App::new(); + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(1)), + method: Some("eth_call".to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); + app.focus = app::Focus::RequestSection; + let area = ratatui::layout::Rect::new(0, 0, 120, 24); + + toggle_visual_selection(&mut app); + move_focused_detail_cursor(&mut app, 2, area); + + let selection = app.line_selection.as_ref().unwrap(); + assert_eq!(app.request_details_cursor_line, 3); + assert_eq!(selection.anchor_line, 1); + assert_eq!((selection.start_line, selection.end_line), (1, 3)); + assert_eq!(selection.text.len(), 3); + + toggle_visual_selection(&mut app); + assert!(app.line_selection.is_none()); + } + + #[test] + fn visual_selection_annotation_uses_the_persistent_annotation_path() { + let mut history = HistoryStore::in_memory().unwrap(); + let mut app = App::new(); + app.session = Some( + history + .create_session(Some("test"), "http://localhost:8090") + .unwrap(), + ); + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(1)), + method: Some("eth_call".to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); + app.focus = app::Focus::RequestSection; + let text = ui::detail_line_text(&app, app.focus, 2, 2).unwrap(); + app.select_lines(app::Focus::RequestSection, 2, 2, text); + app.start_visual_selection(); + + annotate_visual_selection( + &mut app, + &history, + ratatui::layout::Rect::new(0, 0, 120, 24), + "Check this method", + ) + .unwrap(); + + assert_eq!(app.annotations[0].message, "Check this method"); + assert!(!app.visual_selection_active); + let session_id = app.session.as_ref().unwrap().id.as_str(); + assert_eq!(history.annotations(session_id).unwrap(), app.annotations); + } + + #[test] + fn scrolling_does_not_change_a_fixed_line_reference() { + let mut app = App::new(); + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(1)), + method: Some("eth_call".to_string()), + params: Some(serde_json::json!([1, 2, 3, 4, 5, 6, 7, 8])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); + let text = ui::detail_line_text(&app, app::Focus::RequestSection, 2, 3).unwrap(); + app.reveal_lines(app::Focus::RequestSection, 2, 3, text.clone()); + app.add_annotation(LineAnnotation { + id: "annotation-1".to_string(), + exchange_index: 0, + panel: app::Focus::RequestSection, + tab: app::DetailTab::Body, + start_line: 2, + end_line: 3, + message: "Inspect this range".to_string(), + text, + }); + + scroll_panel(&mut app, app::Focus::RequestSection, true, 2); + + let selection = app.line_selection.as_ref().unwrap(); + assert!(!app.visual_selection_active); + assert_eq!((selection.start_line, selection.end_line), (2, 3)); + assert_eq!( + app.annotations + .first() + .map(|annotation| (annotation.start_line, annotation.end_line)), + Some((2, 3)) + ); + } + + #[test] + fn agent_line_reference_is_centered_in_the_panel() { + let mut app = App::new(); + let area = ratatui::layout::Rect::new(0, 0, 120, 50); + let visible_lines = ui::panel_visible_lines(area, &app, app::Focus::ResponseSection); + app.add_annotation(LineAnnotation { + id: "annotation-1".to_string(), + exchange_index: 0, + panel: app::Focus::ResponseSection, + tab: app::DetailTab::Body, + start_line: 17, + end_line: 21, + message: "Inspect this range".to_string(), + text: Vec::new(), + }); + + center_detail_range( + &mut app, + area, + app::Focus::ResponseSection, + 17, + 21, + 50, + Some("annotation-1"), + ); + + let display_scroll = app.response_details_scroll; + let viewport_center = display_scroll + visible_lines.saturating_sub(1) / 2; + assert_eq!(viewport_center, 18); } #[tokio::test] @@ -1625,6 +2576,17 @@ mod tests { transport: app::TransportType::Http, headers: None, }); + app.add_message(app::JsonRpcMessage { + id: Some(serde_json::json!(2)), + method: Some("net_version".to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: app::MessageDirection::Request, + transport: app::TransportType::Http, + headers: None, + }); let (message_sender, _) = mpsc::unbounded_channel(); let (pending_sender, _) = mpsc::unbounded_channel(); let proxy_state = ProxyState { @@ -1634,6 +2596,13 @@ mod tests { let (notice_sender, _) = mpsc::unbounded_channel(); let (reply, result) = tokio::sync::oneshot::channel(); let mut proxy_server = None; + let mut history = HistoryStore::in_memory().unwrap(); + app.session = Some( + history + .create_session(Some("test"), "http://localhost:8090") + .unwrap(), + ); + let terminal_area = ratatui::layout::Rect::new(0, 0, 120, 24); handle_control_command( &mut app, @@ -1643,10 +2612,14 @@ mod tests { }, reply, }, - &mut proxy_server, - &message_sender, - &proxy_state, - ¬ice_sender, + ControlContext { + terminal_area, + proxy_server: &mut proxy_server, + message_sender: &message_sender, + proxy_state: &proxy_state, + request_result_sender: ¬ice_sender, + history: &mut history, + }, ) .await; @@ -1664,15 +2637,106 @@ mod tests { }, reply, }, - &mut proxy_server, - &message_sender, - &proxy_state, - ¬ice_sender, + ControlContext { + terminal_area, + proxy_server: &mut proxy_server, + message_sender: &message_sender, + proxy_state: &proxy_state, + request_result_sender: ¬ice_sender, + history: &mut history, + }, ) .await; let state = result.await.unwrap().unwrap(); assert_eq!(state["lineSelection"]["text"], "Method: eth_call"); - assert_eq!(app.request_details_scroll, 1); + assert_eq!(app.request_details_scroll, 0); + let viewport = control::state(&app); + + let (reply, result) = tokio::sync::oneshot::channel(); + handle_control_command( + &mut app, + ControlCommand { + action: ControlAction::AnnotateLines { + focus: app::Focus::RequestSection, + exchange_index: Some(1), + tab: Some(app::DetailTab::Body), + start_line: 2, + end_line: 2, + message: "Inspect this method".to_string(), + }, + reply, + }, + ControlContext { + terminal_area, + proxy_server: &mut proxy_server, + message_sender: &message_sender, + proxy_state: &proxy_state, + request_result_sender: ¬ice_sender, + history: &mut history, + }, + ) + .await; + + let result = result.await.unwrap().unwrap(); + let annotation = &result["annotation"]; + let state = &result["state"]; + assert_eq!(annotation["message"], "Inspect this method"); + assert_eq!(annotation["text"], "Method: net_version"); + assert_eq!(annotation["exchangeIndex"], 1); + assert_eq!(state["selectedExchange"], viewport["selectedExchange"]); + assert_eq!(state["focus"], viewport["focus"]); + assert_eq!(state["scroll"], viewport["scroll"]); + assert_eq!(state["tabs"], viewport["tabs"]); + assert_eq!(state["lineSelection"], viewport["lineSelection"]); + let annotation_id = annotation["id"].as_str().unwrap().to_string(); + + let session_id = app.session.as_ref().unwrap().id.clone(); + let (reply, result) = tokio::sync::oneshot::channel(); + handle_control_command( + &mut app, + ControlCommand { + action: ControlAction::RenameSession { + id: session_id, + name: "Renamed test".to_string(), + }, + reply, + }, + ControlContext { + terminal_area, + proxy_server: &mut proxy_server, + message_sender: &message_sender, + proxy_state: &proxy_state, + request_result_sender: ¬ice_sender, + history: &mut history, + }, + ) + .await; + assert_eq!( + result.await.unwrap().unwrap()["session"]["name"], + "Renamed test" + ); + + let (reply, result) = tokio::sync::oneshot::channel(); + handle_control_command( + &mut app, + ControlCommand { + action: ControlAction::RemoveAnnotation { id: annotation_id }, + reply, + }, + ControlContext { + terminal_area, + proxy_server: &mut proxy_server, + message_sender: &message_sender, + proxy_state: &proxy_state, + request_result_sender: ¬ice_sender, + history: &mut history, + }, + ) + .await; + assert!(result.await.unwrap().unwrap()["annotations"] + .as_array() + .unwrap() + .is_empty()); } } diff --git a/src/proxy.rs b/src/proxy.rs index 9f13e34..95b296a 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,11 +1,14 @@ use crate::app::{ AppMode, JsonRpcMessage, MessageDirection, PendingRequest, ProxyDecision, TransportType, }; -use anyhow::Result; +use anyhow::{Context, Result}; use reqwest::Client; use serde_json::Value; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex}, +}; use uuid::Uuid; use tokio::sync::{mpsc, oneshot}; @@ -56,6 +59,11 @@ impl ProxyServer { } pub async fn start(&self) -> Result<()> { + self.bind()?.await; + Ok(()) + } + + pub fn bind(&self) -> Result + 'static> { let target_url = self.target_url.clone(); let client = self.client.clone(); let message_sender = self.message_sender.clone(); @@ -94,13 +102,11 @@ impl ProxyServer { let routes = proxy_route.with(cors); - // Use a simpler approach - just run the server - // The task abort from main.rs will handle shutdown - warp::serve(routes) - .run(([127, 0, 0, 1], self.listen_port)) - .await; - - Ok(()) + let address = ([127, 0, 0, 1], self.listen_port); + let (_, server) = warp::serve(routes) + .try_bind_ephemeral(address) + .with_context(|| format!("bind proxy port {}", self.listen_port))?; + Ok(server) } } diff --git a/src/ui.rs b/src/ui.rs index 1a45228..42ad31c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,15 +3,20 @@ use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ - Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Scrollbar, + Block, Borders, Cell, Clear, HighlightSpacing, List, ListItem, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap, }, Frame, }; -use crate::app::{App, AppMode, EditorMode, Focus, InputMode, JsonRpcExchange, TransportType}; +use crate::app::{ + request_matches_filter, App, AppMode, EditorMode, Focus, InputMode, JsonRpcExchange, + LineAnnotation, Overlay, TransportType, +}; + +const ANNOTATION_AMBER: Color = Color::Rgb(245, 166, 35); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum MouseAction { EditTarget, EditFilter, @@ -21,10 +26,16 @@ pub enum MouseAction { SelectRequestTab(usize), SelectResponseTab(usize), SelectLine { panel: Focus, line: usize }, + SelectAnnotation { id: String }, + SelectSession(usize), + CloseOverlay, Focus(Focus), } pub fn panel_focus(area: Rect, app: &App, column: u16, row: u16) -> Option { + if app.overlay != Overlay::None { + return None; + } let chunks = screen_chunks(area, app); let header = Layout::default() .direction(Direction::Horizontal) @@ -36,6 +47,9 @@ pub fn panel_focus(area: Rect, app: &App, column: u16, row: u16) -> Option Option usize { let chunks = screen_chunks(area, app); let main_height = chunks[1].height as usize; + if app.panel_fullscreen { + return match focus { + Focus::RequestSection | Focus::ResponseSection => main_height.saturating_sub(2), + Focus::MessageList | Focus::StatusHeader => main_height.saturating_sub(3), + }; + } match (app.app_mode, focus) { (AppMode::Normal, Focus::RequestSection | Focus::ResponseSection) => { (main_height / 2).saturating_sub(2) @@ -76,7 +96,36 @@ pub fn panel_visible_lines(area: Rect, app: &App, focus: Focus) -> usize { } } +pub fn detail_max_source_scroll( + app: &App, + panel: Focus, + total_lines: usize, + visible_lines: usize, +) -> usize { + let annotations = detail_annotations(app, panel); + let block_count = annotations + .iter() + .filter(|annotation| annotation.start_line != annotation.end_line) + .count(); + let max_display_scroll = (total_lines + block_count).saturating_sub(visible_lines); + (0..total_lines) + .take_while(|source| detail_display_scroll(*source, &annotations) <= max_display_scroll) + .last() + .unwrap_or(0) +} + pub fn mouse_action(area: Rect, app: &App, column: u16, row: u16) -> Option { + match app.overlay { + Overlay::Help => return Some(MouseAction::CloseOverlay), + Overlay::Sessions => { + return session_at_row(area, app, column, row) + .map(MouseAction::SelectSession) + .or(Some(MouseAction::CloseOverlay)); + } + Overlay::Prefix => return Some(MouseAction::CloseOverlay), + Overlay::None => {} + } + let chunks = screen_chunks(area, app); let header_chunks = Layout::default() .direction(Direction::Horizontal) @@ -92,6 +141,9 @@ pub fn mouse_action(area: Rect, app: &App, column: u16, row: u16) -> Option Option Option { + match (app.app_mode, app.focus) { + (AppMode::Normal, Focus::MessageList) => message_list_action(area, app, row), + (AppMode::Normal, Focus::RequestSection) => request_details_action(area, app, column, row), + (AppMode::Normal, Focus::ResponseSection) => { + response_details_action(area, app, column, row) + } + (AppMode::Paused | AppMode::Intercepting, Focus::MessageList) => { + pending_list_action(area, app, row) + } + (AppMode::Paused | AppMode::Intercepting, _) => { + Some(MouseAction::Focus(Focus::RequestSection)) + } + (_, Focus::StatusHeader) => status_header_action(area, column, row), + } +} + +pub fn session_at_row(area: Rect, app: &App, column: u16, row: u16) -> Option { + let popup = session_popup(area); + if !contains(popup, column, row) { + return None; + } + let first_row = popup.y.saturating_add(1); + let visible_rows = popup.height.saturating_sub(2) as usize; + if row < first_row || row >= first_row.saturating_add(visible_rows as u16) { + return None; + } + let offset = app + .selected_session + .saturating_sub(visible_rows.saturating_sub(1)); + let index = offset + row.saturating_sub(first_row) as usize; + (index < app.sessions.len()).then_some(index) +} + fn screen_chunks(area: Rect, app: &App) -> std::rc::Rc<[Rect]> { Layout::default() .direction(Direction::Vertical) @@ -211,11 +297,7 @@ fn message_list_action(area: Rect, app: &App, row: u16) -> Option { return Some(MouseAction::Focus(Focus::MessageList)); } - let selected = indices - .iter() - .position(|index| *index == app.selected_exchange) - .unwrap_or(0); - let offset = selected.saturating_sub(visible_rows.saturating_sub(1)); + let offset = app.history_scroll_offset(visible_rows); let clicked = offset + row.saturating_sub(first_row) as usize; let Some(index) = indices.get(clicked) else { return Some(MouseAction::Focus(Focus::MessageList)); @@ -235,13 +317,11 @@ fn pending_list_action(area: Rect, app: &App, row: u16) -> Option { .iter() .enumerate() .filter(|(_, pending)| { - app.filter_text.is_empty() - || pending - .original_request - .method - .as_deref() - .unwrap_or("") - .contains(&app.filter_text) + request_matches_filter( + pending.original_request.method.as_deref(), + pending.original_request.id.as_ref(), + &app.filter_text, + ) }) .nth(row.saturating_sub(first_row) as usize) .map(|(index, _)| index); @@ -260,53 +340,205 @@ fn request_details_action(area: Rect, app: &App, column: u16, row: u16) -> Optio + usize::from(exchange.and_then(|value| value.id.as_ref()).is_some()); let content = request_detail_lines(app); - let clicked = clicked_detail_line(area, row, app.request_details_scroll, &content); + let annotations = detail_annotations(app, Focus::RequestSection); + let clicked = clicked_detail_row( + area, + column, + row, + app.request_details_scroll, + &content, + &annotations, + ); let has_request = exchange.and_then(|value| value.request.as_ref()).is_some(); - if has_request && clicked == Some(tab_line + 1) { + if has_request && clicked == Some(ClickedDetail::Line(tab_line + 1)) { return tab_action(area, column, detail_gutter_width(content.len())) .map(MouseAction::SelectRequestTab); } clicked - .map(|line| MouseAction::SelectLine { - panel: Focus::RequestSection, - line, + .map(|clicked| match clicked { + ClickedDetail::Line(line) => MouseAction::SelectLine { + panel: Focus::RequestSection, + line, + }, + ClickedDetail::Annotation(id) => MouseAction::SelectAnnotation { id }, }) .or(Some(MouseAction::Focus(Focus::RequestSection))) } fn response_details_action(area: Rect, app: &App, column: u16, row: u16) -> Option { let content = response_detail_lines(app); - let clicked = clicked_detail_line(area, row, app.response_details_scroll, &content); + let annotations = detail_annotations(app, Focus::ResponseSection); + let clicked = clicked_detail_row( + area, + column, + row, + app.response_details_scroll, + &content, + &annotations, + ); let has_response = app .get_selected_exchange() .and_then(|exchange| exchange.response.as_ref()) .is_some(); - if has_response && clicked == Some(2) { + if has_response && clicked == Some(ClickedDetail::Line(2)) { return tab_action(area, column, detail_gutter_width(content.len())) .map(MouseAction::SelectResponseTab); } clicked - .map(|line| MouseAction::SelectLine { - panel: Focus::ResponseSection, - line, + .map(|clicked| match clicked { + ClickedDetail::Line(line) => MouseAction::SelectLine { + panel: Focus::ResponseSection, + line, + }, + ClickedDetail::Annotation(id) => MouseAction::SelectAnnotation { id }, }) .or(Some(MouseAction::Focus(Focus::ResponseSection))) } -fn clicked_detail_line(area: Rect, row: u16, scroll: usize, content: &[Line<'_>]) -> Option { +#[derive(Debug, Clone, PartialEq, Eq)] +enum ClickedDetail { + Line(usize), + Annotation(String), +} + +#[derive(Debug, Clone, Copy)] +enum DetailRow<'a> { + Line(usize), + Annotation(&'a LineAnnotation), +} + +struct InlineAnnotation<'a> { + annotation: &'a LineAnnotation, + message: String, + width: usize, +} + +fn inline_annotation_layout<'a>( + line_width: usize, + annotations: &[&'a LineAnnotation], + max_width: usize, +) -> Vec> { + const PREFIX: &str = " ◆ "; + let prefix_width = Line::from(PREFIX).width(); + let available = max_width.saturating_sub(line_width); + let visible = annotations.len().min(available / prefix_width); + if visible == 0 { + return Vec::new(); + } + + let share = available / visible; + annotations + .iter() + .take(visible) + .map(|annotation| { + let message = truncate_to_width(&annotation.message, share - prefix_width); + let width = prefix_width + Line::from(message.as_str()).width(); + InlineAnnotation { + annotation, + message, + width, + } + }) + .collect() +} + +fn truncate_to_width(value: &str, max_width: usize) -> String { + if Line::from(value).width() <= max_width { + return value.to_string(); + } + if max_width == 0 { + return String::new(); + } + + let mut result = String::new(); + for character in value.chars() { + result.push(character); + if Line::from(result.as_str()).width() >= max_width { + result.pop(); + break; + } + } + result.push('…'); + result +} + +fn detail_rows<'a>( + content_len: usize, + annotations: &'a [&'a LineAnnotation], +) -> Vec> { + let mut rows = Vec::with_capacity(content_len + annotations.len()); + for source_index in 0..content_len { + rows.push(DetailRow::Line(source_index)); + rows.extend( + annotations + .iter() + .copied() + .filter(|annotation| { + annotation.start_line != annotation.end_line + && annotation.end_line == source_index + 1 + }) + .map(DetailRow::Annotation), + ); + } + rows +} + +fn clicked_detail_row( + area: Rect, + column: u16, + row: u16, + scroll: usize, + content: &[Line<'_>], + annotations: &[&LineAnnotation], +) -> Option { if row <= area.y || row >= area.y.saturating_add(area.height).saturating_sub(1) { return None; } let width = usize::from(area.width.saturating_sub(2)).max(1); let gutter_width = detail_gutter_width(content.len()); + let clicked_column = usize::from(column.saturating_sub(area.x.saturating_add(1))); let mut visible_row = usize::from(row.saturating_sub(area.y + 1)); - for (index, line) in content.iter().enumerate().skip(scroll) { - let height = (line.width() + gutter_width).max(1).div_ceil(width); + let display_scroll = detail_display_scroll(scroll, annotations); + for detail_row in detail_rows(content.len(), annotations) + .into_iter() + .skip(display_scroll) + { + let line_width = match detail_row { + DetailRow::Line(index) => content[index].width(), + DetailRow::Annotation(annotation) => { + Line::from(format!("◆ {}", annotation.message)).width() + } + }; + let rendered_width = line_width + gutter_width; + let height = rendered_width.max(1).div_ceil(width); if visible_row < height { - return Some(index + 1); + if let DetailRow::Line(index) = detail_row { + let inline = annotations + .iter() + .copied() + .filter(|annotation| { + annotation.start_line == annotation.end_line + && annotation.end_line == index + 1 + }) + .collect::>(); + let mut start = rendered_width; + for note in inline_annotation_layout(start, &inline, width) { + let end = start + note.width; + if visible_row == 0 && (start..end).contains(&clicked_column) { + return Some(ClickedDetail::Annotation(note.annotation.id.clone())); + } + start = end; + } + } + return Some(match detail_row { + DetailRow::Line(index) => ClickedDetail::Line(index + 1), + DetailRow::Annotation(annotation) => { + ClickedDetail::Annotation(annotation.id.clone()) + } + }); } visible_row -= height; } @@ -314,6 +546,20 @@ fn clicked_detail_line(area: Rect, row: u16, scroll: usize, content: &[Line<'_>] None } +fn detail_annotations(app: &App, panel: Focus) -> Vec<&LineAnnotation> { + app.visible_annotations(panel).collect() +} + +fn detail_display_scroll(source_scroll: usize, annotations: &[&LineAnnotation]) -> usize { + source_scroll + + annotations + .iter() + .filter(|annotation| { + annotation.start_line != annotation.end_line && annotation.end_line <= source_scroll + }) + .count() +} + fn tab_action(area: Rect, column: u16, gutter_width: usize) -> Option { let gutter_width = u16::try_from(gutter_width).unwrap_or(u16::MAX); let column = column.checked_sub(area.x.saturating_add(1 + gutter_width))?; @@ -515,13 +761,12 @@ pub fn draw(f: &mut Frame, app: &App) { draw_header(f, chunks[0], app); - // Choose layout based on app mode - match app.app_mode { - AppMode::Normal => { - draw_main_content(f, chunks[1], app); - } - AppMode::Paused | AppMode::Intercepting => { - draw_intercept_content(f, chunks[1], app); + if app.panel_fullscreen { + draw_fullscreen_panel(f, chunks[1], app); + } else { + match app.app_mode { + AppMode::Normal => draw_main_content(f, chunks[1], app), + AppMode::Paused | AppMode::Intercepting => draw_intercept_content(f, chunks[1], app), } } @@ -541,11 +786,126 @@ pub fn draw(f: &mut Frame, app: &App) { if app.editor.is_some() { draw_text_editor(f, app); - } else if app.input_mode == InputMode::FilteringRequests { - draw_input_dialog(f, app, "Filter Requests", "Filter"); + } else { + match app.input_mode { + InputMode::FilteringRequests => draw_input_dialog(f, app, "Filter Requests", "Filter"), + InputMode::AnnotatingSelection => { + let title = if app.selection_overlaps_annotation() { + "Add Another Annotation" + } else { + "Add Annotation" + }; + draw_input_dialog(f, app, title, "Message") + } + InputMode::NamingSession => draw_input_dialog(f, app, "New Session", "Name (optional)"), + InputMode::RenamingSession => draw_input_dialog(f, app, "Rename Session", "Name"), + InputMode::Normal | InputMode::EditingTarget => {} + } + } + + match app.overlay { + Overlay::Help => draw_keybind_help(f), + Overlay::Sessions => draw_sessions(f, app), + Overlay::None | Overlay::Prefix => {} } } +fn draw_keybind_help(f: &mut Frame) { + let popup = centered_popup(f.size(), 90, 65); + let lines = vec![ + Line::from(Span::styled( + "Global commands", + Style::default().fg(Color::Cyan), + )), + Line::from("^B s sessions ^B n new session"), + Line::from("^B R rename session"), + Line::from("^B a annotate visual selection"), + Line::from("^B c create request ^B p pause interception"), + Line::from("^B t target ^B x start/stop proxy"), + Line::from("^B z fullscreen panel"), + Line::from("^B d delete focused annotation"), + Line::from("^B q quit ^B ? this help"), + Line::from(""), + Line::from(Span::styled( + "Focused pending request", + Style::default().fg(Color::Cyan), + )), + Line::from("a allow b block e body h headers"), + Line::from("c complete r resume all"), + Line::from(""), + Line::from(Span::styled("Navigation", Style::default().fg(Color::Cyan))), + Line::from("↑/↓ or j/k navigate Tab focus h/l tabs / filter"), + Line::from("d/u page g/G top/bottom Enter copy Markdown"), + Line::from("Details: v visual select j/k extend Esc clear"), + ]; + let block = Block::default() + .borders(Borders::ALL) + .title("Keybinds") + .border_style(Style::default().fg(Color::Yellow)); + f.render_widget(Clear, popup); + f.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn draw_sessions(f: &mut Frame, app: &App) { + let popup = session_popup(f.size()); + let active = app.session.as_ref().map(|session| session.id.as_str()); + let items = app + .sessions + .iter() + .map(|session| { + let marker = if active == Some(session.id.as_str()) { + "●" + } else { + " " + }; + let target = if session.target.is_empty() { + "no target" + } else { + session.target.as_str() + }; + ListItem::new(format!( + "{marker} {} {} exchanges {target}", + session.name, session.exchange_count + )) + }) + .collect::>(); + let mut state = ratatui::widgets::ListState::default(); + state.select((!items.is_empty()).then_some(app.selected_session)); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Sessions — Enter/click open · Esc close"), + ) + .highlight_style(Style::default().bg(Color::Cyan).fg(Color::Black)) + .highlight_symbol("› "); + f.render_widget(Clear, popup); + f.render_stateful_widget(list, popup, &mut state); +} + +fn session_popup(area: Rect) -> Rect { + centered_popup(area, 82, 70) +} + +fn centered_popup(area: Rect, width_percent: u16, height_percent: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + fn draw_text_editor(f: &mut Frame, app: &App) { let Some(editor) = &app.editor else { return; @@ -850,22 +1210,26 @@ fn draw_main_content(f: &mut Frame, area: Rect, app: &App) { draw_details_split(f, chunks[1], app); } +fn draw_fullscreen_panel(f: &mut Frame, area: Rect, app: &App) { + match (app.app_mode, app.focus) { + (AppMode::Normal, Focus::MessageList) => draw_message_list(f, area, app), + (AppMode::Normal, Focus::RequestSection) => draw_request_details(f, area, app), + (AppMode::Normal, Focus::ResponseSection) => draw_response_details(f, area, app), + (AppMode::Paused | AppMode::Intercepting, Focus::MessageList) => { + draw_pending_requests(f, area, app) + } + (AppMode::Paused | AppMode::Intercepting, _) => { + draw_intercept_request_details(f, area, app) + } + (_, Focus::StatusHeader) => draw_status_header(f, area, app), + } +} + fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { let filtered: Vec<(usize, &JsonRpcExchange)> = app - .exchanges - .iter() - .enumerate() - .filter(|(_, exchange)| { - if app.filter_text.is_empty() { - true - } else { - exchange - .method - .as_deref() - .unwrap_or("") - .contains(&app.filter_text) - } - }) + .filtered_exchange_indices() + .into_iter() + .map(|index| (index, &app.exchanges[index])) .collect(); if filtered.is_empty() { @@ -875,7 +1239,7 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { app.proxy_config.listen_port ) } else { - "Press 's' to start the proxy and begin capturing messages".to_string() + "Press Ctrl-B x to start the proxy and begin capturing messages".to_string() }; let mut block = Block::default().borders(Borders::ALL).title("Requests"); @@ -902,6 +1266,8 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { .iter() .position(|(index, _)| *index == app.selected_exchange) .unwrap_or(0); + let visible_rows = area.height.saturating_sub(3) as usize; + let offset = app.history_scroll_offset(visible_rows); let highlight_style = if matches!(app.focus, Focus::MessageList) { Style::default() @@ -924,6 +1290,8 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { let rows: Vec = filtered .iter() + .skip(offset) + .take(visible_rows) .map(|(_, exchange)| { let transport_symbol = match exchange.transport { TransportType::Http => "HTTP", @@ -1005,14 +1373,21 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) { .header(header) .block(table_block) .highlight_style(highlight_style) - .highlight_symbol(" "); + .highlight_symbol(" ") + .highlight_spacing(HighlightSpacing::Always); let mut table_state = TableState::default(); - table_state.select(Some(selected_position)); + table_state.select( + selected_position + .checked_sub(offset) + .filter(|position| *position < visible_rows), + ); f.render_stateful_widget(table, area, &mut table_state); if filtered.len() > 1 { - let mut scrollbar_state = ScrollbarState::new(filtered.len()).position(selected_position); + let mut scrollbar_state = ScrollbarState::new(filtered.len()) + .position(offset) + .viewport_content_length(visible_rows); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) @@ -1045,6 +1420,23 @@ pub fn detail_lines_text(app: &App, panel: Focus) -> Option> { Some(lines.iter().map(line_text).collect()) } +pub fn detail_lines_text_at( + app: &App, + panel: Focus, + exchange_index: usize, + tab: crate::app::DetailTab, +) -> Option> { + let exchange = app.exchanges.get(exchange_index); + let tab = usize::from(tab == crate::app::DetailTab::Body); + let lines = match panel { + Focus::RequestSection => request_detail_lines_for(exchange, tab, false), + Focus::ResponseSection => response_detail_lines_for(exchange, tab, false), + Focus::MessageList | Focus::StatusHeader => return None, + }; + + Some(lines.iter().map(line_text).collect()) +} + pub fn detail_line_text( app: &App, panel: Focus, @@ -1095,16 +1487,108 @@ fn highlight_selected_lines( .collect() } -fn number_detail_lines(lines: Vec>) -> Vec> { +fn annotate_detail_lines(lines: Vec>, app: &App, panel: Focus) -> Vec> { + let annotations = detail_annotations(app, panel); + if annotations.is_empty() { + return lines; + } + + let range_style = Style::default().bg(Color::Rgb(44, 34, 14)); + lines + .into_iter() + .enumerate() + .map(|(index, line)| { + let line_number = index + 1; + if !annotations.iter().any(|annotation| { + (annotation.start_line..=annotation.end_line).contains(&line_number) + }) { + return line; + } + line.patch_style(range_style) + }) + .collect() +} + +fn insert_annotation_lines( + lines: Vec>, + app: &App, + panel: Focus, + width: usize, +) -> Vec> { + let annotations = detail_annotations(app, panel); + if annotations.is_empty() { + return lines; + } + + let number_width = lines.len().max(1).to_string().len(); + let mut displayed = Vec::with_capacity(lines.len() + annotations.len()); + for (source_index, mut line) in lines.into_iter().enumerate() { + let inline = annotations + .iter() + .copied() + .filter(|annotation| { + annotation.start_line == annotation.end_line + && annotation.end_line == source_index + 1 + }) + .collect::>(); + for note in inline_annotation_layout(line.width(), &inline, width) { + line.spans.extend([ + Span::styled(" ◆ ", Style::default().fg(ANNOTATION_AMBER)), + Span::styled(note.message, Style::default().fg(ANNOTATION_AMBER)), + ]); + } + displayed.push(line); + for annotation in annotations.iter().filter(|annotation| { + annotation.start_line != annotation.end_line && annotation.end_line == source_index + 1 + }) { + displayed.push(Line::from(vec![ + Span::styled( + format!(" {:>number_width$} ╰─", ""), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + "◆ ", + Style::default() + .fg(ANNOTATION_AMBER) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + annotation.message.clone(), + Style::default().fg(ANNOTATION_AMBER), + ), + ])); + } + } + displayed +} + +fn number_detail_lines( + lines: Vec>, + cursor_line: Option, +) -> Vec> { let number_width = lines.len().max(1).to_string().len(); lines .into_iter() .enumerate() .map(|(index, line)| { - let mut spans = vec![Span::styled( - format!("{:>number_width$} │ ", index + 1), - Style::default().fg(Color::DarkGray), - )]; + let line_number = index + 1; + let cursor = if cursor_line == Some(line_number) { + Span::styled( + "›", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(" ") + }; + let mut spans = vec![ + cursor, + Span::styled( + format!("{line_number:>number_width$} │ "), + Style::default().fg(Color::DarkGray), + ), + ]; spans.extend(line.spans); Line::from(spans).style(line.style) }) @@ -1112,7 +1596,7 @@ fn number_detail_lines(lines: Vec>) -> Vec> { } fn detail_gutter_width(line_count: usize) -> usize { - line_count.max(1).to_string().len() + 3 + line_count.max(1).to_string().len() + 4 } fn detail_title(title: &str, app: &App, panel: Focus) -> String { @@ -1133,7 +1617,19 @@ fn detail_title(title: &str, app: &App, panel: Focus) -> String { } pub fn request_detail_lines(app: &App) -> Vec> { - if let Some(exchange) = app.get_selected_exchange() { + request_detail_lines_for( + app.get_selected_exchange(), + app.request_tab, + matches!(app.focus, Focus::RequestSection), + ) +} + +fn request_detail_lines_for( + exchange: Option<&crate::app::JsonRpcExchange>, + tab: usize, + focused: bool, +) -> Vec> { + if let Some(exchange) = exchange { let mut lines = Vec::new(); // Basic exchange info @@ -1166,13 +1662,13 @@ pub fn request_detail_lines(app: &App) -> Vec> { ))); lines.push(build_tab_line( &["Headers", "Body"], - app.request_tab, - matches!(app.focus, Focus::RequestSection), + tab, + focused, exchange.request.is_some(), )); if let Some(request) = &exchange.request { - if app.request_tab == 0 { + if tab == 0 { // Show headers regardless of focus state lines.push(Line::from("")); match &request.headers { @@ -1228,20 +1724,33 @@ pub fn request_detail_lines(app: &App) -> Vec> { } fn draw_request_details(f: &mut Frame, area: Rect, app: &App) { - let content = number_detail_lines(request_detail_lines(app)); - let content = highlight_selected_lines(content, app, Focus::RequestSection); - - // Calculate visible area for scrolling let inner_area = area.inner(&Margin { vertical: 1, horizontal: 1, }); + let cursor_line = app + .detail_cursor_line(Focus::RequestSection) + .filter(|_| app.focus == Focus::RequestSection); + let content = annotate_detail_lines(request_detail_lines(app), app, Focus::RequestSection); + let content = highlight_selected_lines(content, app, Focus::RequestSection); + let content = number_detail_lines(content, cursor_line); + let source_lines = content.len(); + let content = insert_annotation_lines( + content, + app, + Focus::RequestSection, + usize::from(inner_area.width), + ); + + // Calculate visible area for scrolling let visible_lines = inner_area.height as usize; let total_lines = content.len(); // Apply scrolling offset let max_scroll = total_lines.saturating_sub(visible_lines); - let start_line = app.request_details_scroll.min(max_scroll); + let annotations = detail_annotations(app, Focus::RequestSection); + let start_line = + detail_display_scroll(app.request_details_scroll, &annotations).min(max_scroll); let end_line = std::cmp::min(start_line + visible_lines, total_lines); let visible_content = if start_line < total_lines { content[start_line..end_line].to_vec() @@ -1295,6 +1804,7 @@ fn draw_request_details(f: &mut Frame, area: Rect, app: &App) { }), &mut scrollbar_state, ); + draw_annotation_scrollbar_markers(f, area, &annotations, source_lines); } } @@ -1312,7 +1822,19 @@ fn draw_details_split(f: &mut Frame, area: Rect, app: &App) { } pub fn response_detail_lines(app: &App) -> Vec> { - if let Some(exchange) = app.get_selected_exchange() { + response_detail_lines_for( + app.get_selected_exchange(), + app.response_tab, + matches!(app.focus, Focus::ResponseSection), + ) +} + +fn response_detail_lines_for( + exchange: Option<&crate::app::JsonRpcExchange>, + tab: usize, + focused: bool, +) -> Vec> { + if let Some(exchange) = exchange { let mut lines = Vec::new(); // Response section with tabs @@ -1324,13 +1846,13 @@ pub fn response_detail_lines(app: &App) -> Vec> { ))); lines.push(build_tab_line( &["Headers", "Body"], - app.response_tab, - matches!(app.focus, Focus::ResponseSection), + tab, + focused, exchange.response.is_some(), )); if let Some(response) = &exchange.response { - if app.response_tab == 0 { + if tab == 0 { // Show headers regardless of focus state lines.push(Line::from("")); match &response.headers { @@ -1386,20 +1908,33 @@ pub fn response_detail_lines(app: &App) -> Vec> { } fn draw_response_details(f: &mut Frame, area: Rect, app: &App) { - let content = number_detail_lines(response_detail_lines(app)); - let content = highlight_selected_lines(content, app, Focus::ResponseSection); - - // Calculate visible area for scrolling let inner_area = area.inner(&Margin { vertical: 1, horizontal: 1, }); + let cursor_line = app + .detail_cursor_line(Focus::ResponseSection) + .filter(|_| app.focus == Focus::ResponseSection); + let content = annotate_detail_lines(response_detail_lines(app), app, Focus::ResponseSection); + let content = highlight_selected_lines(content, app, Focus::ResponseSection); + let content = number_detail_lines(content, cursor_line); + let source_lines = content.len(); + let content = insert_annotation_lines( + content, + app, + Focus::ResponseSection, + usize::from(inner_area.width), + ); + + // Calculate visible area for scrolling let visible_lines = inner_area.height as usize; let total_lines = content.len(); // Apply scrolling offset let max_scroll = total_lines.saturating_sub(visible_lines); - let start_line = app.response_details_scroll.min(max_scroll); + let annotations = detail_annotations(app, Focus::ResponseSection); + let start_line = + detail_display_scroll(app.response_details_scroll, &annotations).min(max_scroll); let end_line = std::cmp::min(start_line + visible_lines, total_lines); let visible_content = if start_line < total_lines { content[start_line..end_line].to_vec() @@ -1453,9 +1988,61 @@ fn draw_response_details(f: &mut Frame, area: Rect, app: &App) { }), &mut scrollbar_state, ); + draw_annotation_scrollbar_markers(f, area, &annotations, source_lines); } } +fn draw_annotation_scrollbar_markers( + f: &mut Frame, + area: Rect, + annotations: &[&LineAnnotation], + total_lines: usize, +) { + let rail = area.inner(&Margin { + vertical: 1, + horizontal: 0, + }); + if rail.width == 0 || rail.height == 0 { + return; + } + + let x = rail.x + rail.width - 1; + for row in annotation_scrollbar_rows(rail.height, annotations, total_lines) { + f.render_widget( + Paragraph::new(Span::styled( + "▐", + Style::default() + .fg(ANNOTATION_AMBER) + .add_modifier(Modifier::BOLD), + )), + Rect::new(x, rail.y + row, 1, 1), + ); + } +} + +fn annotation_scrollbar_rows( + rail_height: u16, + annotations: &[&LineAnnotation], + total_lines: usize, +) -> Vec { + if rail_height == 0 || total_lines == 0 { + return Vec::new(); + } + + let rail_end = usize::from(rail_height.saturating_sub(1)); + let document_end = total_lines.saturating_sub(1).max(1); + let mut rows = annotations + .iter() + .map(|annotation| { + let line = annotation.end_line.clamp(1, total_lines) - 1; + (line * rail_end / document_end) as u16 + }) + .collect::>(); + rows.sort_unstable(); + rows.dedup(); + rows +} + // Helper struct to represent a keybind with its display information #[derive(Clone)] struct KeybindInfo { @@ -1493,41 +2080,79 @@ impl KeybindInfo { } fn get_keybinds_for_mode(app: &App) -> Vec { + if app.overlay == Overlay::Prefix { + let mut keybinds = vec![ + KeybindInfo::new("?", "keybinds", 1), + KeybindInfo::new("s", "sessions", 1), + KeybindInfo::new("n", "new session", 1), + KeybindInfo::new("R", "rename session", 1), + KeybindInfo::new("c", "create request", 1), + KeybindInfo::new("p", "pause", 1), + KeybindInfo::new("t", "target", 1), + KeybindInfo::new("x", "start/stop", 1), + KeybindInfo::new( + "z", + if app.panel_fullscreen { + "restore panels" + } else { + "fullscreen panel" + }, + 1, + ), + KeybindInfo::new("q", "quit", 1), + KeybindInfo::new("Esc", "cancel", 1), + ]; + if app.annotation_to_delete().is_some() { + keybinds.insert(1, KeybindInfo::new("d", "delete annotation", 1)); + } + if app.visual_selection_active && app.line_selection.is_some() { + let description = if app.selection_overlaps_annotation() { + "add another annotation" + } else { + "add annotation" + }; + keybinds.insert(1, KeybindInfo::new("a", description, 1)); + } + return keybinds; + } + if matches!(app.overlay, Overlay::Help | Overlay::Sessions) { + return vec![KeybindInfo::new("Esc", "close", 1)]; + } + let mut keybinds = vec![ - // Essential keybinds (priority 1) - KeybindInfo::new("q", "quit", 1), - KeybindInfo::new("↑↓", "navigate", 1), - KeybindInfo::new("s", "start/stop proxy", 1), - // Navigation keybinds (priority 2) - KeybindInfo::new("Tab/Shift+Tab", "navigate", 2), - KeybindInfo::new("Enter", "copy markdown", 2), - KeybindInfo::new("^n/^p", "navigate", 2), - KeybindInfo::new("t", "edit target", 2), + KeybindInfo::new("^B", "commands", 1), + KeybindInfo::new("↑↓/j/k", "navigate", 1), + KeybindInfo::new("Tab", "focus", 1), + KeybindInfo::new("Enter", "copy markdown", 1), KeybindInfo::new("/", "filter", 2), - KeybindInfo::new("p", "pause", 2), - // Advanced keybinds (priority 3) - KeybindInfo::new("j/k/d/u/G/g", "scroll details", 3), - KeybindInfo::new("h/l", "navigate tabs", 3), + KeybindInfo::new("h/l", "tabs", 2), + KeybindInfo::new("d/u/g/G", "scroll", 2), ]; + if app.app_mode == AppMode::Normal + && matches!(app.focus, Focus::RequestSection | Focus::ResponseSection) + { + keybinds.extend([ + KeybindInfo::new("v", "visual select", 1), + KeybindInfo::new("Esc", "clear selection", 2), + ]); + } + // Add context-specific keybinds (priority 4) match app.app_mode { AppMode::Paused | AppMode::Intercepting => { - // Only show intercept controls if there are pending requests if !app.pending_requests.is_empty() { keybinds.extend(vec![ - KeybindInfo::new("a", "allow", 4), - KeybindInfo::new("e", "edit", 4), - KeybindInfo::new("h", "headers", 4), - KeybindInfo::new("c", "complete", 4), - KeybindInfo::new("b", "block", 4), - KeybindInfo::new("r", "resume", 4), + KeybindInfo::new("a", "allow", 3), + KeybindInfo::new("e", "edit", 3), + KeybindInfo::new("h", "headers", 3), + KeybindInfo::new("c", "complete", 3), + KeybindInfo::new("b", "block", 3), + KeybindInfo::new("r", "resume", 3), ]); } } - AppMode::Normal => { - keybinds.push(KeybindInfo::new("c", "create request", 4)); - } + AppMode::Normal => {} } keybinds @@ -1630,14 +2255,6 @@ fn draw_input_dialog(f: &mut Frame, app: &App, title: &str, label: &str) { height: 7, }; - // Clear the entire screen first - f.render_widget(Clear, area); - - // Render a black background - let background = Block::default().style(Style::default().bg(Color::Black)); - f.render_widget(background, area); - - // Clear the popup area specifically f.render_widget(Clear, popup_area); let input_text = vec![ @@ -1704,17 +2321,11 @@ fn draw_pending_requests(f: &mut Frame, area: Rect, app: &App) { .iter() .enumerate() .filter(|(_, pending)| { - if app.filter_text.is_empty() { - true - } else { - // Filter pending requests by method name (same as main list) - pending - .original_request - .method - .as_deref() - .unwrap_or("") - .contains(&app.filter_text) - } + request_matches_filter( + pending.original_request.method.as_deref(), + pending.original_request.id.as_ref(), + &app.filter_text, + ) }) .map(|(i, pending)| { let method = pending @@ -2002,7 +2613,8 @@ fn draw_intercept_request_details(f: &mut Frame, area: Rect, app: &App) { #[cfg(test)] mod tests { use super::*; - use crate::app::{JsonRpcMessage, MessageDirection}; + use crate::app::{DetailTab, JsonRpcMessage, MessageDirection, SessionSummary}; + use ratatui::{backend::TestBackend, Terminal}; fn app_with_request() -> App { let mut app = App::new(); @@ -2045,6 +2657,26 @@ mod tests { (main[0], details[0], details[1]) } + fn annotation( + id: &str, + panel: Focus, + start_line: usize, + end_line: usize, + message: &str, + text: Vec, + ) -> LineAnnotation { + LineAnnotation { + id: id.to_string(), + exchange_index: 0, + panel, + tab: DetailTab::Body, + start_line, + end_line, + message: message.to_string(), + text, + } + } + #[test] fn clicking_a_request_row_selects_it() { let app = app_with_request(); @@ -2057,6 +2689,30 @@ mod tests { ); } + #[test] + fn clicking_a_session_row_selects_it() { + let mut app = App::new(); + app.show_sessions(vec![SessionSummary { + id: "saved".to_string(), + name: "Saved".to_string(), + target: "http://node".to_string(), + created_at_ms: 1, + updated_at_ms: 2, + exchange_count: 3, + }]); + let area = Rect::new(0, 0, 120, 40); + let popup = session_popup(area); + + assert_eq!( + mouse_action(area, &app, popup.x + 2, popup.y + 1), + Some(MouseAction::SelectSession(0)) + ); + assert_eq!( + mouse_action(area, &app, 0, 0), + Some(MouseAction::CloseOverlay) + ); + } + #[test] fn clicking_detail_tabs_selects_them() { let app = app_with_request(); @@ -2116,14 +2772,353 @@ mod tests { assert_eq!(app.request_details_scroll, 0); } + #[test] + fn multiline_annotation_renders_on_its_own_amber_row_without_changing_panel_text() { + let mut app = app_with_request(); + let text = detail_line_text(&app, Focus::RequestSection, 2, 3).unwrap(); + app.add_annotation(annotation( + "annotation-1", + Focus::RequestSection, + 2, + 3, + "Method and id must agree", + text, + )); + + let annotated = + annotate_detail_lines(request_detail_lines(&app), &app, Focus::RequestSection); + let selected = highlight_selected_lines(annotated.clone(), &app, Focus::RequestSection); + let numbered = number_detail_lines(selected.clone(), Some(3)); + let displayed = insert_annotation_lines(numbered, &app, Focus::RequestSection, 120); + + assert_eq!( + detail_lines_text(&app, Focus::RequestSection).unwrap()[2], + "ID: 1" + ); + assert_eq!(line_text(&annotated[2]), "ID: 1"); + assert!(line_text(&displayed[3]).ends_with("◆ Method and id must agree")); + assert_eq!(displayed.len(), annotated.len() + 1); + assert_eq!(annotated[1].style.bg, Some(Color::Rgb(44, 34, 14))); + assert_eq!(annotated[2].style.bg, Some(Color::Rgb(44, 34, 14))); + assert_eq!(selected[1].style.bg, Some(Color::Rgb(44, 34, 14))); + assert_eq!(selected[2].style.bg, Some(Color::Rgb(44, 34, 14))); + assert_eq!( + displayed[3].spans[1].style.fg, + Some(Color::Rgb(245, 166, 35)) + ); + } + + #[test] + fn annotations_remain_when_the_highlight_moves() { + let mut app = app_with_request(); + app.add_annotation(annotation( + "annotation-1", + Focus::RequestSection, + 2, + 2, + "Check the method", + vec!["Method: eth_call".to_string()], + )); + app.add_annotation(annotation( + "annotation-2", + Focus::RequestSection, + 3, + 3, + "Check the id", + vec!["ID: 1".to_string()], + )); + let text = detail_line_text(&app, Focus::RequestSection, 4, 4).unwrap(); + app.select_lines(Focus::RequestSection, 4, 4, text); + + let annotated = + annotate_detail_lines(request_detail_lines(&app), &app, Focus::RequestSection); + let numbered = number_detail_lines(annotated.clone(), Some(4)); + let displayed = insert_annotation_lines(numbered, &app, Focus::RequestSection, 120); + + assert_eq!(app.annotations.len(), 2); + assert_eq!(app.active_annotation_id, None); + assert_eq!(displayed.len(), annotated.len()); + assert!(line_text(&displayed[1]).ends_with("◆ Check the method")); + assert!(line_text(&displayed[2]).ends_with("◆ Check the id")); + } + + #[test] + fn single_line_annotations_stack_inline() { + let mut app = app_with_request(); + app.add_annotation(annotation( + "annotation-1", + Focus::RequestSection, + 2, + 2, + "Check the method", + vec!["Method: eth_call".to_string()], + )); + app.add_annotation(annotation( + "annotation-2", + Focus::RequestSection, + 2, + 2, + "Compare the name", + vec!["Method: eth_call".to_string()], + )); + let numbered = number_detail_lines(request_detail_lines(&app), Some(2)); + let displayed = insert_annotation_lines(numbered, &app, Focus::RequestSection, 80); + let line = line_text(&displayed[1]); + + assert_eq!(displayed.len(), request_detail_lines(&app).len()); + assert!(line.contains("◆ Check the method")); + assert!(line.contains("◆ Compare the name")); + } + + #[test] + fn single_line_annotation_renders_inline_in_amber() { + let mut app = app_with_request(); + app.add_annotation(annotation( + "annotation-1", + Focus::RequestSection, + 2, + 2, + "Check the method", + vec!["Method: eth_call".to_string()], + )); + app.request_details_scroll = 0; + let area = Rect::new(0, 0, 80, 8); + let numbered = number_detail_lines(request_detail_lines(&app), Some(2)); + let marker_x = 1 + numbered[1].width() as u16 + 2; + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + + terminal + .draw(|frame| draw_request_details(frame, area, &app)) + .unwrap(); + + let marker = terminal.backend().buffer().get(marker_x, 2); + assert_eq!(marker.symbol(), "◆"); + assert_eq!(marker.fg, ANNOTATION_AMBER); + } + + #[test] + fn narrow_inline_annotation_is_truncated_without_wrapping() { + let note = annotation( + "annotation-1", + Focus::RequestSection, + 2, + 2, + "Long annotation", + vec!["Method: eth_call".to_string()], + ); + let layout = inline_annotation_layout(10, &[¬e], 20); + + assert_eq!(layout.len(), 1); + assert_eq!(layout[0].message, "Long …"); + assert_eq!(layout[0].width, 10); + } + + #[test] + fn annotation_scrollbar_markers_map_document_positions_to_the_rail() { + let top = annotation( + "top", + Focus::RequestSection, + 1, + 1, + "top", + vec!["top".to_string()], + ); + let middle = annotation( + "middle", + Focus::RequestSection, + 50, + 51, + "middle", + vec!["middle".to_string()], + ); + let same_row = annotation( + "same-row", + Focus::RequestSection, + 51, + 51, + "same row", + vec!["middle".to_string()], + ); + let bottom = annotation( + "bottom", + Focus::RequestSection, + 101, + 101, + "bottom", + vec!["bottom".to_string()], + ); + + assert_eq!( + annotation_scrollbar_rows(11, &[&top, &middle, &same_row, &bottom], 101), + vec![0, 5, 10] + ); + } + + #[test] + fn annotation_scrollbar_marker_renders_in_amber() { + let mut app = app_with_request(); + app.add_annotation(annotation( + "marker", + Focus::RequestSection, + 2, + 2, + "marked", + vec!["Method: eth_call".to_string()], + )); + let area = Rect::new(0, 0, 40, 6); + let total_lines = request_detail_lines(&app).len(); + let row = annotation_scrollbar_rows(4, &[&app.annotations[0]], total_lines)[0]; + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + + terminal + .draw(|frame| draw_request_details(frame, area, &app)) + .unwrap(); + + let marker = terminal.backend().buffer().get(area.width - 1, 1 + row); + assert_eq!(marker.symbol(), "▐"); + assert_eq!(marker.fg, ANNOTATION_AMBER); + } + + #[test] + fn prefix_offers_add_annotation_only_during_visual_selection() { + let mut app = app_with_request(); + app.focus = Focus::RequestSection; + app.show_prefix(); + assert!(!get_keybinds_for_mode(&app) + .iter() + .any(|keybind| keybind.key == "a")); + + app.close_overlay(); + let text = detail_line_text(&app, Focus::RequestSection, 2, 2).unwrap(); + app.select_lines(Focus::RequestSection, 2, 2, text); + app.start_visual_selection(); + app.show_prefix(); + assert_eq!( + get_keybinds_for_mode(&app) + .iter() + .find(|keybind| keybind.key == "a") + .map(|keybind| keybind.description.as_str()), + Some("add annotation") + ); + + app.close_overlay(); + app.add_annotation(annotation( + "existing", + Focus::RequestSection, + 2, + 2, + "Existing note", + vec!["Method: eth_call".to_string()], + )); + let text = detail_line_text(&app, Focus::RequestSection, 2, 2).unwrap(); + app.select_lines(Focus::RequestSection, 2, 2, text); + app.start_visual_selection(); + app.show_prefix(); + assert_eq!( + get_keybinds_for_mode(&app) + .iter() + .find(|keybind| keybind.key == "a") + .map(|keybind| keybind.description.as_str()), + Some("add another annotation") + ); + + app.finish_visual_selection(); + assert!(!get_keybinds_for_mode(&app) + .iter() + .any(|keybind| keybind.key == "a")); + } + + #[test] + fn annotation_row_does_not_change_mouse_line_numbers() { + let area = Rect::new(0, 0, 40, 10); + let content = vec![ + Line::from("one"), + Line::from("two"), + Line::from("three"), + Line::from("four"), + ]; + let annotation = annotation( + "annotation-1", + Focus::RequestSection, + 1, + 2, + "note", + vec!["one".to_string(), "two".to_string()], + ); + let annotations = vec![&annotation]; + + assert_eq!( + clicked_detail_row(area, 1, 1, 0, &content, &annotations), + Some(ClickedDetail::Line(1)) + ); + assert_eq!( + clicked_detail_row(area, 1, 2, 0, &content, &annotations), + Some(ClickedDetail::Line(2)) + ); + assert_eq!( + clicked_detail_row(area, 1, 3, 0, &content, &annotations), + Some(ClickedDetail::Annotation("annotation-1".to_string())) + ); + assert_eq!( + clicked_detail_row(area, 1, 4, 0, &content, &annotations), + Some(ClickedDetail::Line(3)) + ); + assert_eq!( + clicked_detail_row(area, 1, 1, 2, &content, &annotations), + Some(ClickedDetail::Line(3)) + ); + } + + #[test] + fn clicking_inline_annotation_text_focuses_the_annotation() { + let area = Rect::new(0, 0, 40, 10); + let content = vec![Line::from("one"), Line::from("two")]; + let annotation = annotation( + "annotation-1", + Focus::RequestSection, + 2, + 2, + "note", + vec!["two".to_string()], + ); + let annotations = vec![&annotation]; + + assert_eq!( + clicked_detail_row(area, 9, 2, 0, &content, &annotations), + Some(ClickedDetail::Annotation("annotation-1".to_string())) + ); + assert_eq!( + clicked_detail_row(area, 2, 2, 0, &content, &annotations), + Some(ClickedDetail::Line(2)) + ); + } + + #[test] + fn annotation_at_the_bottom_remains_scrollable() { + let mut app = app_with_request(); + app.add_annotation(annotation( + "annotation-1", + Focus::RequestSection, + 3, + 4, + "bottom note", + vec!["three".to_string(), "four".to_string()], + )); + + assert_eq!( + detail_max_source_scroll(&app, Focus::RequestSection, 4, 2), + 3 + ); + } + #[test] fn visible_line_numbers_match_get_panel_without_changing_its_text() { let app = app_with_request(); let raw = detail_lines_text(&app, Focus::RequestSection).unwrap(); - let numbered = number_detail_lines(request_detail_lines(&app)); + let numbered = number_detail_lines(request_detail_lines(&app), Some(2)); assert_eq!(raw[1], "Method: eth_call"); - assert_eq!(line_text(&numbered[1]), " 2 │ Method: eth_call"); + assert_eq!(line_text(&numbered[1]), "› 2 │ Method: eth_call"); } #[test] @@ -2193,4 +3188,89 @@ mod tests { Some(Focus::ResponseSection) ); } + + #[test] + fn fullscreen_draws_only_the_focused_panel_in_the_main_area() { + let mut app = app_with_request(); + app.set_focus(Focus::ResponseSection); + let area = Rect::new(0, 0, 120, 40); + let split_visible_lines = panel_visible_lines(area, &app, app.focus); + app.set_panel_fullscreen(true); + + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal.draw(|frame| draw(frame, &app)).unwrap(); + + let main = screen_chunks(area, &app)[1]; + let title = (0..area.width).fold(String::new(), |mut title, column| { + title.push_str(terminal.backend().buffer().get(column, main.y).symbol()); + title + }); + assert!(title.contains("Response Details")); + assert!(!title.contains("Requests")); + assert!(panel_visible_lines(area, &app, app.focus) > split_visible_lines); + assert_eq!( + panel_focus(area, &app, main.x + 2, main.y + 2), + Some(Focus::ResponseSection) + ); + } + + #[test] + fn clicking_a_scrolled_request_uses_the_viewport_offset() { + let mut app = App::new(); + for id in 0..6 { + app.add_message(JsonRpcMessage { + id: Some(serde_json::json!(id)), + method: Some(format!("request_{id}")), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + }); + } + app.history_scroll = Some(3); + let area = Rect::new(0, 0, 80, 5); + + assert_eq!( + message_list_action(area, &app, 2), + Some(MouseAction::SelectExchange(3)) + ); + } + + #[test] + fn request_columns_do_not_shift_when_selection_scrolls_offscreen() { + let mut app = App::new(); + for id in 0..6 { + app.add_message(JsonRpcMessage { + id: Some(serde_json::json!(id)), + method: Some(format!("request_{id}")), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + }); + } + let area = Rect::new(0, 0, 80, 5); + let header_x = |app: &App| { + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal + .draw(|frame| draw_message_list(frame, area, app)) + .unwrap(); + (0..area.width) + .find(|column| terminal.backend().buffer().get(*column, 1).symbol() == "S") + .unwrap() + }; + + app.history_scroll = Some(0); + let selected_header_x = header_x(&app); + app.history_scroll = Some(3); + let scrolled_header_x = header_x(&app); + + assert_eq!(selected_header_x, scrolled_header_x); + } } diff --git a/tests/app_tests.rs b/tests/app_tests.rs index bdc4a48..04e0bb2 100644 --- a/tests/app_tests.rs +++ b/tests/app_tests.rs @@ -121,6 +121,22 @@ fn test_toggle_proxy() { assert!(app.is_running); } +#[test] +fn panel_fullscreen_is_an_idempotent_view_state() { + let mut app = App::new(); + let revision = app.revision(); + + app.set_panel_fullscreen(true); + assert!(app.panel_fullscreen); + assert_eq!(app.revision(), revision + 1); + + app.set_panel_fullscreen(true); + assert_eq!(app.revision(), revision + 1); + + app.set_panel_fullscreen(false); + assert!(!app.panel_fullscreen); +} + #[test] fn test_request_response_pairing() { let mut app = App::new(); @@ -385,6 +401,33 @@ fn test_filtering_functionality() { assert_eq!(case_insensitive_count, 3); } +#[test] +fn filters_requests_by_their_visible_id() { + let mut app = App::new(); + for (id, method) in [ + (serde_json::json!("audit-ab12"), "first_method"), + (serde_json::json!(73), "second_method"), + ] { + app.add_message(JsonRpcMessage { + id: Some(id), + method: Some(method.to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + }); + } + + app.filter_text = "ab12".to_string(); + assert_eq!(app.filtered_exchange_indices(), vec![0]); + + app.filter_text = "73".to_string(); + assert_eq!(app.filtered_exchange_indices(), vec![1]); +} + #[test] fn focused_request_list_copies_as_markdown_table() { let mut app = App::new(); @@ -441,6 +484,52 @@ fn focused_details_copy_the_selected_tab_as_markdown() { assert!(app.focused_markdown().is_none()); } +#[test] +fn focused_detail_selection_copies_only_the_selected_lines() { + let mut app = App::new(); + app.focus = Focus::RequestSection; + app.select_lines( + Focus::RequestSection, + 2, + 3, + vec!["one".to_string(), "two".to_string()], + ); + + assert_eq!(app.focused_markdown().unwrap(), "```text\none\ntwo\n```"); + assert_eq!(app.request_details_cursor_line, 3); +} + +#[test] +fn visual_selection_copy_takes_priority_after_hover_changes_focus() { + let mut app = App::new(); + app.focus = Focus::RequestSection; + app.select_lines( + Focus::RequestSection, + 2, + 3, + vec!["one".to_string(), "two".to_string()], + ); + app.start_visual_selection(); + app.focus = Focus::MessageList; + + assert_eq!(app.focused_markdown().unwrap(), "```text\none\ntwo\n```"); +} + +#[test] +fn detail_cursor_and_scroll_survive_focus_changes() { + let mut app = App::new(); + app.focus = Focus::RequestSection; + app.request_details_cursor_line = 7; + app.request_details_scroll = 4; + + app.switch_focus(); + app.switch_focus_reverse(); + + assert_eq!(app.focus, Focus::RequestSection); + assert_eq!(app.request_details_cursor_line, 7); + assert_eq!(app.request_details_scroll, 4); +} + #[test] fn inline_editor_edits_multiline_unicode_text() { let mut editor = TextEditor::new(EditorTarget::NewRequest, "aé\ncd".to_string()); @@ -496,3 +585,169 @@ fn new_requests_are_validated_before_background_send() { assert!(app.prepare_new_request("{}".to_string()).is_err()); } + +#[test] +fn stopped_proxy_refuses_to_send_through_its_port() { + let mut app = App::new(); + app.is_running = false; + + let error = app + .prepare_new_request(r#"{"jsonrpc":"2.0","method":"eth_chainId","id":1}"#.to_string()) + .unwrap_err(); + + assert_eq!(error, "Proxy is stopped. Press Ctrl-B x to start it."); +} + +#[test] +fn active_session_tracks_new_exchanges() { + let mut app = App::new(); + app.activate_session( + SessionSummary { + id: "session".to_string(), + name: "Session".to_string(), + target: "http://node".to_string(), + created_at_ms: 1, + updated_at_ms: 1, + exchange_count: 0, + }, + Vec::new(), + Vec::new(), + ); + + app.add_message(JsonRpcMessage { + id: Some(serde_json::json!(1)), + method: Some("eth_chainId".to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + }); + + assert_eq!(app.session.unwrap().exchange_count, 1); +} + +#[test] +fn session_name_prompts_use_the_shared_input_buffer() { + let mut app = App::new(); + app.activate_session( + SessionSummary { + id: "session".to_string(), + name: "Original".to_string(), + target: "http://node".to_string(), + created_at_ms: 1, + updated_at_ms: 1, + exchange_count: 0, + }, + Vec::new(), + Vec::new(), + ); + + app.start_naming_session(); + app.handle_input_char('N'); + assert_eq!(app.input_mode, InputMode::NamingSession); + assert_eq!(app.input_buffer, "N"); + + app.start_renaming_session(); + assert_eq!(app.input_mode, InputMode::RenamingSession); + assert_eq!(app.input_buffer, "Original"); + + app.rename_session("session", "Refunds".to_string()); + assert_eq!(app.session.unwrap().name, "Refunds"); +} + +#[test] +fn annotation_prompt_requires_an_active_visual_selection() { + let mut app = App::new(); + app.select_lines( + Focus::RequestSection, + 2, + 3, + vec!["two".to_string(), "three".to_string()], + ); + + app.start_annotating_selection(); + assert_eq!(app.input_mode, InputMode::Normal); + + app.start_visual_selection(); + app.start_annotating_selection(); + app.handle_input_char('N'); + + assert_eq!(app.input_mode, InputMode::AnnotatingSelection); + assert_eq!(app.input_buffer, "N"); +} + +#[test] +fn adding_an_annotation_preserves_the_viewport() { + let mut app = App::new(); + app.selected_exchange = 7; + app.focus = Focus::MessageList; + app.request_tab = 0; + app.response_tab = 1; + app.request_details_scroll = 3; + app.response_details_scroll = 9; + app.line_selection = Some(LineSelection { + panel: Focus::RequestSection, + anchor_line: 2, + start_line: 2, + end_line: 4, + text: vec!["selected".to_string()], + }); + app.active_annotation_id = Some("existing".to_string()); + + let selection = app.line_selection.clone(); + app.add_annotation(LineAnnotation { + id: "new".to_string(), + exchange_index: 12, + panel: Focus::ResponseSection, + tab: DetailTab::Body, + start_line: 20, + end_line: 22, + message: "Background finding".to_string(), + text: vec!["evidence".to_string()], + }); + + assert_eq!(app.annotations.len(), 1); + assert_eq!(app.selected_exchange, 7); + assert_eq!(app.focus, Focus::MessageList); + assert_eq!((app.request_tab, app.response_tab), (0, 1)); + assert_eq!( + (app.request_details_scroll, app.response_details_scroll), + (3, 9) + ); + assert_eq!(app.line_selection, selection); + assert_eq!(app.active_annotation_id.as_deref(), Some("existing")); +} + +#[test] +fn unpausing_with_pending_requests_keeps_them_visible() { + let mut app = App::new(); + let (decision_sender, _decision_receiver) = tokio::sync::oneshot::channel(); + app.app_mode = AppMode::Paused; + app.pending_requests.push(PendingRequest { + id: "pending".to_string(), + original_request: JsonRpcMessage { + id: Some(serde_json::json!(1)), + method: Some("eth_chainId".to_string()), + params: Some(serde_json::json!([])), + result: None, + error: None, + timestamp: std::time::SystemTime::now(), + direction: MessageDirection::Request, + transport: TransportType::Http, + headers: None, + }, + modified_request: None, + modified_headers: None, + decision_sender, + }); + + app.toggle_pause_mode(); + assert_eq!(app.app_mode, AppMode::Intercepting); + assert_eq!(app.pending_requests.len(), 1); + + app.toggle_pause_mode(); + assert_eq!(app.app_mode, AppMode::Paused); +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 32aba6e..9bbd079 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,5 +1,21 @@ use jsonrpc_debugger::app::*; use std::collections::HashMap; +use std::process::Command; + +#[test] +fn skill_flag_prints_the_bundled_agent_skill() { + let output = Command::new(env!("CARGO_BIN_EXE_jsonrpc-debugger")) + .arg("--skill") + .output() + .expect("jsonrpc-debugger should run"); + + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).expect("skill should be UTF-8"); + assert!(stdout.starts_with("---\nname: jsonrpc-debugger\n")); + assert!(stdout.contains("debugger.getState")); + assert!(output.stderr.is_empty()); +} #[test] fn test_full_exchange_flow() { diff --git a/tests/proxy_tests.rs b/tests/proxy_tests.rs index 84b7801..a366534 100644 --- a/tests/proxy_tests.rs +++ b/tests/proxy_tests.rs @@ -14,6 +14,16 @@ async fn test_proxy_server_creation() { // The constructor parameters are validated during creation } +#[tokio::test] +async fn proxy_bind_rejects_an_occupied_port() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sender, _receiver) = mpsc::unbounded_channel(); + let proxy = ProxyServer::new(port, "http://localhost:8090".to_string(), sender); + + assert!(proxy.bind().is_err()); +} + #[tokio::test] async fn test_proxy_handles_different_paths() { use warp::Filter;