@reopt-ai/dev-proxy 1.1.1

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.
@@ -0,0 +1,292 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { execSync } from "node:child_process";
3
+ import { writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { Box, Text, render } from "ink";
6
+ import { readGlobalConfig, readProjectConfig, writeProjectConfig, isValidPort, isValidSubdomain, allocatePorts, getEntryPorts, generateEnvContent, } from "../cli/config-io.js";
7
+ import { Header, Row, SuccessMessage, ErrorMessage, ExitOnRender, } from "../cli/output.js";
8
+ function findOwningProject(cwd) {
9
+ const cfg = readGlobalConfig();
10
+ const projects = cfg.projects ?? [];
11
+ for (const p of projects) {
12
+ if (cwd === p || cwd.startsWith(p + "/")) {
13
+ return p;
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+ // ── List worktrees ───────────────────────────────────────────
19
+ function WorktreeList() {
20
+ const cfg = readGlobalConfig();
21
+ const projects = cfg.projects ?? [];
22
+ const entries = [];
23
+ for (const p of projects) {
24
+ const pc = readProjectConfig(p);
25
+ for (const [name, entry] of Object.entries(pc.worktrees ?? {})) {
26
+ entries.push({ project: p, name, entry });
27
+ }
28
+ }
29
+ if (entries.length === 0) {
30
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(Header, { text: "Worktrees" }), _jsx(Text, { dimColor: true, children: " (none)" })] }));
31
+ }
32
+ function formatPorts(entry) {
33
+ if ("ports" in entry) {
34
+ return Object.entries(entry.ports)
35
+ .map(([svc, p]) => `${svc}:${p}`)
36
+ .join(", ");
37
+ }
38
+ return `port ${entry.port}`;
39
+ }
40
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(Header, { text: "Worktrees" }), entries.map((e) => (_jsx(Row, { label: e.name, value: `${formatPorts(e.entry)} (${e.project})`, pad: 20 }, `${e.project}:${e.name}`)))] }));
41
+ }
42
+ // ── Add worktree ─────────────────────────────────────────────
43
+ function WorktreeAdd({ name, port }) {
44
+ const cwd = process.cwd();
45
+ const projectPath = findOwningProject(cwd);
46
+ if (!projectPath) {
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: "Current directory is not inside a registered project", hint: "Run 'dev-proxy project add .' to register this project first" })] }));
48
+ }
49
+ const cfg = readProjectConfig(projectPath);
50
+ cfg.worktrees = cfg.worktrees ?? {};
51
+ cfg.worktrees[name] = { port };
52
+ writeProjectConfig(projectPath, cfg);
53
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(SuccessMessage, { message: `Added worktree "${name}" on port ${port}` })] }));
54
+ }
55
+ // ── Remove worktree ──────────────────────────────────────────
56
+ function WorktreeRemove({ name }) {
57
+ const cwd = process.cwd();
58
+ const projectPath = findOwningProject(cwd);
59
+ if (!projectPath) {
60
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: "Current directory is not inside a registered project", hint: "Run 'dev-proxy project add .' to register this project first" })] }));
61
+ }
62
+ const cfg = readProjectConfig(projectPath);
63
+ const worktrees = cfg.worktrees ?? {};
64
+ if (!(name in worktrees)) {
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: `Worktree "${name}" not found in project`, hint: "Run 'dev-proxy worktree list' to see all worktrees" })] }));
66
+ }
67
+ const { [name]: _, ...remaining } = worktrees;
68
+ cfg.worktrees = remaining;
69
+ writeProjectConfig(projectPath, cfg);
70
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(SuccessMessage, { message: `Removed worktree "${name}"` })] }));
71
+ }
72
+ // ── Create worktree (full lifecycle) ─────────────────────────
73
+ function WorktreeCreate({ branch }) {
74
+ const cwd = process.cwd();
75
+ const projectPath = findOwningProject(cwd);
76
+ if (!projectPath) {
77
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: "Current directory is not inside a registered project", hint: "Run 'dev-proxy project add .' to register this project first" })] }));
78
+ }
79
+ const cfg = readProjectConfig(projectPath);
80
+ const wtConfig = cfg.worktreeConfig;
81
+ if (!wtConfig) {
82
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: "worktreeConfig not configured in .dev-proxy.json", hint: 'Add "worktreeConfig": { "portRange": [4001, 5000], "directory": "../project-{branch}" }' })] }));
83
+ }
84
+ const worktrees = cfg.worktrees ?? {};
85
+ if (branch in worktrees) {
86
+ const existing = worktrees[branch];
87
+ const portInfo = "ports" in existing
88
+ ? Object.entries(existing.ports)
89
+ .map(([s, p]) => `${s}:${p}`)
90
+ .join(", ")
91
+ : `port ${existing.port}`;
92
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: `Worktree "${branch}" already exists (${portInfo})` })] }));
93
+ }
94
+ // Collect used ports across all existing worktrees
95
+ const usedPorts = new Set(Object.values(worktrees).flatMap((w) => getEntryPorts(w)));
96
+ // Allocate ports — multi-service or single
97
+ const services = wtConfig.services;
98
+ const serviceNames = services ? Object.keys(services) : null;
99
+ const portCount = serviceNames ? serviceNames.length : 1;
100
+ const allocated = allocatePorts(portCount, wtConfig.portRange, usedPorts);
101
+ if (allocated === null) {
102
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: `No available ports in range ${wtConfig.portRange[0]}-${wtConfig.portRange[1]}`, hint: "Destroy unused worktrees or expand portRange" })] }));
103
+ }
104
+ // Build the worktree entry
105
+ let worktreeEntry;
106
+ let portsMap = null;
107
+ if (serviceNames) {
108
+ portsMap = {};
109
+ for (let i = 0; i < serviceNames.length; i++) {
110
+ portsMap[serviceNames[i]] = allocated[i];
111
+ }
112
+ worktreeEntry = { ports: portsMap };
113
+ }
114
+ else {
115
+ worktreeEntry = { port: allocated[0] };
116
+ }
117
+ // Resolve directory
118
+ const dirPattern = wtConfig.directory.replace("{branch}", branch);
119
+ const worktreeDir = resolve(projectPath, dirPattern);
120
+ const messages = [];
121
+ const warnings = [];
122
+ // git worktree add
123
+ try {
124
+ execSync(`git worktree add ${JSON.stringify(worktreeDir)} ${branch}`, {
125
+ cwd: projectPath,
126
+ stdio: "pipe",
127
+ });
128
+ messages.push(`Created git worktree at ${worktreeDir}`);
129
+ }
130
+ catch (err) {
131
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: `git worktree add failed: ${err.message}`, hint: "Ensure the branch exists or will be created" })] }));
132
+ }
133
+ // Update config
134
+ cfg.worktrees = { ...worktrees, [branch]: worktreeEntry };
135
+ try {
136
+ writeProjectConfig(projectPath, cfg);
137
+ }
138
+ catch (err) {
139
+ warnings.push(`Failed to update config: ${err.message}`);
140
+ }
141
+ if (portsMap) {
142
+ for (const [svc, p] of Object.entries(portsMap)) {
143
+ messages.push(`Allocated port ${p} for ${svc}`);
144
+ }
145
+ }
146
+ else {
147
+ messages.push(`Allocated port ${allocated[0]}`);
148
+ }
149
+ // Generate .env file for multi-service worktrees
150
+ if (services && portsMap) {
151
+ const envContent = generateEnvContent(services, portsMap);
152
+ const envFile = wtConfig.envFile ?? ".env.local";
153
+ try {
154
+ writeFileSync(resolve(worktreeDir, envFile), envContent);
155
+ messages.push(`Wrote ${envFile}`);
156
+ }
157
+ catch (err) {
158
+ warnings.push(`Failed to write ${envFile}: ${err.message}`);
159
+ }
160
+ }
161
+ // Run post-create hook
162
+ const hook = wtConfig.hooks?.["post-create"];
163
+ if (hook) {
164
+ try {
165
+ execSync(hook, { cwd: worktreeDir, stdio: "inherit" });
166
+ messages.push(`Hook post-create completed`);
167
+ }
168
+ catch {
169
+ warnings.push(`Hook post-create failed (worktree was still created)`);
170
+ }
171
+ }
172
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), messages.map((m, i) => (_jsx(SuccessMessage, { message: m }, i))), warnings.map((w, i) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { children: ` ${w}` })] }, i))), _jsx(Text, { children: "" }), _jsx(Text, { dimColor: true, children: ` Access: {branch}--*.${readGlobalConfig().domain ?? "localhost"}:${readGlobalConfig().port ?? 3000}` })] }));
173
+ }
174
+ // ── Destroy worktree (full lifecycle) ────────────────────────
175
+ function WorktreeDestroy({ branch }) {
176
+ const cwd = process.cwd();
177
+ const projectPath = findOwningProject(cwd);
178
+ if (!projectPath) {
179
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: "Current directory is not inside a registered project", hint: "Run 'dev-proxy project add .' to register this project first" })] }));
180
+ }
181
+ const cfg = readProjectConfig(projectPath);
182
+ const worktrees = cfg.worktrees ?? {};
183
+ if (!(branch in worktrees)) {
184
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(ErrorMessage, { message: `Worktree "${branch}" not found`, hint: "Run 'dev-proxy worktree list' to see all worktrees" })] }));
185
+ }
186
+ const wtConfig = cfg.worktreeConfig;
187
+ const messages = [];
188
+ const warnings = [];
189
+ // Resolve directory
190
+ const dirPattern = wtConfig
191
+ ? wtConfig.directory.replace("{branch}", branch)
192
+ : `../${branch}`;
193
+ const worktreeDir = resolve(projectPath, dirPattern);
194
+ // Run post-remove hook
195
+ const hook = wtConfig?.hooks?.["post-remove"];
196
+ if (hook) {
197
+ try {
198
+ execSync(hook, { cwd: worktreeDir, stdio: "inherit" });
199
+ messages.push(`Hook post-remove completed`);
200
+ }
201
+ catch {
202
+ warnings.push(`Hook post-remove failed (continuing with removal)`);
203
+ }
204
+ }
205
+ // git worktree remove
206
+ try {
207
+ execSync(`git worktree remove ${JSON.stringify(worktreeDir)} --force`, {
208
+ cwd: projectPath,
209
+ stdio: "pipe",
210
+ });
211
+ messages.push(`Removed git worktree at ${worktreeDir}`);
212
+ }
213
+ catch {
214
+ warnings.push(`git worktree remove failed (config entry still removed)`);
215
+ }
216
+ // Update config
217
+ const removed = worktrees[branch];
218
+ const { [branch]: _, ...remaining } = worktrees;
219
+ cfg.worktrees = remaining;
220
+ try {
221
+ writeProjectConfig(projectPath, cfg);
222
+ }
223
+ catch (err) {
224
+ warnings.push(`Failed to update config: ${err.message}`);
225
+ }
226
+ const releasedPorts = getEntryPorts(removed);
227
+ if ("ports" in removed) {
228
+ for (const [svc, p] of Object.entries(removed.ports)) {
229
+ messages.push(`Released port ${p} (${svc})`);
230
+ }
231
+ }
232
+ else {
233
+ messages.push(`Released port ${releasedPorts[0]}`);
234
+ }
235
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), messages.map((m, i) => (_jsx(SuccessMessage, { message: m }, i))), warnings.map((w, i) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { children: ` ${w}` })] }, i)))] }));
236
+ }
237
+ // ── Entry point ──────────────────────────────────────────────
238
+ const args = process.argv.slice(3);
239
+ const subcommand = args[0];
240
+ if (subcommand === "create") {
241
+ const branch = args[1];
242
+ if (!branch) {
243
+ render(_jsx(ErrorMessage, { message: "Usage: dev-proxy worktree create <branch>" }));
244
+ }
245
+ else if (!isValidSubdomain(branch)) {
246
+ render(_jsx(ErrorMessage, { message: `Invalid branch name "${branch}" for subdomain routing`, hint: "Use lowercase alphanumeric and hyphens only (e.g. fix-auth-bug)" }));
247
+ }
248
+ else {
249
+ render(_jsx(WorktreeCreate, { branch: branch }));
250
+ }
251
+ }
252
+ else if (subcommand === "destroy") {
253
+ const branch = args[1];
254
+ if (!branch) {
255
+ render(_jsx(ErrorMessage, { message: "Usage: dev-proxy worktree destroy <branch>" }));
256
+ }
257
+ else {
258
+ render(_jsx(WorktreeDestroy, { branch: branch }));
259
+ }
260
+ }
261
+ else if (subcommand === "add") {
262
+ const name = args[1];
263
+ const portStr = args[2];
264
+ if (!name || !portStr) {
265
+ render(_jsx(ErrorMessage, { message: "Usage: dev-proxy worktree add <name> <port>" }));
266
+ }
267
+ else {
268
+ const port = Number(portStr);
269
+ if (!isValidSubdomain(name)) {
270
+ render(_jsx(ErrorMessage, { message: `Invalid worktree name "${name}"`, hint: "Use lowercase alphanumeric and hyphens only" }));
271
+ }
272
+ else if (!isValidPort(port)) {
273
+ render(_jsx(ErrorMessage, { message: `Invalid port "${portStr}"`, hint: "Expected an integer between 1 and 65535" }));
274
+ }
275
+ else {
276
+ render(_jsx(WorktreeAdd, { name: name, port: port }));
277
+ }
278
+ }
279
+ }
280
+ else if (subcommand === "remove") {
281
+ const name = args[1];
282
+ if (!name) {
283
+ render(_jsx(ErrorMessage, { message: "Usage: dev-proxy worktree remove <name>" }));
284
+ }
285
+ else {
286
+ render(_jsx(WorktreeRemove, { name: name }));
287
+ }
288
+ }
289
+ else {
290
+ // "list" or no subcommand → default to list
291
+ render(_jsx(WorktreeList, {}));
292
+ }
@@ -0,0 +1,394 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import { spawnSync } from "node:child_process";
6
+ import { Box, Text, useInput, useStdout } from "ink";
7
+ import { Splash } from "./splash.js";
8
+ import { StatusBar } from "./status-bar.js";
9
+ import { RequestList } from "./request-list.js";
10
+ import { DetailPanel } from "./detail-panel.js";
11
+ import { FooterBar } from "./footer-bar.js";
12
+ import { HTTPS_PORT, PROXY_PORT } from "../proxy/routes.js";
13
+ import { palette } from "../utils/format.js";
14
+ import { useMouse } from "../hooks/use-mouse.js";
15
+ import { buildListHeaderTokens, getListDimensions, LIST_PADDING_X, LIST_RESERVED_LINES, } from "../utils/list-layout.js";
16
+ import { useStore, setInspectActive, selectNext, selectPrev, selectFirst, selectLast, selectByFilteredIndex, pauseFollow, toggleFollow, toggleHideNoise, toggleErrorsOnly, setSearchQuery, getSearchQuery, clearAll, getSelectedReplayInfo, getSelectedCurl, } from "../store.js";
17
+ const MOUSE_PREFIX = "\x1b[<";
18
+ const INSPECT_IDLE_MS = 60_000;
19
+ const CLIPBOARD_COMMANDS = process.platform === "darwin"
20
+ ? [["pbcopy"]]
21
+ : process.platform === "win32"
22
+ ? [["clip"]]
23
+ : [
24
+ ["wl-copy"],
25
+ ["xclip", "-selection", "clipboard"],
26
+ ["xsel", "--clipboard", "--input"],
27
+ ];
28
+ function tokensLength(tokens) {
29
+ if (tokens.length === 0)
30
+ return 0;
31
+ const total = tokens.reduce((sum, token) => sum + token.text.length, 0);
32
+ return total + (tokens.length - 1);
33
+ }
34
+ function hitToken(tokens, col) {
35
+ let cursor = 1;
36
+ for (let i = 0; i < tokens.length; i++) {
37
+ const token = tokens[i];
38
+ const start = cursor;
39
+ const end = start + token.text.length - 1;
40
+ if (col >= start && col <= end)
41
+ return token;
42
+ cursor = end + 1;
43
+ if (i < tokens.length - 1)
44
+ cursor += 1;
45
+ }
46
+ return null;
47
+ }
48
+ function getScrollOffset(total, selectedIdx, visibleRows) {
49
+ if (total <= visibleRows)
50
+ return 0;
51
+ const safeSelected = Math.max(0, selectedIdx);
52
+ return Math.max(0, Math.min(safeSelected - Math.floor(visibleRows / 2), total - visibleRows));
53
+ }
54
+ function resolveReplayTarget(info) {
55
+ const defaultPort = info.protocol === "https" ? HTTPS_PORT : PROXY_PORT;
56
+ try {
57
+ const origin = new URL(`${info.protocol}://${info.host}`);
58
+ return {
59
+ hostname: origin.hostname,
60
+ port: origin.port ? Number(origin.port) : defaultPort,
61
+ };
62
+ }
63
+ catch {
64
+ // Malformed URL — fall back to manual host parsing
65
+ return {
66
+ hostname: info.host.split(":")[0] ?? "localhost",
67
+ port: defaultPort,
68
+ };
69
+ }
70
+ }
71
+ function copyToClipboard(text) {
72
+ for (const [command, ...args] of CLIPBOARD_COMMANDS) {
73
+ const result = spawnSync(command, args, {
74
+ input: text,
75
+ stdio: ["pipe", "ignore", "ignore"],
76
+ });
77
+ if (!result.error && result.status === 0) {
78
+ return true;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ function StandbyView({ termSize, httpsEnabled, }) {
84
+ const line = "─".repeat(Math.max(24, Math.min(termSize.cols - 18, 52)));
85
+ return (_jsx(Box, { alignItems: "center", justifyContent: "center", flexGrow: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: palette.muted, paddingX: 4, paddingY: 1, children: [_jsxs(Box, { justifyContent: "center", gap: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "DEV-PROXY" }), _jsx(Text, { color: palette.subtle, children: "\u00B7" }), _jsx(Text, { color: palette.success, bold: true, children: "ACTIVE" })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.muted, children: "Inspect UI sleeping to reduce memory pressure" }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsxs(Box, { justifyContent: "center", marginTop: 1, children: [_jsxs(Text, { color: palette.accent, bold: true, children: ["LISTENING :", PROXY_PORT] }), httpsEnabled && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.subtle, children: [" ", "\u00B7", " "] }), _jsxs(Text, { color: palette.success, bold: true, children: ["TLS :", HTTPS_PORT] })] }))] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.dim, children: "Proxying requests in background. Live inspect is paused." }) }), _jsxs(Box, { justifyContent: "center", marginTop: 1, children: [_jsx(Text, { color: palette.muted, children: "Press " }), _jsx(Text, { color: palette.accent, bold: true, children: "I" }), _jsx(Text, { color: palette.muted, children: " or " }), _jsx(Text, { color: palette.accent, bold: true, children: "Enter" }), _jsx(Text, { color: palette.muted, children: " to resume inspect" })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: "Auto-sleeps after 60s without inspect interaction" }) })] }) }));
86
+ }
87
+ function InspectView({ httpsEnabled, termSize, searchMode, searchInput, detailScroll, onDetailScrollChange, onSelectionChange, focus, showDetail, noteActivity, }) {
88
+ const store = useStore();
89
+ const chromeRows = 1 + (searchMode ? 1 : 0) + 1;
90
+ const contentSize = {
91
+ rows: Math.max(1, termSize.rows - chromeRows),
92
+ cols: termSize.cols,
93
+ };
94
+ const listDims = getListDimensions(contentSize, showDetail);
95
+ const headerTokens = buildListHeaderTokens({
96
+ available: listDims.available,
97
+ count: store.events.length,
98
+ followMode: store.followMode,
99
+ errorsOnly: store.errorsOnly,
100
+ hideNextStatic: store.hideNextStatic,
101
+ searchQuery: store.searchQuery,
102
+ });
103
+ const scrollOffset = getScrollOffset(store.events.length, store.selectedIndex, listDims.visibleRows);
104
+ useMouse((event) => {
105
+ noteActivity();
106
+ const contentTop = 1 + (searchMode ? 1 : 0) + 1;
107
+ const contentBottom = contentTop + contentSize.rows - 1;
108
+ if (event.y < contentTop || event.y > contentBottom)
109
+ return;
110
+ const listWidth = showDetail ? Math.floor(termSize.cols / 2) : termSize.cols;
111
+ const listLeft = 1;
112
+ const listRight = listLeft + listWidth - 1;
113
+ const inList = event.x >= listLeft && event.x <= listRight;
114
+ const inDetail = event.x > listRight && event.x <= termSize.cols;
115
+ if (event.kind === "scroll") {
116
+ if (inList) {
117
+ if (event.direction === "up")
118
+ selectPrev();
119
+ else
120
+ selectNext();
121
+ }
122
+ else if (inDetail) {
123
+ pauseFollow();
124
+ onDetailScrollChange((s) => Math.max(0, s + (event.direction === "up" ? -1 : 1)));
125
+ }
126
+ return;
127
+ }
128
+ // After the scroll-return above, the only remaining kind is "down" + "left"
129
+ if (inList) {
130
+ const listInnerLeft = listLeft + 1 + LIST_PADDING_X;
131
+ const colInInner = event.x - listInnerLeft + 1;
132
+ if (colInInner < 1 || colInInner > listDims.innerWidth)
133
+ return;
134
+ const listHeaderRow = contentTop + 1;
135
+ if (event.y === listHeaderRow) {
136
+ const leftLen = tokensLength(headerTokens.left);
137
+ const rightLen = tokensLength(headerTokens.right);
138
+ if (colInInner <= leftLen) {
139
+ const token = hitToken(headerTokens.left, colInInner);
140
+ if (token?.kind === "follow")
141
+ toggleFollow();
142
+ }
143
+ else if (rightLen > 0) {
144
+ const rightStart = Math.max(1, listDims.innerWidth - rightLen + 1);
145
+ if (colInInner >= rightStart) {
146
+ const token = hitToken(headerTokens.right, colInInner - rightStart + 1);
147
+ if (token?.kind === "err")
148
+ toggleErrorsOnly();
149
+ if (token?.kind === "quiet")
150
+ toggleHideNoise();
151
+ }
152
+ }
153
+ return;
154
+ }
155
+ const listDataStart = contentTop + 1 + LIST_RESERVED_LINES;
156
+ const rowIndex = event.y - listDataStart;
157
+ if (rowIndex >= 0 && rowIndex < listDims.visibleRows) {
158
+ selectByFilteredIndex(scrollOffset + rowIndex);
159
+ }
160
+ }
161
+ else if (inDetail) {
162
+ pauseFollow();
163
+ }
164
+ });
165
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(StatusBar, { termSize: termSize, httpsEnabled: httpsEnabled }), searchMode && (_jsxs(Box, { children: [_jsx(Text, { color: palette.brand, bold: true, children: "FILTER" }), _jsxs(Text, { color: palette.subtle, children: [" ", "\u276F", " "] }), _jsx(Text, { color: palette.accent, bold: true, children: "/" }), _jsxs(Text, { color: palette.text, children: [" ", searchInput] }), _jsx(Text, { color: palette.muted, children: "\u2588" })] })), _jsxs(Box, { flexGrow: 1, children: [_jsx(RequestList, { halfWidth: showDetail, termSize: contentSize, active: focus === "list" }), showDetail && (_jsx(DetailPanel, { termSize: contentSize, scrollOffset: detailScroll, onScrollChange: onDetailScrollChange, onSelectionChange: onSelectionChange, active: focus === "detail" }))] }), _jsx(FooterBar, { termSize: termSize, focus: focus, showDetail: showDetail })] }));
166
+ }
167
+ export function App({ httpsEnabled = false }) {
168
+ const [showSplash, setShowSplash] = useState(true);
169
+ const [inspectMode, setInspectMode] = useState(false);
170
+ const [searchMode, setSearchMode] = useState(false);
171
+ const [searchInput, setSearchInput] = useState("");
172
+ const [detailScroll, setDetailScroll] = useState(0);
173
+ const [focus, setFocus] = useState("list");
174
+ const [showDetail, setShowDetail] = useState(true);
175
+ const idleTimerRef = useRef(null);
176
+ const { stdout } = useStdout();
177
+ const [termSize, setTermSize] = useState({
178
+ rows: stdout.rows,
179
+ cols: stdout.columns,
180
+ });
181
+ useEffect(() => {
182
+ const handle = () => {
183
+ setTermSize({ rows: stdout.rows, cols: stdout.columns });
184
+ };
185
+ stdout.on("resize", handle);
186
+ return () => {
187
+ stdout.off("resize", handle);
188
+ };
189
+ }, [stdout]);
190
+ const resetDetailScroll = useCallback(() => {
191
+ setDetailScroll(0);
192
+ }, []);
193
+ const clearInspectIdle = useCallback(() => {
194
+ if (idleTimerRef.current) {
195
+ clearTimeout(idleTimerRef.current);
196
+ idleTimerRef.current = null;
197
+ }
198
+ }, []);
199
+ const scheduleInspectIdle = useCallback(() => {
200
+ clearInspectIdle();
201
+ idleTimerRef.current = setTimeout(() => {
202
+ setInspectMode(false);
203
+ setSearchMode(false);
204
+ }, INSPECT_IDLE_MS);
205
+ }, [clearInspectIdle]);
206
+ const resumeInspect = useCallback(() => {
207
+ setInspectMode(true);
208
+ setShowDetail(true);
209
+ resetDetailScroll();
210
+ }, [resetDetailScroll]);
211
+ const noteInspectActivity = useCallback(() => {
212
+ if (!showSplash && inspectMode) {
213
+ scheduleInspectIdle();
214
+ }
215
+ }, [inspectMode, scheduleInspectIdle, showSplash]);
216
+ useEffect(() => {
217
+ setInspectActive(!showSplash && inspectMode);
218
+ }, [inspectMode, showSplash]);
219
+ useEffect(() => {
220
+ if (showSplash || !inspectMode) {
221
+ clearInspectIdle();
222
+ return;
223
+ }
224
+ scheduleInspectIdle();
225
+ return clearInspectIdle;
226
+ }, [clearInspectIdle, inspectMode, scheduleInspectIdle, showSplash]);
227
+ // ── Keyboard handler ──────────────────────────────────────
228
+ // State machine: Splash → (Enter) → Inspect ↔ (idle 60s / i) ↔ Standby
229
+ // Within Inspect: searchMode overlays on top; focus toggles list/detail
230
+ useInput((input, key) => {
231
+ if (input.includes(MOUSE_PREFIX))
232
+ return;
233
+ const lowerInput = input.toLowerCase();
234
+ // ── Splash gate ──
235
+ if (showSplash) {
236
+ if (key.return) {
237
+ setShowSplash(false);
238
+ setInspectMode(true);
239
+ }
240
+ return;
241
+ }
242
+ if (!inspectMode) {
243
+ if (lowerInput === "i" || key.return) {
244
+ resumeInspect();
245
+ }
246
+ return;
247
+ }
248
+ noteInspectActivity();
249
+ if (searchMode) {
250
+ if (key.escape) {
251
+ setSearchMode(false);
252
+ setSearchInput("");
253
+ setSearchQuery("");
254
+ }
255
+ else if (key.return) {
256
+ setSearchMode(false);
257
+ }
258
+ else if (key.backspace || key.delete) {
259
+ const next = searchInput.slice(0, -1);
260
+ setSearchInput(next);
261
+ setSearchQuery(next);
262
+ }
263
+ else if (input && !key.ctrl && !key.meta) {
264
+ const next = searchInput + input;
265
+ setSearchInput(next);
266
+ setSearchQuery(next);
267
+ }
268
+ return;
269
+ }
270
+ if (key.leftArrow) {
271
+ setFocus("list");
272
+ }
273
+ else if (key.rightArrow) {
274
+ if (!showDetail)
275
+ setShowDetail(true);
276
+ setFocus("detail");
277
+ pauseFollow();
278
+ }
279
+ else if (focus === "detail" && key.downArrow) {
280
+ setDetailScroll((s) => s + 1);
281
+ }
282
+ else if (focus === "detail" && key.upArrow) {
283
+ setDetailScroll((s) => Math.max(0, s - 1));
284
+ }
285
+ else if (focus === "list" && (input === "j" || key.downArrow)) {
286
+ selectNext();
287
+ }
288
+ else if (focus === "list" && (input === "k" || key.upArrow)) {
289
+ selectPrev();
290
+ }
291
+ else if (focus === "detail" && input === "j") {
292
+ // Allow j/k navigation from detail — switch to list and move
293
+ setFocus("list");
294
+ selectNext();
295
+ }
296
+ else if (focus === "detail" && input === "k") {
297
+ setFocus("list");
298
+ selectPrev();
299
+ }
300
+ else if (input === "g") {
301
+ selectFirst();
302
+ }
303
+ else if (input === "G") {
304
+ selectLast();
305
+ }
306
+ else if (input === "f") {
307
+ toggleFollow();
308
+ }
309
+ else if (input === "n") {
310
+ toggleHideNoise();
311
+ }
312
+ else if (input === "e") {
313
+ toggleErrorsOnly();
314
+ }
315
+ else if (input === "x") {
316
+ clearAll();
317
+ }
318
+ else if (input === "/") {
319
+ setSearchMode(true);
320
+ setSearchInput(getSearchQuery());
321
+ }
322
+ else if (key.return) {
323
+ if (!showDetail)
324
+ setShowDetail(true);
325
+ setFocus("detail");
326
+ pauseFollow();
327
+ resetDetailScroll();
328
+ }
329
+ else if (key.escape) {
330
+ if (getSearchQuery()) {
331
+ setSearchInput("");
332
+ setSearchQuery("");
333
+ }
334
+ else if (showDetail) {
335
+ setShowDetail(false);
336
+ setFocus("list");
337
+ }
338
+ }
339
+ else if (input === "r") {
340
+ const info = getSelectedReplayInfo();
341
+ if (info) {
342
+ const { hostname, port } = resolveReplayTarget(info);
343
+ // Build replay headers from original request
344
+ const replayHeaders = {};
345
+ for (const [key, value] of Object.entries(info.requestHeaders)) {
346
+ // Skip hop-by-hop and forwarding headers — they belong to the original hop
347
+ if (key === "host" ||
348
+ key === "connection" ||
349
+ key === "keep-alive" ||
350
+ key === "transfer-encoding" ||
351
+ key === "content-length" ||
352
+ key.startsWith("x-forwarded-"))
353
+ continue;
354
+ replayHeaders[key] = value;
355
+ }
356
+ replayHeaders.host = info.host;
357
+ const req = info.protocol === "https"
358
+ ? https.request({
359
+ hostname,
360
+ port,
361
+ path: info.url,
362
+ method: info.method,
363
+ headers: replayHeaders,
364
+ servername: hostname,
365
+ rejectUnauthorized: false,
366
+ })
367
+ : http.request({
368
+ hostname,
369
+ port,
370
+ path: info.url,
371
+ method: info.method,
372
+ headers: replayHeaders,
373
+ });
374
+ req.on("error", () => {
375
+ /* fire-and-forget: replay failure is non-critical */
376
+ });
377
+ req.end();
378
+ }
379
+ }
380
+ else if (input === "y") {
381
+ const curl = getSelectedCurl();
382
+ if (curl) {
383
+ copyToClipboard(curl);
384
+ }
385
+ }
386
+ });
387
+ if (showSplash) {
388
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(StatusBar, { termSize: termSize, httpsEnabled: httpsEnabled }), _jsx(Splash, { httpsEnabled: httpsEnabled })] }));
389
+ }
390
+ if (!inspectMode) {
391
+ return _jsx(StandbyView, { termSize: termSize, httpsEnabled: httpsEnabled });
392
+ }
393
+ return (_jsx(InspectView, { httpsEnabled: httpsEnabled, termSize: termSize, searchMode: searchMode, searchInput: searchInput, detailScroll: detailScroll, onDetailScrollChange: setDetailScroll, onSelectionChange: resetDetailScroll, focus: focus, showDetail: showDetail, noteActivity: noteInspectActivity }));
394
+ }