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.
- package/README.md +35 -9
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +63 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/tui/App.d.ts.map +1 -1
- package/dist/cli/tui/App.js +126 -125
- package/dist/cli/tui/App.js.map +1 -1
- package/dist/cli/tui/components/Panel.d.ts +4 -0
- package/dist/cli/tui/components/Panel.d.ts.map +1 -1
- package/dist/cli/tui/components/Panel.js +33 -12
- package/dist/cli/tui/components/Panel.js.map +1 -1
- package/dist/cli/tui/components/RequestList.d.ts +4 -0
- package/dist/cli/tui/components/RequestList.d.ts.map +1 -1
- package/dist/cli/tui/components/RequestList.js +18 -2
- package/dist/cli/tui/components/RequestList.js.map +1 -1
- package/dist/cli/tui/components/StatusBar.d.ts.map +1 -1
- package/dist/cli/tui/components/StatusBar.js +1 -1
- package/dist/cli/tui/components/StatusBar.js.map +1 -1
- package/dist/cli/tui/hooks/useRequestListState.d.ts +23 -0
- package/dist/cli/tui/hooks/useRequestListState.d.ts.map +1 -0
- package/dist/cli/tui/hooks/useRequestListState.js +88 -0
- package/dist/cli/tui/hooks/useRequestListState.js.map +1 -0
- package/dist/cli/tui/hooks/useRequests.d.ts.map +1 -1
- package/dist/cli/tui/hooks/useRequests.js +201 -55
- package/dist/cli/tui/hooks/useRequests.js.map +1 -1
- package/dist/cli/tui/state/request-list-state.d.ts +17 -0
- package/dist/cli/tui/state/request-list-state.d.ts.map +1 -0
- package/dist/cli/tui/state/request-list-state.js +45 -0
- package/dist/cli/tui/state/request-list-state.js.map +1 -0
- package/dist/daemon/control.d.ts.map +1 -1
- package/dist/daemon/control.js +15 -0
- package/dist/daemon/control.js.map +1 -1
- package/dist/daemon/storage.d.ts +16 -1
- package/dist/daemon/storage.d.ts.map +1 -1
- package/dist/daemon/storage.js +136 -16
- package/dist/daemon/storage.js.map +1 -1
- package/dist/shared/control-client.d.ts +9 -1
- package/dist/shared/control-client.d.ts.map +1 -1
- package/dist/shared/control-client.js +6 -0
- package/dist/shared/control-client.js.map +1 -1
- package/dist/shared/types.d.ts +17 -0
- package/dist/shared/types.d.ts.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/cli/commands/mcp.js
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/cli/tui/App.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
setFollowing(true);
|
|
163
|
-
}, []);
|
|
142
|
+
resetToFollowMode();
|
|
143
|
+
}, [resetToFollowMode]);
|
|
164
144
|
const handleBodySearchChange = useCallback((nextBodySearch) => {
|
|
165
145
|
setBodySearch(nextBodySearch);
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
if (
|
|
157
|
+
const selected = requests[index];
|
|
158
|
+
if (!selected) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (following) {
|
|
183
162
|
setFollowing(false);
|
|
184
|
-
|
|
163
|
+
setTopVisibleRequestId(requests[effectiveListScrollOffsetRef.current]?.id ?? requests[0]?.id ?? null);
|
|
164
|
+
}
|
|
165
|
+
setSelectedRequestId(selected.id);
|
|
185
166
|
setActivePanel("list");
|
|
186
167
|
}, [requests, following]);
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
|
397
|
-
|
|
396
|
+
const currentRequests = requestsRef.current;
|
|
397
|
+
const lastIdx = Math.max(0, currentRequests.length - 1);
|
|
398
|
+
if (followingRef.current) {
|
|
398
399
|
setFollowing(false);
|
|
399
|
-
|
|
400
|
-
|
|
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(
|
|
411
|
-
if (
|
|
414
|
+
const newIdx = Math.max(currentIndex - halfPage, 0);
|
|
415
|
+
if (followingRef.current) {
|
|
412
416
|
setFollowing(false);
|
|
413
|
-
|
|
414
|
-
|
|
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(
|
|
422
|
-
if (
|
|
428
|
+
const newIdx = Math.min(currentIndex + halfPage, currentRequests.length - 1);
|
|
429
|
+
if (followingRef.current) {
|
|
423
430
|
setFollowing(false);
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
592
|
+
const next = !prev;
|
|
593
|
+
if (next) {
|
|
594
|
+
setSelectedRequestId(null);
|
|
595
|
+
setTopVisibleRequestId(null);
|
|
596
|
+
setPendingNewCount(0);
|
|
587
597
|
}
|
|
588
|
-
|
|
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
|
|
596
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
|
607
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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:
|
|
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 }) }));
|