patchrelay 0.14.1 → 0.15.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.14.1",
4
- "commit": "ceb28a181ab2",
5
- "builtAt": "2026-03-25T11:27:52.659Z"
3
+ "version": "0.15.0",
4
+ "commit": "55a384ed81ec",
5
+ "builtAt": "2026-03-25T12:16:29.541Z"
6
6
  }
@@ -41,7 +41,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
41
41
  if (key.escape || key.backspace || key.delete) {
42
42
  dispatch({ type: "exit-detail" });
43
43
  }
44
+ else if (input === "f") {
45
+ dispatch({ type: "toggle-follow" });
46
+ }
44
47
  }
45
48
  });
46
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report })) }));
49
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report, follow: state.follow })) }));
47
50
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function HelpBar({ view }) {
3
+ export function HelpBar({ view, follow }) {
4
4
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
5
5
  ? "j/k: navigate Enter: detail Tab: filter q: quit"
6
- : "Esc: back q: quit" }) }));
6
+ : `Esc: back f: follow ${follow ? "on" : "off"} q: quit` }) }));
7
7
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { ThreadView } from "./ThreadView.js";
4
4
  import { HelpBar } from "./HelpBar.js";
@@ -6,13 +6,23 @@ function truncate(text, max) {
6
6
  const line = text.replace(/\n/g, " ").trim();
7
7
  return line.length > max ? `${line.slice(0, max - 3)}...` : line;
8
8
  }
9
+ function formatTokens(n) {
10
+ if (n >= 1_000_000)
11
+ return `${(n / 1_000_000).toFixed(1)}M`;
12
+ if (n >= 1_000)
13
+ return `${(n / 1_000).toFixed(1)}k`;
14
+ return String(n);
15
+ }
16
+ function ThreadStatusBar({ thread, follow }) {
17
+ return (_jsxs(Box, { gap: 2, children: [thread.tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(thread.tokenUsage.inputTokens), " in / ", formatTokens(thread.tokenUsage.outputTokens), " out"] })), thread.diffSummary && thread.diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", thread.diffSummary.filesChanged, " file", thread.diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", thread.diffSummary.linesAdded, " -", thread.diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }));
18
+ }
9
19
  function ReportView({ report }) {
10
20
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "Latest run:" }), _jsx(Text, { bold: true, children: report.runType }), _jsx(Text, { color: report.status === "completed" ? "green" : "red", children: report.status })] }), report.summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary:" }), _jsx(Text, { wrap: "wrap", children: truncate(report.summary, 300) })] })), report.commands.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Commands (", report.commands.length, "):"] }), report.commands.slice(-10).map((cmd, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: cmd.exitCode === 0 ? "green" : cmd.exitCode !== undefined ? "red" : "white", children: cmd.exitCode === 0 ? "\u2713" : cmd.exitCode !== undefined ? "\u2717" : " " }), _jsx(Text, { dimColor: true, children: "$ " }), _jsx(Text, { children: truncate(cmd.command, 60) }), cmd.durationMs !== undefined && _jsxs(Text, { dimColor: true, children: [" ", (cmd.durationMs / 1000).toFixed(1), "s"] })] }, `cmd-${i}`)))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [report.fileChanges > 0 && _jsxs(Text, { dimColor: true, children: [report.fileChanges, " file change", report.fileChanges !== 1 ? "s" : ""] }), report.toolCalls > 0 && _jsxs(Text, { dimColor: true, children: [report.toolCalls, " tool call", report.toolCalls !== 1 ? "s" : ""] }), report.assistantMessages.length > 0 && _jsxs(Text, { dimColor: true, children: [report.assistantMessages.length, " message", report.assistantMessages.length !== 1 ? "s" : ""] })] })] }));
11
21
  }
12
- export function IssueDetailView({ issue, thread, report }) {
22
+ export function IssueDetailView({ issue, thread, report, follow }) {
13
23
  if (!issue) {
14
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail" })] }));
24
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
15
25
  }
16
26
  const key = issue.issueKey ?? issue.projectId;
17
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail" })] }));
27
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), thread && _jsx(ThreadStatusBar, { thread: thread, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread, follow: follow })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
18
28
  }
@@ -15,6 +15,12 @@ function planStepColor(status) {
15
15
  return "yellow";
16
16
  return "white";
17
17
  }
