@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,122 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useEffect, useRef } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useSelected, useSelectedDetail } from "../store.js";
5
+ import { formatTime, formatDuration, formatSize, methodColor, statusColor, durationColor, truncate, palette, } from "../utils/format.js";
6
+ const BORDER_ROWS = 2;
7
+ const BORDER_COLS = 2;
8
+ const PADDING_X = 1;
9
+ // summary(3) + marginBottom(1) + separator(1)
10
+ const DETAIL_RESERVED = 5;
11
+ function buildDetailLines(detail, nameMax, valueMax) {
12
+ const lines = [];
13
+ const pushPlain = (text) => {
14
+ lines.push(_jsx(Box, { children: text }, `l-${lines.length}`));
15
+ };
16
+ const blank = () => {
17
+ pushPlain(_jsx(Text, { children: " " }));
18
+ };
19
+ if (!detail) {
20
+ pushPlain(_jsx(Text, { color: palette.muted, italic: true, children: "Detail expired (only last 50 requests retained)" }));
21
+ return lines;
22
+ }
23
+ const renderEntries = (entries, sep) => {
24
+ if (entries.length === 0) {
25
+ pushPlain(_jsxs(Text, { color: palette.muted, italic: true, children: [" ", "(empty)"] }));
26
+ return;
27
+ }
28
+ for (const [name, value] of entries) {
29
+ const display = Array.isArray(value) ? value.join(", ") : value;
30
+ pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.accent, children: [" ", truncate(name, nameMax)] }), _jsx(Text, { color: palette.subtle, children: sep }), _jsx(Text, { color: palette.dim, children: truncate(display, valueMax) })] }));
31
+ }
32
+ };
33
+ // Request Headers
34
+ const reqEntries = Object.entries(detail.requestHeaders);
35
+ pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.success, bold: true, children: ["\u25B2", " Request Headers"] }), _jsxs(Text, { color: palette.muted, children: [" (", reqEntries.length, ")"] })] }));
36
+ renderEntries(reqEntries, ": ");
37
+ blank();
38
+ // Response Headers
39
+ const resEntries = Object.entries(detail.responseHeaders);
40
+ const hasResponse = resEntries.length > 0;
41
+ pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.accent, bold: true, children: ["\u25BC", " Response Headers"] }), _jsxs(Text, { color: palette.muted, children: [" ", hasResponse ? `(${resEntries.length})` : "(pending)"] })] }));
42
+ if (hasResponse) {
43
+ renderEntries(resEntries, ": ");
44
+ }
45
+ else {
46
+ pushPlain(_jsxs(Text, { color: palette.muted, italic: true, children: [" ", "(awaiting response)"] }));
47
+ }
48
+ blank();
49
+ // Cookies
50
+ const cookieEntries = Object.entries(detail.cookies);
51
+ pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.warning, bold: true, children: ["\u25CF", " Cookies"] }), _jsxs(Text, { color: palette.muted, children: [" (", cookieEntries.length, ")"] })] }));
52
+ renderEntries(cookieEntries, " = ");
53
+ blank();
54
+ // Query
55
+ const queryEntries = Object.entries(detail.query);
56
+ pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.info, bold: true, children: ["\u25CF", " Query"] }), _jsxs(Text, { color: palette.muted, children: [" (", queryEntries.length, ")"] })] }));
57
+ renderEntries(queryEntries, " = ");
58
+ return lines;
59
+ }
60
+ function buildWsDetailLines() {
61
+ const lines = [];
62
+ lines.push(_jsx(Box, { children: _jsx(Text, { color: palette.muted, italic: true, children: "(WebSocket \u2014 no headers/body captured)" }) }, "ws-note"));
63
+ return lines;
64
+ }
65
+ function PanelEmpty({ active, height }) {
66
+ return (_jsxs(Box, { width: "50%", height: height, flexDirection: "column", borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECTOR" }), _jsxs(Text, { color: palette.muted, italic: true, children: ["\u25CB", " Select a request to inspect"] })] }));
67
+ }
68
+ function HttpSummary({ event, urlMax, targetMax, }) {
69
+ const sColor = event.error ? palette.error : statusColor(event.statusCode);
70
+ const status = event.error
71
+ ? `ERR ${event.error}`
72
+ : String(event.statusCode ?? "\u2022\u2022\u2022");
73
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECT" }), _jsx(Text, { color: palette.subtle, children: "\u2022" }), _jsx(Text, { color: methodColor(event.method), bold: true, children: event.method }), _jsx(Text, { color: palette.text, bold: true, children: truncate(event.url, urlMax) })] }), _jsxs(Box, { gap: 1, marginTop: 0, children: [_jsx(Text, { color: palette.dim, children: formatTime(event.timestamp) }), _jsx(Text, { color: sColor, bold: true, children: status }), _jsx(Text, { color: durationColor(event.duration), children: formatDuration(event.duration) }), event.responseSize !== undefined && (_jsx(Text, { color: palette.dim, children: formatSize(event.responseSize) }))] }), _jsxs(Box, { children: [_jsx(Text, { color: palette.muted, children: event.host }), _jsxs(Text, { color: palette.subtle, children: [" ", "\u2192", " "] }), _jsx(Text, { color: palette.dim, children: truncate(event.target, targetMax) })] })] }));
74
+ }
75
+ function WsSummary({ event, urlMax, targetMax, }) {
76
+ const wsStatusColor = event.wsStatus === "open"
77
+ ? palette.success
78
+ : event.wsStatus === "error"
79
+ ? palette.error
80
+ : palette.dim;
81
+ const wsStatusText = event.wsStatus.toUpperCase();
82
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECT" }), _jsx(Text, { color: palette.subtle, children: "\u2022" }), _jsx(Text, { color: palette.ws, bold: true, children: "WS" }), _jsx(Text, { color: palette.text, bold: true, children: truncate(event.url, urlMax) })] }), _jsxs(Box, { gap: 1, marginTop: 0, children: [_jsx(Text, { color: palette.dim, children: formatTime(event.timestamp) }), _jsx(Text, { color: wsStatusColor, bold: true, children: wsStatusText }), _jsx(Text, { color: durationColor(event.duration), children: formatDuration(event.duration) })] }), _jsxs(Box, { children: [_jsx(Text, { color: palette.muted, children: event.host }), _jsxs(Text, { color: palette.subtle, children: [" ", "\u2192", " "] }), _jsx(Text, { color: palette.dim, children: truncate(event.target, targetMax) })] }), event.error && (_jsx(Box, { children: _jsx(Text, { color: palette.error, children: truncate(event.error, urlMax) }) }))] }));
83
+ }
84
+ export function DetailPanel({ termSize, scrollOffset, onScrollChange, onSelectionChange, active, }) {
85
+ const event = useSelected();
86
+ const detail = useSelectedDetail();
87
+ const panelWidth = Math.max(20, Math.floor(termSize.cols / 2));
88
+ const panelHeight = Math.max(4, termSize.rows);
89
+ const innerWidth = Math.max(12, panelWidth - BORDER_COLS - PADDING_X * 2);
90
+ const innerHeight = Math.max(1, panelHeight - BORDER_ROWS);
91
+ const nameMax = Math.max(8, Math.floor(innerWidth * 0.3));
92
+ const valueMax = Math.max(12, innerWidth - nameMax - 4);
93
+ const urlMax = Math.max(16, innerWidth - 10);
94
+ const targetMax = Math.max(16, innerWidth - 6);
95
+ const separatorWidth = Math.max(1, innerWidth - 1);
96
+ // Reset scroll when selection changes
97
+ const prevEventIdRef = useRef(undefined);
98
+ useEffect(() => {
99
+ if (event?.id !== prevEventIdRef.current) {
100
+ prevEventIdRef.current = event?.id;
101
+ onSelectionChange();
102
+ }
103
+ }, [event?.id, onSelectionChange]);
104
+ const availableRows = Math.max(1, innerHeight - DETAIL_RESERVED);
105
+ const isWs = event?.type === "ws";
106
+ const lines = React.useMemo(() => (isWs ? buildWsDetailLines() : buildDetailLines(detail, nameMax, valueMax)), [isWs, detail, nameMax, valueMax]);
107
+ const totalLines = lines.length;
108
+ const maxScroll = Math.max(0, totalLines - availableRows);
109
+ const clampedOffset = Math.min(scrollOffset, maxScroll);
110
+ // Sync clamped value back to parent after paint (avoids flicker from render-time setState)
111
+ useEffect(() => {
112
+ if (scrollOffset > maxScroll) {
113
+ onScrollChange(() => maxScroll);
114
+ }
115
+ }, [scrollOffset, maxScroll, onScrollChange]);
116
+ // When the event changes, the parent's onSelectionChange resets scrollOffset to 0.
117
+ // clampedOffset will be 0 on the subsequent render.
118
+ const displayOffset = clampedOffset;
119
+ if (!event)
120
+ return _jsx(PanelEmpty, { active: active, height: panelHeight });
121
+ return (_jsxs(Box, { width: "50%", height: panelHeight, flexDirection: "column", borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: PADDING_X, children: [event.type === "ws" ? (_jsx(WsSummary, { event: event, urlMax: urlMax, targetMax: targetMax })) : (_jsx(HttpSummary, { event: event, urlMax: urlMax, targetMax: targetMax })), _jsx(Text, { color: palette.subtle, children: "─".repeat(separatorWidth) }), _jsx(Box, { height: availableRows, flexDirection: "column", children: lines.slice(displayOffset, displayOffset + availableRows) })] }));
122
+ }
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { palette } from "../utils/format.js";
5
+ function HintItem({ keys, label }) {
6
+ return (_jsxs(Text, { children: [_jsx(Text, { color: palette.accent, bold: true, children: keys }), _jsxs(Text, { color: palette.muted, children: [" ", label] })] }));
7
+ }
8
+ const MEM_INTERVAL = 5000;
9
+ function formatMB(bytes) {
10
+ const mb = bytes / (1024 * 1024);
11
+ return mb >= 100 ? `${mb.toFixed(0)} MB` : `${mb.toFixed(1)} MB`;
12
+ }
13
+ /**
14
+ * Standalone memory display — does NOT subscribe to the main store.
15
+ * Only triggers a re-render when the formatted string actually changes,
16
+ * keeping FooterBar completely idle during normal request traffic.
17
+ */
18
+ function useMemoryDisplay() {
19
+ const [display, setDisplay] = useState(() => formatMB(process.memoryUsage.rss()));
20
+ useEffect(() => {
21
+ const id = setInterval(() => {
22
+ setDisplay((prev) => {
23
+ const next = formatMB(process.memoryUsage.rss());
24
+ return prev === next ? prev : next;
25
+ });
26
+ }, MEM_INTERVAL);
27
+ return () => {
28
+ clearInterval(id);
29
+ };
30
+ }, []);
31
+ return display;
32
+ }
33
+ export function FooterBar({ termSize, focus, showDetail }) {
34
+ const mem = useMemoryDisplay();
35
+ const base = [
36
+ ...(showDetail ? [{ keys: "←/→", label: "FOCUS" }] : []),
37
+ focus === "detail" ? { keys: "↑/↓", label: "SCROLL" } : { keys: "↑/↓", label: "NAV" },
38
+ { keys: "J/K", label: "NAV" },
39
+ { keys: "G", label: "TOP" },
40
+ { keys: "SHIFT+G", label: "END" },
41
+ { keys: "ENTER", label: showDetail ? "INSPECT" : "DETAIL" },
42
+ { keys: "/", label: "FILTER" },
43
+ { keys: "F", label: "FOLLOW" },
44
+ { keys: "N", label: "QUIET" },
45
+ { keys: "E", label: "ERRORS" },
46
+ { keys: "R", label: "REPLAY" },
47
+ { keys: "Y", label: "COPY" },
48
+ { keys: "X", label: "CLEAR" },
49
+ ...(showDetail ? [{ keys: "ESC", label: "CLOSE" }] : []),
50
+ ];
51
+ const maxHints = termSize.cols >= 140
52
+ ? 12
53
+ : termSize.cols >= 120
54
+ ? 10
55
+ : termSize.cols >= 100
56
+ ? 8
57
+ : termSize.cols >= 80
58
+ ? 6
59
+ : 4;
60
+ const hints = base.slice(0, maxHints);
61
+ return (_jsxs(Box, { width: "100%", paddingX: 1, justifyContent: "space-between", children: [_jsx(Box, { gap: 1, children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { color: palette.subtle, children: "\u00B7" }), _jsx(HintItem, { ...hint })] }, `${hint.keys}-${hint.label}`))) }), _jsx(Text, { color: palette.dim, children: mem })] }));
62
+ }
@@ -0,0 +1,104 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo, useMemo } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useStore } from "../store.js";
5
+ import { DOMAIN, PROXY_PORT } from "../proxy/routes.js";
6
+ import { formatTime, formatDuration, formatSize, methodColor, statusColor, durationColor, compactUrl, truncate, pad, subdomainFrom, subdomainColor, palette, } from "../utils/format.js";
7
+ import { LIST_COL as COL, getListDimensions, buildListHeaderTokens, LIST_PADDING_X, } from "../utils/list-layout.js";
8
+ function headerTokenColor(token) {
9
+ switch (token.kind) {
10
+ case "requests":
11
+ return { color: palette.brand, bold: true };
12
+ case "follow":
13
+ return {
14
+ color: token.active ? palette.success : palette.dim,
15
+ bold: !!token.active,
16
+ };
17
+ case "meta":
18
+ return { color: palette.dim };
19
+ case "filter-label":
20
+ return { color: palette.subtle };
21
+ case "err":
22
+ return {
23
+ color: token.active ? palette.error : palette.dim,
24
+ bold: !!token.active,
25
+ };
26
+ case "quiet":
27
+ return {
28
+ color: token.active ? palette.accent : palette.dim,
29
+ bold: !!token.active,
30
+ };
31
+ case "query":
32
+ return { color: palette.accent, bold: true };
33
+ default:
34
+ return { color: palette.text };
35
+ }
36
+ }
37
+ const ListHeader = memo(function ListHeader({ count, available, followMode, errorsOnly, hideNextStatic, searchQuery, }) {
38
+ const { left, right } = buildListHeaderTokens({
39
+ available,
40
+ count,
41
+ followMode,
42
+ errorsOnly,
43
+ hideNextStatic,
44
+ searchQuery,
45
+ });
46
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { gap: 1, children: left.map((token) => {
47
+ const { color, bold } = headerTokenColor(token);
48
+ return (_jsx(Text, { color: color, bold: bold, children: token.text }, `left-${token.kind}`));
49
+ }) }), _jsx(Box, { gap: 1, children: right.map((token, i) => {
50
+ const { color, bold } = headerTokenColor(token);
51
+ return (_jsx(Text, { color: color, bold: bold, children: token.text }, `right-${token.kind}-${i}`));
52
+ }) })] }));
53
+ });
54
+ const TableHeader = memo(function TableHeader({ pathW }) {
55
+ return (_jsx(Box, { children: _jsxs(Text, { color: palette.brand, bold: true, children: [" ", pad("REQ TIME", COL.time), pad("METHOD", COL.method), pad("SUB", COL.sub), pad("URL", pathW), pad("ST", COL.status), pad("SIZE", COL.size), "DR"] }) }));
56
+ });
57
+ function EmptyState() {
58
+ return (_jsxs(Box, { flexDirection: "column", paddingY: 2, paddingX: 2, children: [_jsxs(Text, { color: palette.brand, bold: true, children: ["\u25C9", " Listening for traffic", "\u2026"] }), _jsxs(Text, { color: palette.muted, children: ["Open ", _jsx(Text, { color: palette.accent, bold: true, children: `http://*.${DOMAIN}:${PROXY_PORT}` }), " ", "in your browser"] })] }));
59
+ }
60
+ const RequestRow = memo(function RequestRow({ event, isSelected, isEven, pathW, }) {
61
+ const pointer = isSelected ? "\u27A4" : " ";
62
+ const time = formatTime(event.timestamp);
63
+ const method = event.method.toUpperCase();
64
+ const sub = subdomainFrom(event.host);
65
+ const path = truncate(compactUrl(event.url), pathW - 1);
66
+ // WS-specific rendering
67
+ if (event.type === "ws") {
68
+ const wsStatusText = event.wsStatus === "open" ? "OPEN" : event.wsStatus === "closed" ? "CLOS" : "ERR";
69
+ const wsStatusColor = event.wsStatus === "open"
70
+ ? palette.success
71
+ : event.wsStatus === "error"
72
+ ? palette.error
73
+ : palette.dim;
74
+ const dur = formatDuration(event.duration);
75
+ const dColor = durationColor(event.duration);
76
+ const rowBg = isSelected ? palette.selection : undefined;
77
+ return (_jsx(Box, { children: _jsxs(Text, { backgroundColor: rowBg, dimColor: !isSelected && !isEven, children: [_jsx(Text, { color: isSelected ? palette.accent : palette.subtle, children: pointer }), _jsxs(Text, { color: isSelected ? palette.accent : palette.muted, children: [time, " "] }), _jsx(Text, { color: palette.ws, bold: true, children: pad(method, 7) }), _jsx(Text, { children: " " }), _jsx(Text, { color: subdomainColor(sub), bold: isSelected, children: pad(sub, COL.sub - 1) }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? palette.text : palette.dim, children: pad(path, pathW - 1) }), _jsx(Text, { color: wsStatusColor, bold: true, children: pad(wsStatusText, 5) }), _jsx(Text, { color: palette.muted, children: pad("-", COL.size) }), _jsxs(Text, { color: dColor, children: [" ", dur] })] }) }));
78
+ }
79
+ // HTTP rendering
80
+ const dur = formatDuration(event.duration);
81
+ const size = formatSize(event.responseSize);
82
+ const status = event.error
83
+ ? "ERR"
84
+ : event.statusCode
85
+ ? String(event.statusCode)
86
+ : "\u2022\u2022\u2022";
87
+ const sColor = event.error ? palette.error : statusColor(event.statusCode);
88
+ const dColor = durationColor(event.duration);
89
+ // Selected row: subtle background tint + brighter text, no inverse
90
+ const rowBg = isSelected ? palette.selection : undefined;
91
+ return (_jsx(Box, { children: _jsxs(Text, { backgroundColor: rowBg, dimColor: !isSelected && !isEven, children: [_jsx(Text, { color: isSelected ? palette.accent : palette.subtle, children: pointer }), _jsxs(Text, { color: isSelected ? palette.accent : palette.muted, children: [time, " "] }), _jsx(Text, { color: methodColor(method), bold: true, children: pad(method, 7) }), _jsx(Text, { children: " " }), _jsx(Text, { color: subdomainColor(sub), bold: isSelected, children: pad(sub, COL.sub - 1) }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? palette.text : palette.dim, children: pad(path, pathW - 1) }), _jsx(Text, { color: sColor, bold: !!event.statusCode || !!event.error, children: pad(status, 5) }), _jsx(Text, { color: palette.dim, children: pad(size, COL.size) }), _jsxs(Text, { color: dColor, children: [" ", dur] })] }) }));
92
+ });
93
+ export function RequestList({ halfWidth, termSize, active }) {
94
+ const { events, selectedIndex, followMode, errorsOnly, hideNextStatic, searchQuery } = useStore();
95
+ const { innerWidth, pathW, visibleRows } = getListDimensions(termSize, halfWidth);
96
+ // Auto-scroll: keep selectedIndex visible
97
+ const scrollOffset = useMemo(() => {
98
+ if (events.length <= visibleRows)
99
+ return 0;
100
+ return Math.max(0, Math.min(selectedIndex - Math.floor(visibleRows / 2), events.length - visibleRows));
101
+ }, [events.length, selectedIndex, visibleRows]);
102
+ const visible = useMemo(() => events.slice(scrollOffset, scrollOffset + visibleRows), [events, scrollOffset, visibleRows]);
103
+ return (_jsx(Box, { width: halfWidth ? "50%" : "100%", height: termSize.rows, borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: LIST_PADDING_X, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, width: "100%", children: [_jsx(ListHeader, { count: events.length, available: innerWidth, followMode: followMode, errorsOnly: errorsOnly, hideNextStatic: hideNextStatic, searchQuery: searchQuery }), _jsx(TableHeader, { pathW: pathW }), _jsx(Text, { color: palette.subtle, children: "─".repeat(Math.max(1, innerWidth - 1)) }), events.length === 0 ? (_jsx(EmptyState, {})) : (visible.map((event, i) => (_jsx(RequestRow, { event: event, isSelected: scrollOffset + i === selectedIndex, isEven: (scrollOffset + i) % 2 === 0, pathW: pathW }, event.id))))] }) }));
104
+ }
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { DOMAIN, ROUTES, DEFAULT_TARGET, PROXY_PORT, HTTPS_PORT, } from "../proxy/routes.js";
4
+ import { useWorktrees } from "../proxy/worktrees.js";
5
+ import { palette } from "../utils/format.js";
6
+ export function Splash({ httpsEnabled = false }) {
7
+ const sorted = Object.entries(ROUTES).sort(([a], [b]) => a.localeCompare(b));
8
+ const worktrees = useWorktrees();
9
+ const line = "─".repeat(44);
10
+ return (_jsx(Box, { alignItems: "center", justifyContent: "center", flexGrow: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: palette.accent, paddingX: 4, paddingY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: palette.accent, bold: true, children: "DEV-PROXY" }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.dim, children: "LIVE TRAFFIC INSPECTOR" }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [sorted.map(([sub, target]) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, children: `${sub}.${DOMAIN}`.padEnd(22) }), _jsx(Text, { color: palette.subtle, children: "\u279C" }), _jsx(Text, { color: palette.dim, children: target })] }, sub))), DEFAULT_TARGET && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.muted, children: `*.${DOMAIN}`.padEnd(22) }), _jsx(Text, { color: palette.subtle, children: "\u279C" }), _jsx(Text, { color: palette.dim, children: DEFAULT_TARGET })] }))] }), (() => {
11
+ const wts = [...worktrees.entries()]
12
+ .filter(([b]) => b !== "main")
13
+ .sort(([a], [b]) => a.localeCompare(b));
14
+ if (wts.length === 0)
15
+ return null;
16
+ return (_jsxs(_Fragment, { children: [_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.dim, children: "WORKTREES" }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: wts.map(([branch, entry]) => {
17
+ let portLabel;
18
+ if ("ports" in entry) {
19
+ const vals = Object.values(entry.ports);
20
+ const first = vals[0];
21
+ portLabel =
22
+ vals.length > 1
23
+ ? `:${first} +${vals.length - 1} more`
24
+ : `:${first}`;
25
+ }
26
+ else {
27
+ portLabel = `:${entry.port}`;
28
+ }
29
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.accent, children: branch.padEnd(14) }), _jsx(Text, { color: palette.muted, children: portLabel.padEnd(6) }), _jsx(Text, { color: palette.dim, children: `${branch}--*.${DOMAIN}` })] }, branch));
30
+ }) })] }));
31
+ })(), _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: [_jsx(Text, { color: palette.subtle, children: " \u00B7 " }), _jsxs(Text, { color: palette.success, bold: true, children: ["TLS :", HTTPS_PORT] })] }))] }), _jsxs(Box, { justifyContent: "center", marginTop: 1, children: [_jsx(Text, { color: palette.muted, children: "Press " }), _jsx(Text, { color: palette.accent, bold: true, children: "Enter" }), _jsx(Text, { color: palette.muted, children: " to arm" })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: "/ filter \u00B7 j/k nav \u00B7 r replay \u00B7 y copy" }) })] }) }));
32
+ }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { PROXY_PORT, HTTPS_PORT } from "../proxy/routes.js";
4
+ import { useWorktrees } from "../proxy/worktrees.js";
5
+ import { useStore } from "../store.js";
6
+ import { truncate, palette } from "../utils/format.js";
7
+ function Tag({ label, color }) {
8
+ return _jsx(Text, { color: color, bold: true, children: `[${label}]` });
9
+ }
10
+ export function StatusBar({ termSize, httpsEnabled }) {
11
+ const { followMode, events, errorsOnly, hideNextStatic, searchQuery, activeWsCount } = useStore();
12
+ const wts = useWorktrees();
13
+ const wtCount = [...wts.keys()].filter((k) => k !== "main").length;
14
+ const showFlags = termSize.cols >= 80;
15
+ const showSearch = !!searchQuery && termSize.cols >= 90;
16
+ const showHints = termSize.cols >= 120;
17
+ const query = searchQuery ? truncate(searchQuery, showHints ? 28 : 16) : "";
18
+ return (_jsxs(Box, { width: "100%", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: palette.brand, bold: true, children: ["\u25C9", " DEV-PROXY"] }), _jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsx(Text, { color: palette.success, bold: true, children: "LIVE" }), _jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsxs(Text, { color: palette.accent, bold: true, children: [":", PROXY_PORT] }), httpsEnabled && (_jsxs(_Fragment, { children: [_jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsxs(Text, { color: palette.success, bold: true, children: ["TLS :", HTTPS_PORT] })] })), wtCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsx(Tag, { label: `WT ${wtCount}`, color: palette.accent })] }))] }), _jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: palette.dim, children: [events.length, " REQ"] }), activeWsCount > 0 && (_jsxs(Text, { color: palette.ws, bold: true, children: ["WS ", activeWsCount] })), showFlags && followMode && _jsx(Tag, { label: "FOL", color: palette.success }), showFlags && errorsOnly && _jsx(Tag, { label: "ERR", color: palette.error }), showFlags && hideNextStatic && _jsx(Tag, { label: "QUIET", color: palette.muted }), showSearch && _jsx(Tag, { label: `/${query}`, color: palette.accent }), showHints && _jsx(Text, { color: palette.muted, children: "J/K \u00B7 ENTER \u00B7 /" })] })] }));
19
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useStdin, useStdout } from "ink";
3
+ const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
4
+ const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
5
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
6
+ const SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([mM])/g;
7
+ function parseSgrEvent(code, x, y, isDown) {
8
+ const isWheel = (code & 64) === 64;
9
+ if (isWheel) {
10
+ const direction = (code & 1) === 1 ? "down" : "up";
11
+ return { kind: "scroll", direction, x, y };
12
+ }
13
+ if (!isDown)
14
+ return null;
15
+ const button = code & 3;
16
+ if (button === 0)
17
+ return { kind: "down", button: "left", x, y };
18
+ return null;
19
+ }
20
+ export function useMouse(handler) {
21
+ const { stdout } = useStdout();
22
+ const { internal_eventEmitter } = useStdin();
23
+ const handlerRef = useRef(handler);
24
+ const bufferRef = useRef("");
25
+ useEffect(() => {
26
+ handlerRef.current = handler;
27
+ }, [handler]);
28
+ useEffect(() => {
29
+ stdout.write(ENABLE_MOUSE);
30
+ const onInput = (data) => {
31
+ const chunk = typeof data === "string" ? data : data.toString("utf8");
32
+ bufferRef.current += chunk;
33
+ let match;
34
+ let lastIndex = 0;
35
+ SGR_RE.lastIndex = 0;
36
+ while ((match = SGR_RE.exec(bufferRef.current)) !== null) {
37
+ lastIndex = SGR_RE.lastIndex;
38
+ const code = Number(match[1]);
39
+ const x = Number(match[2]);
40
+ const y = Number(match[3]);
41
+ const isDown = match[4] === "M";
42
+ const event = parseSgrEvent(code, x, y, isDown);
43
+ if (event)
44
+ handlerRef.current(event);
45
+ }
46
+ if (lastIndex > 0) {
47
+ bufferRef.current = bufferRef.current.slice(lastIndex);
48
+ }
49
+ else {
50
+ const prefixIndex = bufferRef.current.lastIndexOf("\x1b[<");
51
+ if (prefixIndex === -1)
52
+ bufferRef.current = "";
53
+ else if (prefixIndex > 0)
54
+ bufferRef.current = bufferRef.current.slice(prefixIndex);
55
+ }
56
+ if (bufferRef.current.length > 2000) {
57
+ bufferRef.current = bufferRef.current.slice(-200);
58
+ }
59
+ };
60
+ internal_eventEmitter.on("input", onInput);
61
+ return () => {
62
+ internal_eventEmitter.removeListener("input", onInput);
63
+ stdout.write(DISABLE_MOUSE);
64
+ };
65
+ }, [stdout, internal_eventEmitter]);
66
+ }
package/dist/index.js ADDED
@@ -0,0 +1,153 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ process.title = "dev-proxy";
3
+ import { render } from "ink";
4
+ import { createProxyServer, startProxyServer, destroyAgents } from "./proxy/server.js";
5
+ import { loadRegistry, stopRegistry } from "./proxy/worktrees.js";
6
+ import { pushHttp, pushWs } from "./store.js";
7
+ import { App } from "./components/app.js";
8
+ loadRegistry();
9
+ const { server, httpsServer, emitter } = createProxyServer();
10
+ let shuttingDown = false;
11
+ emitter.on("request", (event) => {
12
+ pushHttp(event);
13
+ });
14
+ emitter.on("request:complete", (event) => {
15
+ pushHttp(event);
16
+ });
17
+ emitter.on("request:error", (event) => {
18
+ pushHttp(event);
19
+ });
20
+ emitter.on("ws", (event) => {
21
+ pushWs(event);
22
+ });
23
+ try {
24
+ await startProxyServer(server, httpsServer);
25
+ }
26
+ catch (err) {
27
+ console.error(`[dev-proxy] Failed to start: ${err.message}`);
28
+ process.exit(1);
29
+ }
30
+ // ── Alternate screen buffer (fullscreen) ─────────────────────
31
+ process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
32
+ // ── Synchronized output wrapper ──────────────────────────────
33
+ // Buffers all stdout.write calls within the same event-loop tick and
34
+ // flushes them as a single atomic frame wrapped in DEC synchronized
35
+ // output markers (mode 2026). This prevents the terminal from painting
36
+ // intermediate states (cursor moves, partial line rewrites) which the
37
+ // user would perceive as flicker.
38
+ // Supported: iTerm2, kitty, WezTerm, Alacritty, Windows Terminal, etc.
39
+ // Unsupported terminals safely ignore the escape sequences.
40
+ const _raw = process.stdout.write.bind(process.stdout);
41
+ const MAX_BUF = 100;
42
+ let _buf = [];
43
+ let _pending = false;
44
+ let altScreenActive = true;
45
+ process.stdout.write = ((chunk, encodingOrCb, cb) => {
46
+ _buf.push(typeof chunk === "string"
47
+ ? chunk
48
+ : Buffer.isBuffer(chunk)
49
+ ? chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf-8")
50
+ : String(chunk));
51
+ // Force early flush if buffer grows too large
52
+ if (_buf.length >= MAX_BUF) {
53
+ flushBufferedFrame();
54
+ return true;
55
+ }
56
+ if (!_pending) {
57
+ _pending = true;
58
+ setImmediate(() => {
59
+ try {
60
+ const frame = _buf.join("");
61
+ _buf = [];
62
+ if (frame)
63
+ _raw("\x1b[?2026h" + frame + "\x1b[?2026l");
64
+ }
65
+ catch {
66
+ // stdout closed or broken — drop the frame silently
67
+ }
68
+ finally {
69
+ _pending = false;
70
+ }
71
+ });
72
+ }
73
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
74
+ if (callback)
75
+ callback();
76
+ return true;
77
+ });
78
+ /** Flush any buffered frame immediately via _raw (bypass setImmediate) */
79
+ function flushBufferedFrame() {
80
+ try {
81
+ if (_buf.length > 0) {
82
+ const frame = _buf.join("");
83
+ _buf = [];
84
+ if (frame)
85
+ _raw(frame);
86
+ }
87
+ }
88
+ catch {
89
+ /* ignored: stdout may be closed during shutdown */
90
+ }
91
+ _pending = false;
92
+ }
93
+ const app = render(_jsx(App, { httpsEnabled: httpsServer !== null }), {
94
+ patchConsole: false,
95
+ });
96
+ // ── Graceful shutdown ────────────────────────────────────────
97
+ function restoreTerminal() {
98
+ if (!altScreenActive)
99
+ return;
100
+ altScreenActive = false;
101
+ _raw("\x1b[?1049l");
102
+ }
103
+ function shutdown(code = 0) {
104
+ if (shuttingDown)
105
+ return;
106
+ shuttingDown = true;
107
+ stopRegistry();
108
+ try {
109
+ app.unmount();
110
+ }
111
+ catch {
112
+ /* ignored: best-effort cleanup */
113
+ }
114
+ try {
115
+ server.close();
116
+ }
117
+ catch {
118
+ /* ignored: best-effort cleanup */
119
+ }
120
+ try {
121
+ httpsServer?.close();
122
+ }
123
+ catch {
124
+ /* ignored: best-effort cleanup */
125
+ }
126
+ destroyAgents();
127
+ // Drain any buffered frame before leaving alternate screen
128
+ flushBufferedFrame();
129
+ restoreTerminal();
130
+ process.exit(code);
131
+ }
132
+ process.on("SIGINT", () => {
133
+ shutdown(0);
134
+ });
135
+ process.on("SIGTERM", () => {
136
+ shutdown(0);
137
+ });
138
+ process.on("SIGHUP", () => {
139
+ shutdown(0);
140
+ });
141
+ process.on("uncaughtException", (err, origin) => {
142
+ flushBufferedFrame();
143
+ restoreTerminal();
144
+ console.error(`[dev-proxy] Uncaught exception (${origin}): ${err.stack ?? err.message}`);
145
+ shutdown(1);
146
+ });
147
+ process.on("unhandledRejection", (reason) => {
148
+ const message = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
149
+ flushBufferedFrame();
150
+ restoreTerminal();
151
+ console.error(`[dev-proxy] Unhandled rejection: ${message}`);
152
+ shutdown(1);
153
+ });
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync, execSync } from "node:child_process";
5
+ import { config, CONFIG_DIR } from "./config.js";
6
+ const CERTS_DIR = path.resolve(CONFIG_DIR, "certs");
7
+ const DEFAULT_CERT = path.join(CERTS_DIR, `${config.domain}+1.pem`);
8
+ const DEFAULT_KEY = path.join(CERTS_DIR, `${config.domain}+1-key.pem`);
9
+ const DOMAINS = [`*.${config.domain}`, config.domain];
10
+ function hasMkcert() {
11
+ try {
12
+ execSync("which mkcert", { stdio: "ignore" });
13
+ return true;
14
+ }
15
+ catch {
16
+ // Expected: mkcert is simply not installed
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * Resolves TLS certificate paths for the dev proxy.
22
+ *
23
+ * 1. If explicit paths are provided (from config), uses those.
24
+ * 2. Otherwise checks for certs at ~/.dev-proxy/certs/.
25
+ * 3. If missing, auto-generates using mkcert (installs local CA if needed).
26
+ * 4. If mkcert is not installed, logs instructions and returns null.
27
+ */
28
+ export function resolveCerts(configCertPath, configKeyPath) {
29
+ // Explicit config — use as-is
30
+ if (configCertPath && configKeyPath) {
31
+ if (fs.existsSync(configCertPath) && fs.existsSync(configKeyPath)) {
32
+ return { certPath: configCertPath, keyPath: configKeyPath };
33
+ }
34
+ console.error(`[dev-proxy] Configured cert/key not found:\n cert: ${configCertPath}\n key: ${configKeyPath}`);
35
+ return null;
36
+ }
37
+ // Default location — check if already generated
38
+ if (fs.existsSync(DEFAULT_CERT) && fs.existsSync(DEFAULT_KEY)) {
39
+ return { certPath: DEFAULT_CERT, keyPath: DEFAULT_KEY };
40
+ }
41
+ // Auto-generate with mkcert
42
+ if (!hasMkcert()) {
43
+ console.error("[dev-proxy] HTTPS disabled — mkcert not found.\n" +
44
+ " Install: brew install mkcert && mkcert -install");
45
+ return null;
46
+ }
47
+ try {
48
+ fs.mkdirSync(CERTS_DIR, { recursive: true });
49
+ // Ensure local CA is installed (idempotent)
50
+ execFileSync("mkcert", ["-install"], { stdio: "ignore" });
51
+ // Generate wildcard cert (use execFileSync to avoid shell injection)
52
+ execFileSync("mkcert", ["-cert-file", DEFAULT_CERT, "-key-file", DEFAULT_KEY, ...DOMAINS], { stdio: "inherit" });
53
+ // Restrict cert file permissions to owner-only (0600)
54
+ try {
55
+ fs.chmodSync(DEFAULT_CERT, constants.S_IRUSR | constants.S_IWUSR);
56
+ fs.chmodSync(DEFAULT_KEY, constants.S_IRUSR | constants.S_IWUSR);
57
+ }
58
+ catch {
59
+ // Non-fatal: best-effort permission tightening
60
+ }
61
+ console.warn("[dev-proxy] TLS certificates generated with mkcert");
62
+ return { certPath: DEFAULT_CERT, keyPath: DEFAULT_KEY };
63
+ }
64
+ catch (err) {
65
+ console.error(`[dev-proxy] HTTPS disabled — mkcert failed: ${err.message}`);
66
+ return null;
67
+ }
68
+ }