peep-proxy 0.1.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 (72) hide show
  1. package/dist/app.d.ts +9 -0
  2. package/dist/app.js +162 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +98 -0
  5. package/dist/components/BorderedBox.d.ts +10 -0
  6. package/dist/components/BorderedBox.js +13 -0
  7. package/dist/components/DetailPanel.d.ts +13 -0
  8. package/dist/components/DetailPanel.js +8 -0
  9. package/dist/components/DetailView.d.ts +12 -0
  10. package/dist/components/DetailView.js +151 -0
  11. package/dist/components/DomainSidebar.d.ts +12 -0
  12. package/dist/components/DomainSidebar.js +36 -0
  13. package/dist/components/RequestList.d.ts +22 -0
  14. package/dist/components/RequestList.js +30 -0
  15. package/dist/components/RequestRow.d.ts +15 -0
  16. package/dist/components/RequestRow.js +84 -0
  17. package/dist/components/SortModal.d.ts +8 -0
  18. package/dist/components/SortModal.js +56 -0
  19. package/dist/components/SpinnerContext.d.ts +5 -0
  20. package/dist/components/SpinnerContext.js +20 -0
  21. package/dist/components/StatusBar.d.ts +11 -0
  22. package/dist/components/StatusBar.js +21 -0
  23. package/dist/hooks/useActivePanel.d.ts +11 -0
  24. package/dist/hooks/useActivePanel.js +36 -0
  25. package/dist/hooks/useDetailScroll.d.ts +11 -0
  26. package/dist/hooks/useDetailScroll.js +59 -0
  27. package/dist/hooks/useDetailTabs.d.ts +14 -0
  28. package/dist/hooks/useDetailTabs.js +50 -0
  29. package/dist/hooks/useDomainFilter.d.ts +30 -0
  30. package/dist/hooks/useDomainFilter.js +103 -0
  31. package/dist/hooks/useListNavigation.d.ts +12 -0
  32. package/dist/hooks/useListNavigation.js +105 -0
  33. package/dist/hooks/useSorting.d.ts +20 -0
  34. package/dist/hooks/useSorting.js +71 -0
  35. package/dist/hooks/useTerminalDimensions.d.ts +6 -0
  36. package/dist/hooks/useTerminalDimensions.js +25 -0
  37. package/dist/hooks/useTrafficEntries.d.ts +2 -0
  38. package/dist/hooks/useTrafficEntries.js +16 -0
  39. package/dist/proxy/ca.d.ts +4 -0
  40. package/dist/proxy/ca.js +72 -0
  41. package/dist/proxy/cert-trust.d.ts +20 -0
  42. package/dist/proxy/cert-trust.js +181 -0
  43. package/dist/proxy/index.d.ts +3 -0
  44. package/dist/proxy/index.js +2 -0
  45. package/dist/proxy/proxy-server.d.ts +9 -0
  46. package/dist/proxy/proxy-server.js +262 -0
  47. package/dist/proxy/system-proxy.d.ts +2 -0
  48. package/dist/proxy/system-proxy.js +64 -0
  49. package/dist/proxy/types.d.ts +45 -0
  50. package/dist/proxy/types.js +1 -0
  51. package/dist/store/index.d.ts +2 -0
  52. package/dist/store/index.js +1 -0
  53. package/dist/store/traffic-store.d.ts +14 -0
  54. package/dist/store/traffic-store.js +87 -0
  55. package/dist/store/types.d.ts +14 -0
  56. package/dist/store/types.js +1 -0
  57. package/dist/theme.d.ts +1 -0
  58. package/dist/theme.js +1 -0
  59. package/dist/utils/contentType.d.ts +7 -0
  60. package/dist/utils/contentType.js +46 -0
  61. package/dist/utils/copyToClipboard.d.ts +1 -0
  62. package/dist/utils/copyToClipboard.js +21 -0
  63. package/dist/utils/decompress.d.ts +2 -0
  64. package/dist/utils/decompress.js +25 -0
  65. package/dist/utils/formatBytes.d.ts +1 -0
  66. package/dist/utils/formatBytes.js +14 -0
  67. package/dist/utils/getTabText.d.ts +3 -0
  68. package/dist/utils/getTabText.js +96 -0
  69. package/dist/utils/highlightBody.d.ts +3 -0
  70. package/dist/utils/highlightBody.js +43 -0
  71. package/package.json +57 -0
  72. package/readme.md +73 -0