18
- export function ThreadView({ thread }) {
19
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: thread.turns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i }, turn.id))) })] }));
18
+ export function ThreadView({ thread, follow }) {
19
+ const visibleTurns = follow && thread.turns.length > 1
20
+ ? thread.turns.slice(-1)
21
+ : thread.turns;
22
+ const turnOffset = follow && thread.turns.length > 1
23
+ ? thread.turns.length - 1
24
+ : 0;
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visibleTurns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i + turnOffset, follow: follow }, turn.id))) })] }));
20
26
  }
@@ -10,6 +10,11 @@ function turnStatusColor(status) {
10
10
  return "yellow";
11
11
  return "white";
12
12
  }
13
- export function TurnSection({ turn, index }) {
14
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), turn.items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === turn.items.length - 1 }, item.id)))] }));
13
+ const FOLLOW_TAIL_SIZE = 8;
14
+ export function TurnSection({ turn, index, follow }) {
15
+ const items = follow && turn.items.length > FOLLOW_TAIL_SIZE
16
+ ? turn.items.slice(-FOLLOW_TAIL_SIZE)
17
+ : turn.items;
18
+ const skipped = turn.items.length - items.length;
19
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier items"] }), items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === items.length - 1 }, item.id)))] }));
15
20
  }
@@ -7,6 +7,7 @@ export const initialWatchState = {
7
7
  thread: null,
8
8
  report: null,
9
9
  filter: "non-done",
10
+ follow: true,
10
11
  };
11
12
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
12
13
  export function filterIssues(issues, filter) {
@@ -57,6 +58,8 @@ export function watchReducer(state, action) {
57
58
  return applyCodexNotification(state, action.method, action.params);
58
59
  case "cycle-filter":
59
60
  return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
61
+ case "toggle-follow":
62
+ return { ...state, follow: !state.follow };
60
63
  }
61
64
  }
62
65
  // ─── Feed Event Application ───────────────────────────────────────
@@ -123,6 +126,8 @@ function applyCodexNotification(state, method, params) {
123
126
  return withThread(state, appendItemText(state.thread, params));
124
127
  case "thread/status/changed":
125
128
  return withThread(state, updateThreadStatus(state.thread, params));
129
+ case "thread/tokenUsage/updated":
130
+ return withThread(state, updateTokenUsage(state.thread, params));
126
131
  default:
127
132
  return state;
128
133
  }
@@ -185,7 +190,34 @@ function updatePlan(thread, params) {
185
190
  }
186
191
  function updateDiff(thread, params) {
187
192
  const diff = typeof params.diff === "string" ? params.diff : undefined;
188
- return { ...thread, diff };
193
+ return { ...thread, diff, diffSummary: diff ? parseDiffSummary(diff) : undefined };
194
+ }
195
+ function parseDiffSummary(diff) {
196
+ const files = new Set();
197
+ let added = 0;
198
+ let removed = 0;
199
+ for (const line of diff.split("\n")) {
200
+ if (line.startsWith("+++ b/")) {
201
+ files.add(line.slice(6));
202
+ }
203
+ else if (line.startsWith("+") && !line.startsWith("+++")) {
204
+ added += 1;
205
+ }
206
+ else if (line.startsWith("-") && !line.startsWith("---")) {
207
+ removed += 1;
208
+ }
209
+ }
210
+ return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
211
+ }
212
+ function updateTokenUsage(thread, params) {
213
+ const usage = params.usage;
214
+ if (!usage)
215
+ return thread;
216
+ const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
217
+ : typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
218
+ const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
219
+ : typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
220
+ return { ...thread, tokenUsage: { inputTokens, outputTokens } };
189
221
  }
190
222
  function updateThreadStatus(thread, params) {
191
223
  const statusObj = params.status;
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ async function main() {
28
28
  await ensureDir(project.worktreeRoot);
29
29
  }
30
30
  await enforceRuntimeFilePermissions(config);
31
- const preflight = await runPreflight(config);
31
+ const preflight = await runPreflight(config, { skipServiceCheck: true });
32
32
  const failedChecks = preflight.checks.filter((check) => check.status === "fail");
33
33
  if (failedChecks.length > 0) {
34
34
  throw new Error(["PatchRelay startup preflight failed:", ...failedChecks.map((check) => `- [${check.scope}] ${check.message}`)].join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {