procsi 0.8.1 → 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 -113
  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;AAs5B/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,75 +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
- if (requests.length > 0 && selectedIndex !== 0) {
134
- setSelectedIndex(0);
135
- }
136
- return;
137
- }
138
- const targetId = selectedRequestIdRef.current;
139
- if (!targetId || requests.length === 0)
140
- return;
141
- const newIndex = requests.findIndex((r) => r.id === targetId);
142
- if (newIndex !== -1 && newIndex !== selectedIndex) {
143
- const indexDelta = newIndex - selectedIndex;
144
- setSelectedIndex(newIndex);
145
- setListScrollOffset((prev) => Math.max(0, prev + indexDelta));
146
- }
147
- }, [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;
148
136
  // Stores the filter state at the moment the filter bar opens, so Escape can revert
149
137
  const preOpenFilterRef = useRef({});
150
138
  const preOpenBodySearchRef = useRef(undefined);
151
139
  // Handle filter change from the filter bar
152
140
  const handleFilterChange = useCallback((newFilter) => {
153
141
  setFilter(newFilter);
154
- setSelectedIndex(0);
155
- selectedRequestIdRef.current = null;
156
- setFollowing(true);
157
- }, []);
142
+ resetToFollowMode();
143
+ }, [resetToFollowMode]);
158
144
  const handleBodySearchChange = useCallback((nextBodySearch) => {
159
145
  setBodySearch(nextBodySearch);
160
- setSelectedIndex(0);
161
- selectedRequestIdRef.current = null;
162
- setFollowing(true);
163
- }, []);
146
+ resetToFollowMode();
147
+ }, [resetToFollowMode]);
164
148
  // Handle filter cancel — revert to pre-open state
165
149
  const handleFilterCancel = useCallback(() => {
166
150
  setFilter(preOpenFilterRef.current);
167
151
  setBodySearch(preOpenBodySearchRef.current);
168
- setSelectedIndex(0);
169
- selectedRequestIdRef.current = null;
170
- setFollowing(true);
152
+ resetToFollowMode();
171
153
  setShowFilter(false);
172
- }, []);
154
+ }, [resetToFollowMode]);
173
155
  // Handle item click from the request list
174
156
  const handleItemClick = useCallback((index) => {
175
- selectedRequestIdRef.current = requests[index]?.id ?? null;
176
- if (following)
157
+ const selected = requests[index];
158
+ if (!selected) {
159
+ return;
160
+ }
161
+ if (following) {
177
162
  setFollowing(false);
178
- setSelectedIndex(index);
163
+ setTopVisibleRequestId(requests[effectiveListScrollOffsetRef.current]?.id ?? requests[0]?.id ?? null);
164
+ }
165
+ setSelectedRequestId(selected.id);
179
166
  setActivePanel("list");
180
167
  }, [requests, following]);