package/dist/app.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { TrafficStore } from "./store/index.js";
2
+ export type QuitStep = (message: string, replaceLast?: boolean) => void;
3
+ type Props = {
4
+ store: TrafficStore;
5
+ port: number;
6
+ onQuit: (step: QuitStep) => Promise<void>;
7
+ };
8
+ export default function App({ store, port, onQuit }: Props): import("react/jsx-runtime").JSX.Element;
9
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,162 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { DetailPanel } from "./components/DetailPanel.js";
5
+ import { DomainSidebar } from "./components/DomainSidebar.js";
6
+ import { RequestList } from "./components/RequestList.js";
7
+ import { SortModal } from "./components/SortModal.js";
8
+ import { StatusBar } from "./components/StatusBar.js";
9
+ import { useActivePanel } from "./hooks/useActivePanel.js";
10
+ import { useDetailTabs } from "./hooks/useDetailTabs.js";
11
+ import { useDomainGroups } from "./hooks/useDomainFilter.js";
12
+ import { useListNavigation } from "./hooks/useListNavigation.js";
13
+ import { useSorting } from "./hooks/useSorting.js";
14
+ import { useTerminalDimensions } from "./hooks/useTerminalDimensions.js";
15
+ import { useTrafficEntries } from "./hooks/useTrafficEntries.js";
16
+ const COL_ID = 4;
17
+ const COL_METHOD = 7;
18
+ const COL_STATUS = 6;
19
+ const COL_DURATION = 8;
20
+ const COL_SIZE = 7;
21
+ const COL_PADDING = 12; // leading space + 5 separators (1 each) + trailing
22
+ const STATUS_BAR_HEIGHT = 1;
23
+ const LIST_CHROME_LINES = 3; // border top + header row + border bottom
24
+ const SIDEBAR_BORDER_LINES = 2; // top + bottom border
25
+ const SIDEBAR_WIDTH = 22;
26
+ export default function App({ store, port, onQuit }) {
27
+ const { exit } = useApp();
28
+ const [quitting, setQuitting] = useState(false);
29
+ const [quitSteps, setQuitSteps] = useState([]);
30
+ const entries = useTrafficEntries(store);
31
+ const { columns, rows } = useTerminalDimensions();
32
+ useEffect(() => {
33
+ if (!quitting)
34
+ return;
35
+ let cancelled = false;
36
+ const step = (message, replaceLast) => {
37
+ if (!cancelled)
38
+ setQuitSteps((prev) => replaceLast ? [...prev.slice(0, -1), message] : [...prev, message]);
39
+ };
40
+ onQuit(step).finally(() => {
41
+ if (!cancelled)
42
+ exit();
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [quitting, onQuit, exit]);
48
+ // Refs to break circular deps: useActivePanel needs hasEntries/awaitingColumn
49
+ // which come from hooks that need activePanel. Refs use previous render values
50
+ // for those guards, while activePanel itself is always fresh.
51
+ const hasSelectionRef = useRef(false);
52
+ const sortModalOpenRef = useRef(false);
53
+ const sidebarSelectedRef = useRef(0);
54
+ const available = Math.max(1, rows - STATUS_BAR_HEIGHT);
55
+ const contentWidth = columns - SIDEBAR_WIDTH;
56
+ const listInnerWidth = contentWidth - 2; // bordered box borders
57
+ const sidebarViewportHeight = Math.max(1, available - SIDEBAR_BORDER_LINES);
58
+ // Active panel — called first so activePanel is fresh for isActive guards
59
+ const { activePanel, setActivePanel } = useActivePanel({
60
+ hasSelection: hasSelectionRef.current,
61
+ awaitingColumn: sortModalOpenRef.current,
62
+ });
63
+ // Domain grouping (expand/collapse, no dependency on selected index)
64
+ const { visibleItems, groups, expandAtIndex, collapseAtIndex } = useDomainGroups(entries);
65
+ const sidebarKeys = useMemo(() => visibleItems.map((item) => {
66
+ if (item.type === "all")
67
+ return "all";
68
+ if (item.type === "group")
69
+ return `group:${item.baseDomain}`;
70
+ return `domain:${item.host}`;
71
+ }), [visibleItems]);
72
+ // Sidebar navigation — uses activePanel directly (not a ref)
73
+ const { selectedIndex: sidebarSelectedIndex, scrollOffset: sidebarScrollOffset, } = useListNavigation({
74
+ itemCount: visibleItems.length,
75
+ viewportHeight: sidebarViewportHeight,
76
+ isActive: activePanel === "sidebar",
77
+ keys: sidebarKeys,
78
+ });
79
+ sidebarSelectedRef.current = sidebarSelectedIndex;
80
+ // Domain filtering (inline, uses selected index from navigation)
81
+ const filteredEntries = useMemo(() => {
82
+ const item = visibleItems[sidebarSelectedIndex];
83
+ if (!item || item.type === "all")
84
+ return entries;
85
+ if (item.type === "domain") {
86
+ return entries.filter((e) => e.request.host === item.host);
87
+ }
88
+ const group = groups.find((g) => g.baseDomain === item.baseDomain);
89
+ if (!group)
90
+ return entries;
91
+ const hosts = new Set(group.domains.map((d) => d.host));
92
+ return entries.filter((e) => hosts.has(e.request.host));
93
+ }, [entries, sidebarSelectedIndex, visibleItems, groups]);
94
+ const { sortedEntries, sortConfig, modalOpen, selectColumn, closeModal } = useSorting({
95
+ entries: filteredEntries,
96
+ isActive: activePanel === "list",
97
+ });
98
+ const hasEntries = sortedEntries.length > 0;
99
+ // Update refs for next render
100
+ hasSelectionRef.current = hasEntries;
101
+ sortModalOpenRef.current = modalOpen;
102
+ const listHeight = hasEntries
103
+ ? Math.max(LIST_CHROME_LINES + 1, Math.floor(available * 0.4))
104
+ : available;
105
+ const detailHeight = available - listHeight;
106
+ const listViewportHeight = Math.max(1, listHeight - LIST_CHROME_LINES);
107
+ const colUrl = Math.max(10, listInnerWidth -
108
+ COL_ID -
109
+ COL_METHOD -
110
+ COL_STATUS -
111
+ COL_DURATION -
112
+ COL_SIZE -
113
+ COL_PADDING);
114
+ const requestKeys = useMemo(() => sortedEntries.map((e) => e.id), [sortedEntries]);
115
+ const { selectedIndex, scrollOffset } = useListNavigation({
116
+ itemCount: sortedEntries.length,
117
+ viewportHeight: listViewportHeight,
118
+ isActive: activePanel === "list" && !modalOpen,
119
+ keys: requestKeys,
120
+ });
121
+ const { requestTab, responseTab, notification } = useDetailTabs({
122
+ activePanel,
123
+ selectedEntry: sortedEntries[selectedIndex],
124
+ });
125
+ useInput(useCallback((input, key) => {
126
+ if (input === "q") {
127
+ setQuitting(true);
128
+ return;
129
+ }
130
+ if (modalOpen)
131
+ return;
132
+ if (activePanel === "sidebar") {
133
+ if (key.return) {
134
+ setActivePanel("list");
135
+ }
136
+ else if (input === "l") {
137
+ expandAtIndex(sidebarSelectedRef.current);
138
+ }
139
+ else if (input === "h") {
140
+ collapseAtIndex(sidebarSelectedRef.current);
141
+ }
142
+ }
143
+ else if (activePanel === "list") {
144
+ if (key.return && hasSelectionRef.current) {
145
+ setActivePanel("request");
146
+ }
147
+ }
148
+ }, [modalOpen, activePanel, expandAtIndex, collapseAtIndex, setActivePanel]));
149
+ const columnWidths = {
150
+ id: COL_ID,
151
+ method: COL_METHOD,
152
+ status: COL_STATUS,
153
+ duration: COL_DURATION,
154
+ size: COL_SIZE,
155
+ url: colUrl,
156
+ };
157
+ const selectedEntry = sortedEntries[selectedIndex];
158
+ if (quitting) {
159
+ return (_jsxs(Box, { flexDirection: "column", height: rows, paddingTop: 1, paddingLeft: 2, children: [_jsx(Text, { bold: true, children: "Shutting down\u2026" }), quitSteps.map((msg) => (_jsxs(Text, { dimColor: true, children: [" ", msg] }, msg)))] }));
160
+ }
161
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [modalOpen && (_jsx(Box, { position: "absolute", marginTop: Math.max(0, Math.floor((rows - 8) / 2)), marginLeft: Math.max(0, Math.floor((columns - 22) / 2)), children: _jsx(SortModal, { sortConfig: sortConfig, onSelect: selectColumn, onClose: closeModal }) })), _jsxs(Box, { flexDirection: "row", height: available, children: [_jsx(DomainSidebar, { items: visibleItems, selectedIndex: sidebarSelectedIndex, scrollOffset: sidebarScrollOffset, viewportHeight: sidebarViewportHeight, width: SIDEBAR_WIDTH, height: available, isActive: activePanel === "sidebar" }), _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(RequestList, { entries: sortedEntries, selectedIndex: selectedIndex, scrollOffset: scrollOffset, viewportHeight: listViewportHeight, sortConfig: sortConfig, columnWidths: columnWidths, width: contentWidth, height: listHeight, isActive: activePanel === "list" }), selectedEntry && detailHeight > 0 && (_jsx(DetailPanel, { entry: selectedEntry, activePanel: activePanel, requestTab: requestTab, responseTab: responseTab, width: contentWidth, height: detailHeight }))] })] }), _jsx(StatusBar, { port: port, requestCount: sortedEntries.length, selectedIndex: selectedIndex, columns: columns, activePanel: activePanel, notification: notification })] }));
162
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { render } from "ink";
6
+ import meow from "meow";
7
+ import { findNssProfiles, isCaTrusted, isNssTrusted, trustCa, trustNssStores, } from "./proxy/cert-trust.js";
8
+ import { loadOrCreateCA, ProxyServer } from "./proxy/index.js";
9
+ import { disableSystemProxy, enableSystemProxy } from "./proxy/system-proxy.js";
10
+ import { TrafficStore } from "./store/index.js";
11
+ import App from "./app.js";
12
+ const cli = meow(`
13
+ Usage
14
+ $ peep
15
+
16
+ Options
17
+ --port Proxy port (default: 8080)
18
+
19
+ Examples
20
+ $ peep --port=3128
21
+ `, {
22
+ importMeta: import.meta,
23
+ flags: {
24
+ port: {
25
+ type: "number",
26
+ default: 8080,
27
+ },
28
+ },
29
+ });
30
+ const port = cli.flags.port;
31
+ const caDir = path.join(os.homedir(), ".peep");
32
+ const certPath = path.join(caDir, "ca-cert.pem");
33
+ const ca = await loadOrCreateCA(caDir);
34
+ if (!isCaTrusted(certPath)) {
35
+ process.stderr.write("\nPeep Proxy CA is not trusted. Type your user password to install it.\n\n");
36
+ const ok = trustCa(certPath);
37
+ if (!ok) {
38
+ process.stderr.write("Failed to install CA. HTTPS interception may not work.\n\n");
39
+ }
40
+ }
41
+ if (findNssProfiles().length > 0 && !isNssTrusted()) {
42
+ process.stderr.write("\nFirefox/Zen use their own certificate store and need separate trust setup.\n");
43
+ const result = trustNssStores(certPath);
44
+ switch (result.status) {
45
+ case "ok":
46
+ process.stderr.write(`CA trusted in ${result.count} browser profile(s). Restart your browser to apply.\n\n`);
47
+ break;
48
+ case "no-certutil":
49
+ if (result.hasBrew) {
50
+ process.stderr.write('Run "brew install nss" and restart peep to enable Firefox/Zen HTTPS support.\n\n');
51
+ }
52
+ else {
53
+ process.stderr.write("Install certutil (from nss) to enable Firefox/Zen HTTPS support.\n\n");
54
+ }
55
+ break;
56
+ case "install-failed":
57
+ process.stderr.write('Failed to install nss. Run "brew install nss" manually and restart peep.\n\n');
58
+ break;
59
+ case "partial":
60
+ process.stderr.write(`CA trusted in ${result.trusted}/${result.total} browser profile(s). Some profiles may not work.\n\n`);
61
+ break;
62
+ }
63
+ }
64
+ const proxy = new ProxyServer({ port, ca });
65
+ const store = new TrafficStore(proxy);
66
+ await proxy.start();
67
+ const proxyService = enableSystemProxy(port);
68
+ function cleanup() {
69
+ if (proxyService)
70
+ disableSystemProxy(proxyService);
71
+ }
72
+ async function onQuit(step) {
73
+ let t = performance.now();
74
+ if (proxyService) {
75
+ step("Restoring system proxy…");
76
+ disableSystemProxy(proxyService);
77
+ step(`Restored system proxy (${elapsed(t)})`, true);
78
+ }
79
+ t = performance.now();
80
+ step("Stopping proxy server…");
81
+ store.destroy();
82
+ await proxy.stop();
83
+ step(`Stopped proxy server (${elapsed(t)})`, true);
84
+ }
85
+ function elapsed(start) {
86
+ return `${Math.round(performance.now() - start)}ms`;
87
+ }
88
+ process.on("SIGINT", () => {
89
+ cleanup();
90
+ process.exit(0);
91
+ });
92
+ process.on("SIGTERM", () => {
93
+ cleanup();
94
+ process.exit(0);
95
+ });
96
+ const { waitUntilExit } = render(_jsx(App, { store: store, port: port, onQuit: onQuit }));
97
+ await waitUntilExit();
98
+ process.exit(0);
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+ type Props = {
3
+ title: string;
4
+ width: number;
5
+ height: number;
6
+ isActive: boolean;
7
+ children: ReactNode;
8
+ };
9
+ export declare function BorderedBox({ title, width, height, isActive, children, }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { PRIMARY_COLOR } from "../theme.js";
4
+ export function BorderedBox({ title, width, height, isActive, children, }) {
5
+ const innerWidth = Math.max(0, width - 2);
6
+ const innerHeight = Math.max(0, height - 2);
7
+ const titleStr = ` ${title} `;
8
+ const remaining = Math.max(0, innerWidth - titleStr.length - 1);
9
+ const topLine = `┌─${titleStr}${"─".repeat(remaining)}┐`;
10
+ const bottomLine = `└${"─".repeat(innerWidth)}┘`;
11
+ const borderColor = isActive ? PRIMARY_COLOR : undefined;
12
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: isActive, color: borderColor, dimColor: !isActive, children: topLine }), _jsxs(Box, { flexDirection: "row", height: innerHeight, children: [_jsx(Text, { color: borderColor, dimColor: !isActive, children: "│\n".repeat(innerHeight).trimEnd() }), _jsx(Box, { flexDirection: "column", width: innerWidth, children: children }), _jsx(Text, { color: borderColor, dimColor: !isActive, children: "│\n".repeat(innerHeight).trimEnd() })] }), _jsx(Text, { color: borderColor, dimColor: !isActive, children: bottomLine })] }));
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { Panel } from "../hooks/useActivePanel.js";
2
+ import type { DetailTab } from "../hooks/useDetailTabs.js";
3
+ import type { TrafficEntry } from "../store/index.js";
4
+ type Props = {
5
+ entry: TrafficEntry;
6
+ activePanel: Panel;
7
+ requestTab: DetailTab;
8
+ responseTab: DetailTab;
9
+ width: number;
10
+ height: number;
11
+ };
12
+ export declare function DetailPanel({ entry, activePanel, requestTab, responseTab, width, height, }: Props): import("react/jsx-runtime").JSX.Element;
13
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box } from "ink";
3
+ import { DetailView } from "./DetailView.js";
4
+ export function DetailPanel({ entry, activePanel, requestTab, responseTab, width, height, }) {
5
+ const leftWidth = Math.floor(width / 2);
6
+ const rightWidth = width - leftWidth;
7
+ return (_jsxs(Box, { height: height, children: [_jsx(DetailView, { entry: entry, side: "request", activeTab: requestTab, isActive: activePanel === "request", width: leftWidth, height: height }), _jsx(DetailView, { entry: entry, side: "response", activeTab: responseTab, isActive: activePanel === "response", width: rightWidth, height: height })] }));
8
+ }
@@ -0,0 +1,12 @@
1
+ import type { DetailTab } from "../hooks/useDetailTabs.js";
2
+ import type { TrafficEntry } from "../store/index.js";
3
+ type Props = {
4
+ entry: TrafficEntry;
5
+ side: "request" | "response";
6
+ activeTab: DetailTab;
7
+ isActive: boolean;
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export declare function DetailView({ entry, side, activeTab, isActive, width, height, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,151 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import cliTruncate from "cli-truncate";
3
+ import { Box, Text } from "ink";
4
+ import { useDetailScroll } from "../hooks/useDetailScroll.js";
5
+ import { PRIMARY_COLOR } from "../theme.js";
6
+ import { getHighlightLanguage, hasBinaryBytes, isBinaryContentType, parseContentType, } from "../utils/contentType.js";
7
+ import { decompressBody } from "../utils/decompress.js";
8
+ import { formatBytes } from "../utils/formatBytes.js";
9
+ import { highlightBody } from "../utils/highlightBody.js";
10
+ import { BorderedBox } from "./BorderedBox.js";
11
+ const TABS = ["body", "headers", "raw"];
12
+ const TAB_LABELS = {
13
+ headers: "Headers",
14
+ body: "Body",
15
+ raw: "Raw",
16
+ };
17
+ const CHROME_LINES = 1; // tab bar only (title is now in border)
18
+ function formatHeaders(headers) {
19
+ return Object.entries(headers).flatMap(([key, value]) => {
20
+ if (Array.isArray(value)) {
21
+ return value.map((v) => `${key}: ${v}`);
22
+ }
23
+ return [`${key}: ${value ?? ""}`];
24
+ });
25
+ }
26
+ function getRequestBodyLines(entry) {
27
+ if (entry.request.body.length === 0) {
28
+ return ["Empty body"];
29
+ }
30
+ const body = decompressBody(entry.request.body, entry.request.headers);
31
+ const mime = parseContentType(entry.request.headers);
32
+ if (isBinary(mime, body)) {
33
+ const label = mime || "unknown";
34
+ const size = formatBytes(entry.request.body.length);
35
+ return [`[Binary content: ${label}, ${size}]`];
36
+ }
37
+ const text = body.toString("utf-8");
38
+ const language = getHighlightLanguage(mime);
39
+ return highlightBody(text, language).split("\n");
40
+ }
41
+ function getDecompressedBody(entry) {
42
+ if (!entry.response)
43
+ return Buffer.alloc(0);
44
+ return decompressBody(entry.response.body, entry.response.headers);
45
+ }
46
+ function isBinary(mime, body) {
47
+ if (isBinaryContentType(mime))
48
+ return true;
49
+ return hasBinaryBytes(body);
50
+ }
51
+ function getResponseBodyLines(entry) {
52
+ if (entry.state === "pending" || !entry.response) {
53
+ return ["Waiting for response..."];
54
+ }
55
+ if (entry.response.body.length === 0) {
56
+ return ["Empty body"];
57
+ }
58
+ const body = getDecompressedBody(entry);
59
+ const mime = parseContentType(entry.response.headers);
60
+ if (isBinary(mime, body)) {
61
+ const label = mime || "unknown";
62
+ const size = formatBytes(entry.response.body.length);
63
+ return [`[Binary content: ${label}, ${size}]`];
64
+ }
65
+ const text = body.toString("utf-8");
66
+ const language = getHighlightLanguage(mime);
67
+ return highlightBody(text, language).split("\n");
68
+ }
69
+ function formatRawRequest(entry) {
70
+ const { method, path, headers, body } = entry.request;
71
+ const lines = [`${method} ${path} HTTP/1.1`];
72
+ lines.push(...formatHeaders(headers));
73
+ if (body.length > 0) {
74
+ lines.push("");
75
+ const decompressed = decompressBody(body, headers);
76
+ const mime = parseContentType(headers);
77
+ if (isBinary(mime, decompressed)) {
78
+ const label = mime || "unknown";
79
+ const size = formatBytes(body.length);
80
+ lines.push(`[Binary content: ${label}, ${size}]`);
81
+ }
82
+ else {
83
+ lines.push(...decompressed.toString("utf-8").split("\n"));
84
+ }
85
+ }
86
+ return lines;
87
+ }
88
+ function formatRawResponse(entry) {
89
+ if (entry.state === "pending" || !entry.response) {
90
+ return ["Waiting for response..."];
91
+ }
92
+ const { statusCode, headers, body } = entry.response;
93
+ const lines = [`HTTP/1.1 ${statusCode}`];
94
+ lines.push(...formatHeaders(headers));
95
+ if (body.length > 0) {
96
+ lines.push("");
97
+ const decompressed = getDecompressedBody(entry);
98
+ const mime = parseContentType(headers);
99
+ if (isBinary(mime, decompressed)) {
100
+ const label = mime || "unknown";
101
+ const size = formatBytes(body.length);
102
+ lines.push(`[Binary content: ${label}, ${size}]`);
103
+ }
104
+ else {
105
+ lines.push(...decompressed.toString("utf-8").split("\n"));
106
+ }
107
+ }
108
+ return lines;
109
+ }
110
+ function getContentLines(entry, side, tab) {
111
+ if (tab === "headers") {
112
+ const headers = side === "request" ? entry.request.headers : entry.response?.headers;
113
+ if (!headers)
114
+ return ["Waiting for response..."];
115
+ return formatHeaders(headers);
116
+ }
117
+ if (tab === "body") {
118
+ return side === "request"
119
+ ? getRequestBodyLines(entry)
120
+ : getResponseBodyLines(entry);
121
+ }
122
+ // raw
123
+ return side === "request"
124
+ ? formatRawRequest(entry)
125
+ : formatRawResponse(entry);
126
+ }
127
+ function truncateLine(line, width) {
128
+ return cliTruncate(line, width);
129
+ }
130
+ export function DetailView({ entry, side, activeTab, isActive, width, height, }) {
131
+ const innerHeight = Math.max(0, height - 2); // border top + bottom
132
+ const contentLines = getContentLines(entry, side, activeTab);
133
+ const viewportHeight = Math.max(1, innerHeight - CHROME_LINES);
134
+ const { scrollOffset } = useDetailScroll({
135
+ contentHeight: contentLines.length,
136
+ viewportHeight,
137
+ isActive,
138
+ });
139
+ const visibleLines = contentLines.slice(scrollOffset, scrollOffset + viewportHeight);
140
+ const title = side === "request" ? "Request" : "Response";
141
+ const innerWidth = Math.max(0, width - 2);
142
+ return (_jsxs(BorderedBox, { title: title, width: width, height: height, isActive: isActive, children: [_jsx(Text, { children: TABS.map((tab, i) => {
143
+ const label = TAB_LABELS[tab];
144
+ const sep = i < TABS.length - 1 ? " │ " : "";
145
+ const isCurrent = tab === activeTab;
146
+ if (isCurrent && isActive) {
147
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, color: PRIMARY_COLOR, children: ` ${label} ` }), sep] }, tab));
148
+ }
149
+ return (_jsxs(Text, { dimColor: !isActive, children: [` ${label} `, sep] }, tab));
150
+ }) }), side === "response" && entry.state === "pending" ? (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Text, { dimColor: !isActive, children: " Waiting for response\u2026" }) })) : (visibleLines.map((line, i) => (_jsx(Text, { dimColor: !isActive, children: ` ${truncateLine(line, innerWidth - 1)}` }, `${scrollOffset + i}`))))] }));
151
+ }
@@ -0,0 +1,12 @@
1
+ import type { SidebarItem } from "../hooks/useDomainFilter.js";
2
+ type Props = {
3
+ items: SidebarItem[];
4
+ selectedIndex: number;
5
+ scrollOffset: number;
6
+ viewportHeight: number;
7
+ width: number;
8
+ height: number;
9
+ isActive: boolean;
10
+ };
11
+ export declare function DomainSidebar({ items, selectedIndex, scrollOffset, viewportHeight, width, height, isActive, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { PRIMARY_COLOR } from "../theme.js";
4
+ import { BorderedBox } from "./BorderedBox.js";
5
+ function formatItem(item, maxWidth) {
6
+ if (item.type === "all") {
7
+ return ` All (${item.count})`.slice(0, maxWidth).padEnd(maxWidth);
8
+ }
9
+ if (item.type === "group") {
10
+ const arrow = item.expanded ? "▼" : "▶";
11
+ const label = `${arrow} ${item.baseDomain} (${item.count})`;
12
+ return label.slice(0, maxWidth).padEnd(maxWidth);
13
+ }
14
+ const prefix = item.grouped ? " " : " ";
15
+ const label = `${prefix}${item.host} (${item.count})`;
16
+ return label.slice(0, maxWidth).padEnd(maxWidth);
17
+ }
18
+ function itemKey(item) {
19
+ if (item.type === "all")
20
+ return "all";
21
+ if (item.type === "group")
22
+ return `g:${item.baseDomain}`;
23
+ return `d:${item.host}`;
24
+ }
25
+ export function DomainSidebar({ items, selectedIndex, scrollOffset, viewportHeight, width, height, isActive, }) {
26
+ const contentWidth = width - 2;
27
+ const visible = items.slice(scrollOffset, scrollOffset + viewportHeight);
28
+ return (_jsxs(BorderedBox, { title: "Domains", width: width, height: height, isActive: isActive, children: [visible.map((item, i) => {
29
+ const idx = scrollOffset + i;
30
+ const isSelected = idx === selectedIndex;
31
+ const text = formatItem(item, contentWidth);
32
+ return (_jsx(Text, { backgroundColor: isSelected ? PRIMARY_COLOR : undefined, color: isSelected ? "black" : undefined, dimColor: !isActive && !isSelected, children: text }, itemKey(item)));
33
+ }), visible.length < viewportHeight && (_jsx(Text, { children: `${" ".repeat(contentWidth)}\n`
34
+ .repeat(viewportHeight - visible.length)
35
+ .trimEnd() }))] }));
36
+ }
@@ -0,0 +1,22 @@
1
+ import type { SortConfig } from "../hooks/useSorting.js";
2
+ import type { TrafficEntry } from "../store/index.js";
3
+ type Props = {
4
+ entries: TrafficEntry[];
5
+ selectedIndex: number;
6
+ scrollOffset: number;
7
+ viewportHeight: number;
8
+ sortConfig: SortConfig | null;
9
+ columnWidths: {
10
+ id: number;
11
+ method: number;
12
+ status: number;
13
+ duration: number;
14
+ size: number;
15
+ url: number;
16
+ };
17
+ width: number;
18
+ height: number;
19
+ isActive: boolean;
20
+ };
21
+ export declare function RequestList({ entries, selectedIndex, scrollOffset, viewportHeight, sortConfig, columnWidths, width, height, isActive, }: Props): import("react/jsx-runtime").JSX.Element;
22
+ export {};
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { BorderedBox } from "./BorderedBox.js";
4
+ import { RequestRow } from "./RequestRow.js";
5
+ const COLUMN_LABELS = {
6
+ id: "#",
7
+ method: "Method",
8
+ url: "URL",
9
+ status: "Status",
10
+ duration: "Time",
11
+ size: "Size",
12
+ };
13
+ function headerLabel(label, column, sortConfig, width) {
14
+ const effective = sortConfig ?? {
15
+ column: "id",
16
+ direction: "desc",
17
+ };
18
+ if (effective.column === column) {
19
+ const arrow = effective.direction === "asc" ? "▲" : "▼";
20
+ return `${label}${arrow}`.slice(0, width).padEnd(width);
21
+ }
22
+ return label.padEnd(width);
23
+ }
24
+ export function RequestList({ entries, selectedIndex, scrollOffset, viewportHeight, sortConfig, columnWidths, width, height, isActive, }) {
25
+ if (entries.length === 0) {
26
+ return (_jsx(BorderedBox, { title: "Requests", width: width, height: height, isActive: isActive, children: _jsx(Box, { flexDirection: "column", height: Math.max(0, height - 2), justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No requests captured" }) }) }));
27
+ }
28
+ const visible = entries.slice(scrollOffset, scrollOffset + viewportHeight);
29
+ return (_jsxs(BorderedBox, { title: "Requests", width: width, height: height, isActive: isActive, children: [_jsxs(Text, { bold: true, dimColor: !isActive, children: [_jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.id, "id", sortConfig, columnWidths.id) }), _jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.method, "method", sortConfig, columnWidths.method) }), _jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.url, "url", sortConfig, columnWidths.url) }), _jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.status, "status", sortConfig, columnWidths.status) }), _jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.duration, "duration", sortConfig, columnWidths.duration) }), _jsx(Text, { children: " " }), _jsx(Text, { children: headerLabel(COLUMN_LABELS.size, "size", sortConfig, columnWidths.size) })] }), visible.map((entry, i) => (_jsx(RequestRow, { entry: entry, isSelected: scrollOffset + i === selectedIndex, columnWidths: columnWidths }, entry.id)))] }));
30
+ }
@@ -0,0 +1,15 @@
1
+ import type { TrafficEntry } from "../store/index.js";
2
+ type Props = {
3
+ entry: TrafficEntry;
4
+ isSelected: boolean;
5
+ columnWidths: {
6
+ id: number;
7
+ method: number;
8
+ status: number;
9
+ duration: number;
10
+ size: number;
11
+ url: number;
12
+ };
13
+ };
14
+ export declare function RequestRow({ entry, isSelected, columnWidths }: Props): import("react/jsx-runtime").JSX.Element;
15
+ export {};