Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.1] - 2026-08-21

### Changed

- `Enter` opens the selected response from Requests. `Ctrl-B y` copies any focused panel as Markdown.

### Fixed

- Request lists explain when a filter hides every row, and session changes clear stale filters.

## [0.3.0] - 2026-08-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "jsonrpc-debugger"
version = "0.3.0"
version = "0.3.1"
edition = "2021"
authors = ["Shane Jonas <shane@shanejonas.com>"]
description = "A terminal-based JSON-RPC debugger with interception capabilities"
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ The TUI shows request history beside the selected request and response. It suppo
| Change tabs or inputs | Click them |
| Select a line | Click its line number |
| Select a line range | Click, then Shift-click |
| Copy the focused panel as Markdown | `Enter` |
| Open the selected response | `Enter` from Requests |
| Copy details or status as Markdown | `Enter` |
| Copy any focused panel as Markdown | `Ctrl-B y` |
| 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` |
Expand All @@ -67,7 +69,7 @@ The TUI shows request history beside the selected request and response. It suppo
| 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 request list copies as a Markdown table with `Ctrl-B y`. Request bodies, responses, headers, and status copy as Markdown with `Enter` or `Ctrl-B y`.

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!`.

Expand Down
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ impl App {
self.exchanges = exchanges;
self.selected_exchange = self.exchanges.len().saturating_sub(1);
self.history_scroll = None;
self.filter_text.clear();
self.session = Some(session);
self.overlay = Overlay::None;
self.line_selection = None;
Expand Down
64 changes: 62 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,28 @@ fn copy_focused_panel(
copy_to_clipboard(terminal, &markdown)
}

fn enter_request_list(app: &mut App) -> bool {
if app.app_mode != AppMode::Normal
|| !app.is_message_list_focused()
|| app.visual_selection_active
{
return false;
}

let visible = app.filtered_exchange_indices();
let selected = visible
.iter()
.copied()
.find(|index| *index == app.selected_exchange)
.or_else(|| visible.first().copied());
if let Some(selected) = selected {
app.select_exchange(selected);
app.set_focus(app::Focus::ResponseSection);
}

true
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditorAction {
None,
Expand Down Expand Up @@ -1049,7 +1071,8 @@ async fn run_app(
let mut received_request_result = false;
while let Ok(result) = runtime.request_result_receiver.try_recv() {
app.notice = Some(match result {
Ok(()) => "Request sent".to_string(),
Ok(()) if app.filter_text.is_empty() => "Request sent".to_string(),
Ok(()) => "Request sent; filter is active".to_string(),
Err(error) => format!("Error: {}", error),
});
received_request_result = true;
Expand Down Expand Up @@ -1355,7 +1378,7 @@ async fn run_app(
app.clear_line_selection();
}
KeyCode::Enter => {
if app.app_mode == AppMode::Normal {
if app.app_mode == AppMode::Normal && !enter_request_list(&mut app) {
copy_focused_panel(terminal, &app)?;
}
}
Expand Down Expand Up @@ -1702,6 +1725,10 @@ async fn handle_overlay_key(
None => app.notice = Some("No annotation under cursor".to_string()),
}
}
KeyCode::Char('y') => {
app.close_overlay();
copy_focused_panel(terminal, app)?;
}
KeyCode::Char('s') => match runtime.history.list_sessions(1000) {
Ok(sessions) => app.show_sessions(sessions),
Err(error) => {
Expand Down Expand Up @@ -2348,6 +2375,39 @@ mod tests {
assert_eq!(app.history_scroll, Some(3));
}

#[test]
fn enter_on_request_list_focuses_the_visible_response() {
let mut app = App::new();
for (id, method) in [(1, "first"), (2, "second")] {
app.add_message(app::JsonRpcMessage {
id: Some(serde_json::json!(id)),
method: Some(method.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.selected_exchange = 0;
app.filter_text = "second".to_string();

assert!(enter_request_list(&mut app));
assert_eq!(app.selected_exchange, 1);
assert_eq!(app.focus, app::Focus::ResponseSection);
}

#[test]
fn visual_selection_still_copies_from_request_list_focus() {
let mut app = App::new();
app.visual_selection_active = true;

assert!(!enter_request_list(&mut app));
assert_eq!(app.focus, app::Focus::MessageList);
}

#[test]
fn keyboard_cursor_stops_at_the_bottom_and_keeps_it_visible() {
let mut app = App::new();
Expand Down
41 changes: 38 additions & 3 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ fn draw_keybind_help(f: &mut Frame) {
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 y copy focused panel as Markdown"),
Line::from("^B d delete focused annotation"),
Line::from("^B q quit ^B ? this help"),
Line::from(""),
Expand All @@ -835,7 +836,8 @@ fn draw_keybind_help(f: &mut Frame) {
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("d/u page g/G top/bottom"),
Line::from("Requests: Enter response Details: Enter copy Markdown"),
Line::from("Details: v visual select j/k extend Esc clear"),
];
let block = Block::default()
Expand Down Expand Up @@ -1233,7 +1235,12 @@ fn draw_message_list(f: &mut Frame, area: Rect, app: &App) {
.collect();

if filtered.is_empty() {
let empty_message = if app.is_running {
let empty_message = if !app.filter_text.is_empty() && !app.exchanges.is_empty() {
format!(
"No requests match filter {:?}. Press / then Enter to clear it.",
app.filter_text
)
} else if app.is_running {
format!(
"Proxy is running on port {}. Waiting for requests...",
app.proxy_config.listen_port
Expand Down Expand Up @@ -2090,6 +2097,7 @@ fn get_keybinds_for_mode(app: &App) -> Vec<KeybindInfo> {
KeybindInfo::new("p", "pause", 1),
KeybindInfo::new("t", "target", 1),
KeybindInfo::new("x", "start/stop", 1),
KeybindInfo::new("y", "copy markdown", 1),
KeybindInfo::new(
"z",
if app.panel_fullscreen {
Expand Down Expand Up @@ -2119,11 +2127,17 @@ fn get_keybinds_for_mode(app: &App) -> Vec<KeybindInfo> {
return vec![KeybindInfo::new("Esc", "close", 1)];
}

let enter_description =
if app.app_mode == AppMode::Normal && matches!(app.focus, Focus::MessageList) {
"response"
} else {
"copy markdown"
};
let mut keybinds = vec![
KeybindInfo::new("^B", "commands", 1),
KeybindInfo::new("↑↓/j/k", "navigate", 1),
KeybindInfo::new("Tab", "focus", 1),
KeybindInfo::new("Enter", "copy markdown", 1),
KeybindInfo::new("Enter", enter_description, 1),
KeybindInfo::new("/", "filter", 2),
KeybindInfo::new("h/l", "tabs", 2),
KeybindInfo::new("d/u/g/G", "scroll", 2),
Expand Down Expand Up @@ -2689,6 +2703,27 @@ mod tests {
);
}

#[test]
fn filtered_empty_list_explains_that_requests_are_hidden() {
let mut app = app_with_request();
app.filter_text = "missing".to_string();
let area = Rect::new(0, 0, 80, 5);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();

terminal
.draw(|frame| draw_message_list(frame, area, &app))
.unwrap();

let text = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(text.contains("No requests match filter \"missing\""));
}

#[test]
fn clicking_a_session_row_selects_it() {
let mut app = App::new();
Expand Down
21 changes: 21 additions & 0 deletions tests/app_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,27 @@ fn session_name_prompts_use_the_shared_input_buffer() {
assert_eq!(app.session.unwrap().name, "Refunds");
}

#[test]
fn activating_a_session_clears_the_previous_filter() {
let mut app = App::new();
app.filter_text = "old-filter".to_string();

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(),
);

assert!(app.filter_text.is_empty());
}

#[test]
fn annotation_prompt_requires_an_active_visual_selection() {
let mut app = App::new();
Expand Down
Loading