procsi 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +35 -9
  2. package/dist/cli/commands/mcp.d.ts.map +1 -1
  3. package/dist/cli/commands/mcp.js +63 -0
  4. package/dist/cli/commands/mcp.js.map +1 -1
  5. package/dist/cli/tui/App.d.ts.map +1 -1
  6. package/dist/cli/tui/App.js +126 -125
  7. package/dist/cli/tui/App.js.map +1 -1
  8. package/dist/cli/tui/components/Panel.d.ts +4 -0
  9. package/dist/cli/tui/components/Panel.d.ts.map +1 -1
  10. package/dist/cli/tui/components/Panel.js +33 -12
  11. package/dist/cli/tui/components/Panel.js.map +1 -1
  12. package/dist/cli/tui/components/RequestList.d.ts +4 -0
  13. package/dist/cli/tui/components/RequestList.d.ts.map +1 -1
  14. package/dist/cli/tui/components/RequestList.js +18 -2
  15. package/dist/cli/tui/components/RequestList.js.map +1 -1
  16. package/dist/cli/tui/components/StatusBar.d.ts.map +1 -1
  17. package/dist/cli/tui/components/StatusBar.js +1 -1
  18. package/dist/cli/tui/components/StatusBar.js.map +1 -1
  19. package/dist/cli/tui/hooks/useRequestListState.d.ts +23 -0
  20. package/dist/cli/tui/hooks/useRequestListState.d.ts.map +1 -0
  21. package/dist/cli/tui/hooks/useRequestListState.js +88 -0
  22. package/dist/cli/tui/hooks/useRequestListState.js.map +1 -0
  23. package/dist/cli/tui/hooks/useRequests.d.ts.map +1 -1
  24. package/dist/cli/tui/hooks/useRequests.js +201 -55
  25. package/dist/cli/tui/hooks/useRequests.js.map +1 -1
  26. package/dist/cli/tui/state/request-list-state.d.ts +17 -0
  27. package/dist/cli/tui/state/request-list-state.d.ts.map +1 -0
  28. package/dist/cli/tui/state/request-list-state.js +45 -0
  29. package/dist/cli/tui/state/request-list-state.js.map +1 -0
  30. package/dist/daemon/control.d.ts.map +1 -1
  31. package/dist/daemon/control.js +15 -0
  32. package/dist/daemon/control.js.map +1 -1
  33. package/dist/daemon/storage.d.ts +16 -1
  34. package/dist/daemon/storage.d.ts.map +1 -1
  35. package/dist/daemon/storage.js +136 -16
  36. package/dist/daemon/storage.js.map +1 -1
  37. package/dist/shared/control-client.d.ts +9 -1
  38. package/dist/shared/control-client.d.ts.map +1 -1
  39. package/dist/shared/control-client.js +6 -0
  40. package/dist/shared/control-client.js.map +1 -1
  41. package/dist/shared/types.d.ts +17 -0
  42. package/dist/shared/types.d.ts.map +1 -1
  43. package/package.json +2 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ Procsi is a terminal-based, project-scoped HTTP proxy with a powerful CLI & MCP
12
12
 
13
13
  - **Project-scoped** — each project gets its own `.procsi/` directory with a separate daemon, database, CA cert and interceptors
14
14
  - **MCP server** — AI agents get full access to your captured traffic and can search, filter, inspect, mock — all via tool calls.
15
- - **Interceptors** — mock, modify or observe traffic by writing typescript. Agent can do this over MCP meaning you can express complex scenarios in natural language!
15
+ - **Interceptors** — mock, modify or observe traffic by writing typescript. Agent can do this over MCP meaning you can express complex scenarios in natural language!
16
16
 
17
17
  ## Quick Start
18
18
 
@@ -27,6 +27,9 @@ curl https://api.example.com/users
27
27
 
28
28
  # Open UI
29
29
  procsi tui