181
- // 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.
182
171
  useEffect(() => {
183
- if (selectedSummary) {
184
- void getFullRequest(selectedSummary.id).then(setSelectedFullRequest);
185
- }
186
- else {
172
+ if (!selectedSummary) {
173
+ selectedDetailRequestIdRef.current = null;
187
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;
188
181
  }
189
- }, [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]);
190
191
  // Reset all sections to expanded when a new request is selected
191
192
  useEffect(() => {
192
193
  if (selectedSummary) {
@@ -198,11 +199,19 @@ function AppContent({ __testEnableInput, projectRoot }) {
198
199
  // Use refs to avoid stale closures if useOnWheel caches the callback
199
200
  const visibleHeight = Math.max(1, contentHeightRef.current - 2);
200
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
+ }
201
208
  if (event.button === "wheel-up") {
202
- setListScrollOffset((prev) => Math.max(prev - 1, 0));
209
+ const nextOffset = Math.max(currentOffset - 1, 0);
210
+ setTopVisibleRequestId(currentRequests[nextOffset]?.id ?? null);
203
211
  }
204
212
  else if (event.button === "wheel-down") {
205
- setListScrollOffset((prev) => Math.min(prev + 1, maxOffset));
213
+ const nextOffset = Math.min(currentOffset + 1, maxOffset);
214
+ setTopVisibleRequestId(currentRequests[nextOffset]?.id ?? null);
206
215
  }
207
216
  });
208
217
  // Handle click on panels to activate them
@@ -329,8 +338,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
329
338
  if (pendingClear) {
330
339
  setPendingClear(false);
331
340
  if (input === "y") {
332
- selectedRequestIdRef.current = null;
333
- setFollowing(true);
341
+ resetToFollowMode();
334
342
  void clearRequests().then((success) => {
335
343
  showStatus(success ? "Requests cleared (bookmarks preserved)" : "Failed to clear requests");
336
344
  });
@@ -343,11 +351,14 @@ function AppContent({ __testEnableInput, projectRoot }) {
343
351
  // Navigation - behaviour depends on active panel
344
352
  if (input === "j" || key.downArrow) {
345
353
  if (activePanel === "list") {
346
- 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) {
347
358
  setFollowing(false);
348
- const newIdx = Math.min(selectedIndex + 1, requests.length - 1);
349
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
350
- setSelectedIndex(newIdx);
359
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
360
+ }
361
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
351
362
  }
352
363
  else {
353
364
  // Navigate sections in accordion
@@ -356,11 +367,14 @@ function AppContent({ __testEnableInput, projectRoot }) {
356
367
  }
357
368
  else if (input === "k" || key.upArrow) {
358
369
  if (activePanel === "list") {
359
- 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) {
360
374
  setFollowing(false);
361
- const newIdx = Math.max(selectedIndex - 1, 0);
362
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
363
- setSelectedIndex(newIdx);
375
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
376
+ }
377
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
364
378
  }
365
379
  else {
366
380
  // Navigate sections in accordion
@@ -370,9 +384,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
370
384
  else if (input === "g" && !key.shift) {
371
385
  // Jump to first item/section — re-enters follow mode in list
372
386
  if (activePanel === "list") {
373
- selectedRequestIdRef.current = requests[0]?.id ?? null;
374
- setSelectedIndex(0);
375
- setFollowing(true);
387
+ resetToFollowMode();
376
388
  }
377
389
  else {
378
390
  setFocusedSection(SECTION_REQUEST);
@@ -381,11 +393,13 @@ function AppContent({ __testEnableInput, projectRoot }) {
381
393
  else if (input === "G") {
382
394
  // Jump to last item/section
383
395
  if (activePanel === "list") {
384
- const lastIdx = Math.max(0, requestsLengthRef.current - 1);
385
- if (following)
396
+ const currentRequests = requestsRef.current;
397
+ const lastIdx = Math.max(0, currentRequests.length - 1);
398
+ if (followingRef.current) {
386
399
  setFollowing(false);
387
- selectedRequestIdRef.current = requests[lastIdx]?.id ?? null;
388
- setSelectedIndex(lastIdx);
400
+ }
401
+ setTopVisibleRequestId(currentRequests[Math.max(0, lastIdx - visibleListHeight + 1)]?.id ?? currentRequests[0]?.id ?? null);
402
+ setSelectedRequestId(currentRequests[lastIdx]?.id ?? null);
389
403
  }
390
404
  else {
391
405
  setFocusedSection(SECTION_RESPONSE_BODY);
@@ -394,23 +408,29 @@ function AppContent({ __testEnableInput, projectRoot }) {
394
408
  else if (input === "u" && key.ctrl) {
395
409
  // Half-page up (list only)
396
410
  if (activePanel === "list") {
411
+ const currentRequests = requestsRef.current;
412
+ const currentIndex = Math.max(0, selectedIndexRef.current);
397
413
  const halfPage = Math.floor(contentHeightRef.current / 2);
398
- const newIdx = Math.max(selectedIndex - halfPage, 0);
399
- if (following)
414
+ const newIdx = Math.max(currentIndex - halfPage, 0);
415
+ if (followingRef.current) {
400
416
  setFollowing(false);
401
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
402
- setSelectedIndex(newIdx);
417
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
418
+ }
419
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
403
420
  }
404
421
  }
405
422
  else if (input === "d" && key.ctrl) {
406
423
  // Half-page down (list only)
407
424
  if (activePanel === "list") {
425
+ const currentRequests = requestsRef.current;
426
+ const currentIndex = Math.max(0, selectedIndexRef.current);
408
427
  const halfPage = Math.floor(contentHeightRef.current / 2);
409
- const newIdx = Math.min(selectedIndex + halfPage, requestsLengthRef.current - 1);
410
- if (following)
428
+ const newIdx = Math.min(currentIndex + halfPage, currentRequests.length - 1);
429
+ if (followingRef.current) {
411
430
  setFollowing(false);
412
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
413
- setSelectedIndex(newIdx);
431
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
432
+ }
433
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
414
434
  }
415
435
  }
416
436
  else if (key.tab) {
@@ -569,33 +589,47 @@ function AppContent({ __testEnableInput, projectRoot }) {
569
589
  else if (input === "F" && !key.ctrl) {
570
590
  // Toggle follow mode
571
591
  setFollowing((prev) => {
572
- if (!prev) {
573
- selectedRequestIdRef.current = requests[0]?.id ?? null;
574
- setSelectedIndex(0);
592
+ const next = !prev;
593
+ if (next) {
594
+ setSelectedRequestId(null);
595
+ setTopVisibleRequestId(null);
596
+ setPendingNewCount(0);
597
+ }
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);
575
603
  }
576
- return !prev;
604
+ return next;
577
605
  });
578
606
  }
579
607
  else if (input === "f" && key.ctrl) {
580
608
  // Full-page down (list only)
581
609
  if (activePanel === "list") {
610
+ const currentRequests = requestsRef.current;
582
611
  const fullPage = contentHeightRef.current;
583
- const newIdx = Math.min(selectedIndex + fullPage, requestsLengthRef.current - 1);
584
- if (following)
612
+ const currentIndex = Math.max(0, selectedIndexRef.current);
613
+ const newIdx = Math.min(currentIndex + fullPage, requestsLengthRef.current - 1);
614
+ if (followingRef.current) {
585
615
  setFollowing(false);
586
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
587
- setSelectedIndex(newIdx);
616
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
617
+ }
618
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
588
619
  }
589
620
  }
590
621
  else if (input === "b" && key.ctrl) {
591
622
  // Full-page up (list only)
592
623
  if (activePanel === "list") {
624
+ const currentRequests = requestsRef.current;
593
625
  const fullPage = contentHeightRef.current;
594
- const newIdx = Math.max(selectedIndex - fullPage, 0);
595
- if (following)
626
+ const currentIndex = Math.max(0, selectedIndexRef.current);
627
+ const newIdx = Math.max(currentIndex - fullPage, 0);
628
+ if (followingRef.current) {
596
629
  setFollowing(false);
597
- selectedRequestIdRef.current = requests[newIdx]?.id ?? null;
598
- setSelectedIndex(newIdx);
630
+ setTopVisibleRequestId(currentRequests[effectiveListScrollOffsetRef.current]?.id ?? currentRequests[0]?.id ?? null);
631
+ }
632
+ setSelectedRequestId(currentRequests[newIdx]?.id ?? null);
599
633
  }
600
634
  }
601
635
  else if (input === "u" && !key.ctrl) {
@@ -674,27 +708,6 @@ function AppContent({ __testEnableInput, projectRoot }) {
674
708
  const hasSelectedRequest = selectedFullRequest !== null;
675
709
  const listWidth = hasSelectedRequest ? Math.floor(columns * listWidthRatio) : columns;
676
710
  const accordionWidth = columns - listWidth;
677
- // Status bar takes 2 rows (border line + content line), InfoBar takes 1 row, filter bar takes 2 rows when visible
678
- const filterBarHeight = showFilter ? 2 : 0;
679
- const infoBarHeight = 1;
680
- const contentHeight = rows - 2 - infoBarHeight - filterBarHeight;
681
- // Keep selection in bounds when requests change
682
- React.useEffect(() => {
683
- if (selectedIndex >= requests.length && requests.length > 0) {
684
- setSelectedIndex(requests.length - 1);
685
- }
686
- }, [requests.length, selectedIndex]);
687
- // Auto-scroll list view when selection moves outside visible area
688
- React.useEffect(() => {
689
- const visibleHeight = Math.max(1, contentHeight - 2);
690
- if (selectedIndex < listScrollOffset) {
691
- setListScrollOffset(selectedIndex);
692
- }
693
- else if (selectedIndex >= listScrollOffset + visibleHeight) {
694
- setListScrollOffset(selectedIndex - visibleHeight + 1);
695
- }
696
- // Intentionally omit listScrollOffset to allow free mouse wheel scrolling
697
- }, [selectedIndex, contentHeight]);
698
711
  // Terminal size check — re-evaluates on resize via useStdoutDimensions
699
712
  if (columns < MIN_TERMINAL_COLUMNS || rows < MIN_TERMINAL_ROWS) {
700
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." })] }));
@@ -745,7 +758,7 @@ function AppContent({ __testEnableInput, projectRoot }) {
745
758
  if (showTextViewer && textViewerData) {
746
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 }));
747
760
  }
748
- 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 })] }));
749
762
  }
750
763
  export function App(props) {
751
764
  return (_jsx(MouseProvider, { children: _jsx(AppContent, { ...props }) }));