30
+
31
+ # Add MCP server to your AI tool
32
+ claude mcp add procsi -- procsi mcp
30
33
  ```
31
34
 
32
35
  ## Browser Interception
@@ -81,19 +84,38 @@ procsi --config /tmp/my-procsi-data on
81
84
 
82
85
  See [CLI Reference](docs/cli-reference.md) for the full resolution order (`--config` > `--dir` > auto-detect).
83
86
 
84
- ## Use cases
85
-
86
- - AI analysis
87
- - Chaos monkey
88
- - Mock out APIs that do not yet exist
89
-
90
- ## MCP Integration
87
+ #### MCP Integration
91
88
 
92
89
  procsi has a built-in [MCP](https://modelcontextprotocol.io/) server that gives AI agents full access to your captured traffic and interceptor system.
93
90
 
94
91
  ### Setup
95
92
 
96
- Add procsi to your MCP client config:
93
+ **Claude Code:**
94
+
95
+ ```bash
96
+ claude mcp add procsi -- procsi mcp
97
+ ```
98
+
99
+ **Codex:**
100
+
101
+ ```bash
102
+ codex mcp add procsi -- procsi mcp
103
+ ```
104
+
105
+ **Cursor** — add to `.cursor/mcp.json`:
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "procsi": {
111
+ "command": "procsi",
112
+ "args": ["mcp"]
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ **Other MCP clients** (Windsurf, etc.) — add to your client's MCP config:
97
119
 
98
120
  ```json
99
121
  {
@@ -310,6 +332,10 @@ Your HTTP client needs to respect proxy environment variables.
310
332
 
311
333
  There are workarounds implemented for node - e.g. fetch override. Other libraries in different environments may need a similar treatment.
312
334
 
335
+ ## Acknowledgements
336
+
337
+ procsi is built on top of [MockTTP](https://github.com/httptoolkit/mockttp) by [Tim Perry](https://github.com/pimterry), the same MITM proxy engine that powers [HTTP Toolkit](https://httptoolkit.com/).
338
+
313
339
  ## Licence
314
340
 
315
341
  AGPL-3.0-or-later
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/mcp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,eAAO,MAAM,UAAU,SAsBnB,CAAC"}
1
+ {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/mcp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAqEpC,eAAO,MAAM,UAAU,SA4BnB,CAAC"}
@@ -1,9 +1,72 @@
1
1
  import { Command } from "commander";
2
2
  import { createProcsiMcpServer } from "../../mcp/server.js";
3
3
  import { getGlobalOptions, requireProjectRoot } from "./helpers.js";
4
+ const SEPARATOR_WIDTH = 48;
5
+ function printSetupInstructions() {
6
+ console.log("procsi MCP server");
7
+ console.log("");
8
+ console.log("Add procsi to your AI tool to give it access to captured HTTP traffic.");
9
+ console.log("");
10
+ const clients = [
11
+ {
12
+ name: "Claude Code",
13
+ lines: [" claude mcp add procsi -- procsi mcp"],
14
+ },
15
+ {
16
+ name: "Cursor",
17
+ lines: [
18
+ " Add to .cursor/mcp.json:",
19
+ "",
20
+ " {",
21
+ ' "mcpServers": {',
22
+ ' "procsi": {',
23
+ ' "command": "procsi",',
24
+ ' "args": ["mcp"]',
25
+ " }",
26
+ " }",
27
+ " }",
28
+ ],
29
+ },
30
+ {
31
+ name: "Codex",
32
+ lines: [" codex mcp add procsi -- procsi mcp"],
33
+ },
34
+ {
35
+ name: "Other (Windsurf, etc.)",
36
+ lines: [
37
+ " Add to your MCP client config:",
38
+ "",
39
+ " {",
40
+ ' "mcpServers": {',
41
+ ' "procsi": {',
42
+ ' "command": "procsi",',
43
+ ' "args": ["mcp"]',
44
+ " }",
45
+ " }",
46
+ " }",
47
+ ],
48
+ },
49
+ ];
50
+ for (const client of clients) {
51
+ const label = ` ${client.name} `;
52
+ const padding = "\u2500".repeat(Math.max(0, SEPARATOR_WIDTH - label.length - 2));
53
+ console.log(`\u2500\u2500${label}${padding}`);
54
+ console.log("");
55
+ for (const line of client.lines) {
56
+ console.log(line);
57
+ }
58
+ console.log("");
59
+ }
60
+ console.log('Note: The proxy must be running (eval "$(procsi on)") for the MCP server to connect.');
61
+ }
4
62
  export const mcpCommand = new Command("mcp")
5
63
  .description("Start the procsi MCP server (stdio transport for AI tool integration)")
6
64
  .action(async (_, command) => {
65
+ // If stdout is a TTY, user ran directly — show setup instructions instead
66
+ if (process.stdout.isTTY) {
67
+ printSetupInstructions();
68
+ return;
69
+ }
7
70
  const globalOpts = getGlobalOptions(command);
8
71
  const projectRoot = requireProjectRoot(globalOpts.dir);
9
72
  const mcp = createProcsiMcpServer({ projectRoot });
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../../src/cli/commands/mcp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEpE,MAAM,CAAC,MAAM,UAAU,GAAG,IAAI,OAAO,CAAC,KAAK,CAAC;KACzC,WAAW,CAAC,uEAAuE,CAAC;KACpF,MAAM,CAAC,KAAK,EAAE,CAAC,EAAE,OAAgB,EAAE,EAAE;IACpC,MAAM,UAAU,GAAG,gBAAgB,CAAC,OAAO,CAAC,CAAC;IAC7C,MAAM,WAAW,GAAG,kBAAkB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAEvD,MAAM,GAAG,GAAG,qBAAqB,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC;IAEnD,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,MAAM,QAAQ,GAAG,KAAK,IAAI,EAAE;QAC1B,IAAI,OAAO;YAAE,OAAO;QACpB,OAAO,GAAG,IAAI,CAAC;QACf,MAAM,GAAG,CAAC,KAAK,EAAE,CAAC;QAClB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC;IACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEhC,MAAM,GAAG,CAAC,KAAK,EAAE,CAAC;IAElB,+DAA+D;IAC/D,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,uCAAuC,WAAW,KAAK,CAAC,CAAC;AAChF,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../../src/cli/commands/mcp.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../src/cli/tui/App.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAoCjF,UAAU,QAAQ;IAChB,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAIpC,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,eAAO,MAAM,cAAc,OAAO,CAAC;AACnC,eAAO,MAAM,cAAc,OAAO,CAAC;AACnC,eAAO,MAAM,UAAU,OAAO,CAAC;AAi6B/B,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,GAAG,KAAK,CAAC,YAAY,CAMvD"}
1
+ {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../src/cli/tui/App.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAA4D,MAAM,OAAO,CAAC;AAqCjF,UAAU,QAAQ;IAChB,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAIpC,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,eAAO,MAAM,cAAc,OAAO,CAAC;AACnC,eAAO,MAAM,cAAc,OAAO,CAAC;AACnC,eAAO,MAAM,UAAU,OAAO,CAAC;AAs8B/B,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,GAAG,KAAK,CAAC,YAAY,CAMvD"}
@@ -2,11 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * Root TUI component for browsing captured HTTP traffic.
4
4
  */
5
- import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
5
+ import { useState, useCallback, useRef, useMemo, useEffect } from "react";
6
6
  import { Box, Text, useInput, useApp, useStdin } from "ink";
7
7
  import { MouseProvider, useOnClick, useOnWheel, useOnMouseEnter, useOnMouseLeave } from "@ink-tools/ink-mouse";
8
8
  import { useStdoutDimensions } from "./hooks/useStdoutDimensions.js";
9
9
  import { useRequests } from "./hooks/useRequests.js";
10
+ import { useRequestListState } from "./hooks/useRequestListState.js";
10
11
  import { useSpinner } from "./hooks/useSpinner.js";
11
12
  import { useBodyExport, generateFilename } from "./hooks/useBodyExport.js";
12
13
  import { formatSize } from "./utils/formatters.js";
@@ -66,15 +67,10 @@ function AppContent({ __testEnableInput, projectRoot }) {
66
67
  const startTime = useMemo(() => Date.now(), []);
67
68
  const { saveBody } = useBodyExport();
68
69
  const spinnerFrame = useSpinner(isLoading && requests.length === 0);
69
- const [selectedIndex, setSelectedIndex] = useState(0);
70
70
  const [activePanel, setActivePanel] = useState("list");
71
71
  const [statusMessage, setStatusMessage] = useState();
72
72
  const [showFullUrl, setShowFullUrl] = useState(false);
73
73
  const [hoveredPanel, setHoveredPanel] = useState(null);
74
- const [listScrollOffset, setListScrollOffset] = useState(0);
75
- // Follow mode — when true, cursor tracks the newest request (index 0)
76
- const [following, setFollowing] = useState(true);
77
- const selectedRequestIdRef = useRef(null);
78
74
  // Accordion state — independent expand/collapse per section
79
75
  const [focusedSection, setFocusedSection] = useState(SECTION_REQUEST);
80
76
  const [expandedSections, setExpandedSections] = useState(new Set(ALL_SECTIONS_EXPANDED));
@@ -118,87 +114,80 @@ function AppContent({ __testEnableInput, projectRoot }) {
118
114
  // Ref to track whether a request is selected (for stable callbacks)
119
115
  const hasSelectedRequestRef = useRef(selectedFullRequest !== null);
120
116
  hasSelectedRequestRef.current = selectedFullRequest !== null;
121
- // Refs for wheel handler to avoid stale closures
122
- // (useOnWheel may not update its stored callback on every render)
123
- const contentHeightRef = useRef(rows - 2);
124
- contentHeightRef.current = rows - 2;
117
+ const filterBarHeight = showFilter ? 2 : 0;
118
+ const infoBarHeight = 1;
119
+ const contentHeight = rows - 2 - infoBarHeight - filterBarHeight;
120
+ const visibleListHeight = Math.max(1, contentHeight - 2);
121
+ const { selectedRequestId, setSelectedRequestId, setTopVisibleRequestId, pendingNewCount, setPendingNewCount, following, setFollowing, selectedIndex, effectiveListScrollOffset, selectedSummary, resetToFollowMode, } = useRequestListState({ requests, visibleListHeight });
122
+ // Refs for wheel/input handlers to avoid stale closures.
123
+ // (Ink input handlers may keep callback identity between renders.)
124
+ const contentHeightRef = useRef(contentHeight);
125
+ contentHeightRef.current = contentHeight;
125
126
  const requestsLengthRef = useRef(requests.length);
126
127
  requestsLengthRef.current = requests.length;
127
- // Get the summary for the currently selected request
128
- const selectedSummary = requests[selectedIndex];
129
- // Re-anchor cursor when requests change, adjusting scroll offset to keep
130
- // the selected item at the same visual position on screen.
131
- useEffect(() => {
132
- if (following) {
133
- selectedRequestIdRef.current = requests[0]?.id ?? null;
134
- if (requests.length > 0 && selectedIndex !== 0) {
135
- setSelectedIndex(0);
136
- }
137
- return;
138
- }
139
- const targetId = selectedRequestIdRef.current;
140
- if (!targetId || requests.length === 0)
141
- return;
142
- const newIndex = requests.findIndex((r) => r.id === targetId);
143
- if (newIndex === -1) {
144
- // Target request no longer in the list (e.g. filtered out or cleared).
145
- // Reset the ref so the getFullRequest guard doesn't block the next fetch.
146
- selectedRequestIdRef.current = null;
147
- }
148
- else if (newIndex !== selectedIndex) {
149
- const indexDelta = newIndex - selectedIndex;
150
- setSelectedIndex(newIndex);
151
- setListScrollOffset((prev) => Math.max(0, prev + indexDelta));
152
- }
153
- }, [requests]);
128
+ const followingRef = useRef(following);
129
+ followingRef.current = following;
130
+ const requestsRef = useRef(requests);
131
+ requestsRef.current = requests;
132
+ const effectiveListScrollOffsetRef = useRef(0);
133
+ effectiveListScrollOffsetRef.current = effectiveListScrollOffset;
134
+ const selectedIndexRef = useRef(selectedIndex);
135
+ selectedIndexRef.current = selectedIndex;
154
136
  // Stores the filter state at the moment the filter bar opens, so Escape can revert
155
137
  const preOpenFilterRef = useRef({});
156
138
  const preOpenBodySearchRef = useRef(undefined);
157
139
  // Handle filter change from the filter bar
158
140
  const handleFilterChange = useCallback((newFilter) => {
159
141
  setFilter(newFilter);
160
- setSelectedIndex(0);
161
- selectedRequestIdRef.current = null;
162
- setFollowing(true);
163
- }, []);
142
+ resetToFollowMode();
143
+ }, [resetToFollowMode]);
164
144
  const handleBodySearchChange = useCallback((nextBodySearch) => {
165
145
  setBodySearch(nextBodySearch);
166
- setSelectedIndex(0);
167
- selectedRequestIdRef.current = null;
168
- setFollowing(true);
169
- }, []);
146
+ resetToFollowMode();
147
+ }, [resetToFollowMode]);
170
148
  // Handle filter cancel — revert to pre-open state
171
149
  const handleFilterCancel = useCallback(() => {
172
150
  setFilter(preOpenFilterRef.current);
173
151
  setBodySearch(preOpenBodySearchRef.current);
174
- setSelectedIndex(0);
175
- selectedRequestIdRef.current = null;
176
- setFollowing(true);
152
+ resetToFollowMode();
177
153
  setShowFilter(false);
178
- }, []);
154
+ }, [resetToFollowMode]);
179
155
  // Handle item click from the request list
180
156
  const handleItemClick = useCallback((index) => {
181
- selectedRequestIdRef.current = requests[index]?.id ?? null;
182
- if (following)
157
+ const selected = requests[index];
158
+ if (!selected) {
159
+ return;
160
+ }
161
+ if (following) {
183
162
  setFollowing(false);
184
- setSelectedIndex(index);
163
+ setTopVisibleRequestId(requests[effectiveListScrollOffsetRef.current]?.id ?? requests[0]?.id ?? null);
164
+ }
165
+ setSelectedRequestId(selected.id);
185
166
  setActivePanel("list");
186
167
  }, [requests, following]);
187
- // Fetch full request data when selection changes
168
+ const selectedDetailRequestIdRef = useRef(null);
169
+ // Fetch full request data when selection changes.
170
+ // Guard against stale async responses when selection changes rapidly.
188
171
  useEffect(() => {
189
- if (selectedSummary) {
190
- // When the list shifts (e.g. new request prepended), selectedIndex can
191
- // temporarily point at the wrong row before the re-anchor effect corrects
192
- // it. Skip the fetch in that case to avoid flickering the detail panel.
193
- if (selectedRequestIdRef.current && selectedSummary.id !== selectedRequestIdRef.current) {
194
- return;
195
- }
196
- void getFullRequest(selectedSummary.id).then(setSelectedFullRequest);
197
- }
198
- else {
172
+ if (!selectedSummary) {
173
+ selectedDetailRequestIdRef.current = null;
199
174
  setSelectedFullRequest(null);
175
+ return;
176
+ }
177
+ // In browse mode, wait for an explicit ID anchor before fetching detail.
178
+ // This avoids transient fetches for index 0 during follow->browse transitions.
179
+ if (!following && !selectedRequestId) {
180
+ return;
200
181
  }
201
- }, [selectedSummary?.id, getFullRequest]);
182
+ const requestId = selectedSummary.id;
183
+ selectedDetailRequestIdRef.current = requestId;
184
+ void getFullRequest(requestId).then((fullRequest) => {
185
+ if (selectedDetailRequestIdRef.current !== requestId) {
186
+ return;
187
+ }
188
+ setSelectedFullRequest(fullRequest);
189
+ });
190
+ }, [selectedSummary?.id, getFullRequest, following, selectedRequestId]);
202
191
  // Reset all sections to expanded when a new request is selected
203
192
  useEffect(() => {
204
193
  if (selectedSummary) {
@@ -210,11 +199,19 @@ function AppContent({ __testEnableInput, projectRoot }) {
210
199
  // Use refs to avoid stale closures if useOnWheel caches the callback
211
200
  const visibleHeight = Math.max(1, contentHeightRef.current - 2);
212
201
  const maxOffset = Math.max(0, requestsLengthRef.current - visibleHeight);
202
+ const currentOffset = effectiveListScrollOffsetRef.current;
203
+ const currentRequests = requestsRef.current;
204
+ if (followingRef.current) {
205
+ setFollowing(false);
206
+ setTopVisibleRequestId(currentRequests[currentOffset]?.id ?? currentRequests[0]?.id ?? null);
207
+ }
213
208
  if (event.button === "wheel-up") {
214
- setListScrollOffset((prev) => Math.max(prev - 1, 0));
209
+ const nextOffset = Math.max(currentOffset - 1, 0);
210
+ setTopVisibleRequestId(currentRequests[nextOffset]?.id ?? null);
215
211
  }
216
212
  else if (event.button === "wheel-down") {
217
- setListScrollOffset((prev) => Math.min(prev + 1, maxOffset));
213
+ const nextOffset = Math.min(currentOffset + 1, maxOffset);
214
+ setTopVisibleRequestId(currentRequests[nextOffset]?.id ?? null);
218
215
  }
219
216
  });
220
217
  // Handle click on panels to activate them
@@ -341,8 +338,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
341
338
  if (pendingClear) {
342
339
  setPendingClear(false);
343
340
  if (input === "y") {
344
- selectedRequestIdRef.current = null;
345
- setFollowing(true);
341
+ resetToFollowMode();
346
342
  void clearRequests().then((success) => {
347
343
  showStatus(success ? "Requests cleared (bookmarks preserved)" : "Failed to clear requests");
348
344
  });
@@ -355,11 +351,14 @@ function AppContent({ __testEnableInput, projectRoot }) {
355
351
  // Navigation - behaviour depends on active panel
356
352
  if (input === "j" || key.downArrow) {
357
353
  if (activePanel === "list") {
358
- if (following)
354
+ const currentRequests = requestsRef.current;
355
+ const currentIndex = Math.max(0, selectedIndexRef.current);
356
+ const newIdx = Math.min(currentIndex + 1, currentRequests.length - 1);
357
+ if (followingRef.current) {
359
358
  setFollowing(false);
360
- const newIdx = Math.min(selectedIndex + 1, requests.length - 1);
361
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
362
- setSelectedIndex(newIdx);
359
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
360
+ }
361
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
363
362
  }
364
363
  else {
365
364
  // Navigate sections in accordion
@@ -368,11 +367,14 @@ function AppContent({ __testEnableInput, projectRoot }) {
368
367
  }
369
368
  else if (input === "k" || key.upArrow) {
370
369
  if (activePanel === "list") {
371
- if (following)
370
+ const currentRequests = requestsRef.current;
371
+ const currentIndex = Math.max(0, selectedIndexRef.current);
372
+ const newIdx = Math.max(currentIndex - 1, 0);
373
+ if (followingRef.current) {
372
374
  setFollowing(false);
373
- const newIdx = Math.max(selectedIndex - 1, 0);
374
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
375
- setSelectedIndex(newIdx);
375
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
376
+ }
377
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
376
378
  }
377
379
  else {
378
380
  // Navigate sections in accordion
@@ -382,9 +384,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
382
384
  else if (input === "g" && !key.shift) {
383
385
  // Jump to first item/section — re-enters follow mode in list
384
386
  if (activePanel === "list") {
385
- selectedRequestIdRef.current = requests[0]?.id ?? null;
386
- setSelectedIndex(0);
387
- setFollowing(true);
387
+ resetToFollowMode();
388
388
  }
389
389
  else {
390
390
  setFocusedSection(SECTION_REQUEST);
@@ -393,11 +393,13 @@ function AppContent({ __testEnableInput, projectRoot }) {
393
393
  else if (input === "G") {
394
394
  // Jump to last item/section
395
395
  if (activePanel === "list") {
396
- const lastIdx = Math.max(0, requestsLengthRef.current - 1);
397
- if (following)
396
+ const currentRequests = requestsRef.current;
397
+ const lastIdx = Math.max(0, currentRequests.length - 1);
398
+ if (followingRef.current) {
398
399
  setFollowing(false);
399
- selectedRequestIdRef.current = requests[lastIdx]?.id ?? null;
400
- setSelectedIndex(lastIdx);
400
+ }
401
+ setTopVisibleRequestId(currentRequests[Math.max(0, lastIdx - visibleListHeight + 1)]?.id ?? currentRequests[0]?.id ?? null);
402
+ setSelectedRequestId(currentRequests[lastIdx]?.id ?? null);
401
403
  }
402
404
  else {
403
405
  setFocusedSection(SECTION_RESPONSE_BODY);
@@ -406,23 +408,29 @@ function AppContent({ __testEnableInput, projectRoot }) {
406
408
  else if (input === "u" && key.ctrl) {
407
409
  // Half-page up (list only)
408
410
  if (activePanel === "list") {
411
+ const currentRequests = requestsRef.current;
412
+ const currentIndex = Math.max(0, selectedIndexRef.current);
409
413
  const halfPage = Math.floor(contentHeightRef.current / 2);
410
- const newIdx = Math.max(selectedIndex - halfPage, 0);
411
- if (following)
414
+ const newIdx = Math.max(currentIndex - halfPage, 0);
415
+ if (followingRef.current) {
412
416
  setFollowing(false);
413
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
414
- setSelectedIndex(newIdx);
417
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
418
+ }
419
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
415
420
  }
416
421
  }
417
422
  else if (input === "d" && key.ctrl) {
418
423
  // Half-page down (list only)
419
424
  if (activePanel === "list") {
425
+ const currentRequests = requestsRef.current;
426
+ const currentIndex = Math.max(0, selectedIndexRef.current);
420
427
  const halfPage = Math.floor(contentHeightRef.current / 2);
421
- const newIdx = Math.min(selectedIndex + halfPage, requestsLengthRef.current - 1);
422
- if (following)
428
+ const newIdx = Math.min(currentIndex + halfPage, currentRequests.length - 1);
429
+ if (followingRef.current) {
423
430
  setFollowing(false);
424
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
425
- setSelectedIndex(newIdx);
431
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
432
+ }
433
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
426
434
  }
427
435
  }
428
436
  else if (key.tab) {
@@ -581,33 +589,47 @@ function AppContent({ __testEnableInput, projectRoot }) {
581
589
  else if (input === "F" && !key.ctrl) {
582
590
  // Toggle follow mode
583
591
  setFollowing((prev) => {
584
- if (!prev) {
585
- selectedRequestIdRef.current = requests[0]?.id ?? null;
586
- setSelectedIndex(0);
592
+ const next = !prev;
593
+ if (next) {
594
+ setSelectedRequestId(null);
595
+ setTopVisibleRequestId(null);
596
+ setPendingNewCount(0);
587
597
  }
588
- return !prev;
598
+ else {
599
+ const currentRequests = requestsRef.current;
600
+ const anchorOffset = effectiveListScrollOffsetRef.current;
601
+ setSelectedRequestId(currentRequests[Math.max(0, selectedIndexRef.current)]?.id ?? currentRequests[0]?.id ?? null);
602
+ setTopVisibleRequestId(currentRequests[anchorOffset]?.id ?? currentRequests[0]?.id ?? null);
603
+ }
604
+ return next;
589
605
  });
590
606
  }
591
607
  else if (input === "f" && key.ctrl) {
592
608
  // Full-page down (list only)
593
609
  if (activePanel === "list") {
610
+ const currentRequests = requestsRef.current;
594
611
  const fullPage = contentHeightRef.current;
595
- const newIdx = Math.min(selectedIndex + fullPage, requestsLengthRef.current - 1);
596
- if (following)
612
+ const currentIndex = Math.max(0, selectedIndexRef.current);
613
+ const newIdx = Math.min(currentIndex + fullPage, requestsLengthRef.current - 1);
614
+ if (followingRef.current) {
597
615
  setFollowing(false);
598
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
599
- setSelectedIndex(newIdx);
616
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
617
+ }
618
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
600
619
  }
601
620
  }
602
621
  else if (input === "b" && key.ctrl) {
603
622
  // Full-page up (list only)
604
623
  if (activePanel === "list") {
624
+ const currentRequests = requestsRef.current;
605
625
  const fullPage = contentHeightRef.current;
606
- const newIdx = Math.max(selectedIndex - fullPage, 0);
607
- if (following)
626
+ const currentIndex = Math.max(0, selectedIndexRef.current);
627
+ const newIdx = Math.max(currentIndex - fullPage, 0);
628
+ if (followingRef.current) {
608
629
  setFollowing(false);
609
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
610
- setSelectedIndex(newIdx);
630
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
631
+ }
632
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
611
633
  }
612
634
  }
613
635
  else if (input === "u" && !key.ctrl) {
@@ -686,27 +708,6 @@ function AppContent({ __testEnableInput, projectRoot }) {
686
708
  const hasSelectedRequest = selectedFullRequest !== null;
687
709
  const listWidth = hasSelectedRequest ? Math.floor(columns * listWidthRatio) : columns;
688
710
  const accordionWidth = columns - listWidth;
689
- // Status bar takes 2 rows (border line + content line), InfoBar takes 1 row, filter bar takes 2 rows when visible
690
- const filterBarHeight = showFilter ? 2 : 0;
691
- const infoBarHeight = 1;
692
- const contentHeight = rows - 2 - infoBarHeight - filterBarHeight;
693
- // Keep selection in bounds when requests change
694
- React.useEffect(() => {
695
- if (selectedIndex >= requests.length && requests.length > 0) {
696
- setSelectedIndex(requests.length - 1);
697
- }
698
- }, [requests.length, selectedIndex]);
699
- // Auto-scroll list view when selection moves outside visible area
700
- React.useEffect(() => {
701
- const visibleHeight = Math.max(1, contentHeight - 2);
702
- if (selectedIndex < listScrollOffset) {
703
- setListScrollOffset(selectedIndex);
704
- }
705
- else if (selectedIndex >= listScrollOffset + visibleHeight) {
706
- setListScrollOffset(selectedIndex - visibleHeight + 1);
707
- }
708
- // Intentionally omit listScrollOffset to allow free mouse wheel scrolling
709
- }, [selectedIndex, contentHeight]);
710
711
  // Terminal size check — re-evaluates on resize via useStdoutDimensions
711
712
  if (columns < MIN_TERMINAL_COLUMNS || rows < MIN_TERMINAL_ROWS) {
712
713
  return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: rows, width: columns, children: [_jsx(Text, { color: "red", bold: true, children: "Terminal too small" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Current: ", columns, "x", rows] }), _jsxs(Text, { children: ["Required: ", MIN_TERMINAL_COLUMNS, "x", MIN_TERMINAL_ROWS] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Please resize your terminal." })] }));
@@ -757,7 +758,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
757
758
  if (showTextViewer && textViewerData) {
758
759
  return (_jsx(TextViewerModal, { text: textViewerData.text, title: textViewerData.title, contentType: textViewerData.contentType, bodySize: textViewerData.bodySize, width: columns, height: rows, onClose: () => setShowTextViewer(false), onStatus: showStatus, isActive: __testEnableInput || isRawModeSupported === true }));
759
760
  }
760
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "row", height: contentHeight, children: [_jsx(RequestList, { ref: listPanelRef, requests: requests, selectedIndex: selectedIndex, isActive: activePanel === "list", isHovered: hoveredPanel === "list", width: listWidth, height: contentHeight, showFullUrl: showFullUrl, onItemClick: handleItemClick, scrollOffset: listScrollOffset, searchTerm: bodySearch ? undefined : filter.search }), hasSelectedRequest && (_jsx(AccordionPanel, { ref: accordionPanelRef, request: selectedFullRequest, isActive: activePanel === "accordion", width: accordionWidth, height: contentHeight, focusedSection: focusedSection, expandedSections: expandedSections }))] }), showFilter && (_jsx(FilterBar, { isActive: (__testEnableInput || isRawModeSupported === true) && showFilter, filter: filter, bodySearch: bodySearch, onFilterChange: handleFilterChange, onBodySearchChange: handleBodySearchChange, onClose: () => setShowFilter(false), onCancel: handleFilterCancel, width: columns })), _jsx(InfoBar, { interceptorErrorCount: interceptorEvents.counts.error, requestCount: requests.length, interceptorCount: interceptorEvents.interceptorCount, startTime: startTime, width: columns }), _jsx(StatusBar, { message: statusMessage, filterActive: isFilterActive(filter) || bodySearch !== undefined, filterOpen: showFilter, following: following, hasSelection: selectedFullRequest !== null, hasRequests: requests.length > 0, onViewableBodySection: currentBodyIsExportable && !currentBodyIsBinary, interceptorCount: interceptorEvents.interceptorCount, interceptorErrorCount: interceptorEvents.counts.error })] }));
761
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Box, { flexDirection: "row", height: contentHeight, children: [_jsx(RequestList, { ref: listPanelRef, requests: requests, selectedIndex: selectedIndex, isActive: activePanel === "list", isHovered: hoveredPanel === "list", width: listWidth, height: contentHeight, showFullUrl: showFullUrl, onItemClick: handleItemClick, scrollOffset: effectiveListScrollOffset, searchTerm: bodySearch ? undefined : filter.search, following: following, pendingNewCount: pendingNewCount }), hasSelectedRequest && (_jsx(AccordionPanel, { ref: accordionPanelRef, request: selectedFullRequest, isActive: activePanel === "accordion", width: accordionWidth, height: contentHeight, focusedSection: focusedSection, expandedSections: expandedSections }))] }), showFilter && (_jsx(FilterBar, { isActive: (__testEnableInput || isRawModeSupported === true) && showFilter, filter: filter, bodySearch: bodySearch, onFilterChange: handleFilterChange, onBodySearchChange: handleBodySearchChange, onClose: () => setShowFilter(false), onCancel: handleFilterCancel, width: columns })), _jsx(InfoBar, { interceptorErrorCount: interceptorEvents.counts.error, requestCount: requests.length, interceptorCount: interceptorEvents.interceptorCount, startTime: startTime, width: columns }), _jsx(StatusBar, { message: statusMessage, filterActive: isFilterActive(filter) || bodySearch !== undefined, filterOpen: showFilter, following: following, hasSelection: selectedFullRequest !== null, hasRequests: requests.length > 0, onViewableBodySection: currentBodyIsExportable && !currentBodyIsBinary, interceptorCount: interceptorEvents.interceptorCount, interceptorErrorCount: interceptorEvents.counts.error })] }));
761
762
  }
762
763
  export function App(props) {
763
764
  return (_jsx(MouseProvider, { children: _jsx(AppContent, { ...props }) }));