patchrelay 0.36.19 → 0.37.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.36.19",
4
- "commit": "4022639921ea",
5
- "builtAt": "2026-04-10T13:21:18.771Z"
3
+ "version": "0.37.0",
4
+ "commit": "e927a73f2d7b",
5
+ "builtAt": "2026-04-10T13:26:32.780Z"
6
6
  }
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useReducer, useMemo, useCallback, useState } from "react";
3
- import { Box, Text, useApp, useInput } from "ink";
2
+ import { useReducer, useMemo, useCallback, useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
7
  import { IssueListView } from "./IssueListView.js";
8
8
  import { IssueDetailView } from "./IssueDetailView.js";
9
+ import { buildWatchDetailExportText, exportWatchTextToTempFile, findLastAssistantMessage, findLastCommand, findLastCommandOutput, openTextInPager, writeTextToClipboard, } from "./watch-actions.js";
10
+ import { measureRenderedTextRows } from "./layout-measure.js";
11
+ import { PROMPT_COMPOSER_HINT, measurePromptComposerRows } from "./prompt-layout.js";
12
+ import { clearTransientStatus, defaultTimerApi, setPersistentStatus, showTransientStatus } from "./transient-status.js";
9
13
  async function postPrompt(baseUrl, issueKey, text, bearerToken) {
10
14
  const headers = { "content-type": "application/json" };
11
15
  if (bearerToken)
@@ -61,31 +65,53 @@ async function postRetry(baseUrl, issueKey, bearerToken) {
61
65
  }
62
66
  export function App({ baseUrl, bearerToken, initialIssueKey }) {
63
67
  const { exit } = useApp();
68
+ const { stdout } = useStdout();
64
69
  const [state, dispatch] = useReducer(watchReducer, {
65
70
  ...initialWatchState,
66
71
  ...(initialIssueKey ? { view: "detail", activeDetailKey: initialIssueKey } : {}),
67
72
  });
68
73
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
69
74
  const [frozen, setFrozen] = useState(false);
75
+ const width = Math.max(20, stdout?.columns ?? 80);
70
76
  useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
71
77
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
72
78
  const [promptMode, setPromptMode] = useState(false);
73
79
  const [promptBuffer, setPromptBuffer] = useState("");
80
+ const [promptCursor, setPromptCursor] = useState(0);
81
+ const [promptHistory, setPromptHistory] = useState([]);
82
+ const [promptHistoryIndex, setPromptHistoryIndex] = useState(null);
83
+ const [promptDraftBeforeHistory, setPromptDraftBeforeHistory] = useState("");
84
+ const [promptStatus, setPromptStatus] = useState(null);
85
+ const promptStatusController = useRef({ timer: null });
86
+ const activeIssue = state.issues.find((issue) => issue.issueKey === state.activeDetailKey);
87
+ const showStatus = useCallback((message) => {
88
+ showTransientStatus(promptStatusController.current, message, setPromptStatus, defaultTimerApi);
89
+ }, []);
90
+ const showPersistentStatus = useCallback((message) => {
91
+ setPersistentStatus(promptStatusController.current, message, setPromptStatus, defaultTimerApi);
92
+ }, []);
93
+ useEffect(() => () => {
94
+ clearTransientStatus(promptStatusController.current, defaultTimerApi);
95
+ }, []);
96
+ const resetPromptComposer = useCallback(() => {
97
+ setPromptMode(false);
98
+ setPromptBuffer("");
99
+ setPromptCursor(0);
100
+ setPromptHistoryIndex(null);
101
+ setPromptDraftBeforeHistory("");
102
+ }, []);
74
103
  const handleRetry = useCallback(() => {
75
104
  if (!state.activeDetailKey)
76
105
  return;
77
- setPromptStatus("retrying...");
106
+ showPersistentStatus("retrying...");
78
107
  void postRetry(baseUrl, state.activeDetailKey, bearerToken).then((result) => {
79
- setPromptStatus(result.ok ? "retry queued" : `retry failed: ${result.reason ?? "unknown"}`);
80
- setTimeout(() => setPromptStatus(null), 3000);
108
+ showStatus(result.ok ? "retry queued" : `retry failed: ${result.reason ?? "unknown"}`);
81
109
  });
82
- }, [baseUrl, bearerToken, state.activeDetailKey]);
83
- const [promptStatus, setPromptStatus] = useState(null);
110
+ }, [baseUrl, bearerToken, showPersistentStatus, showStatus, state.activeDetailKey]);
84
111
  const handlePromptSubmit = useCallback(() => {
85
112
  const text = promptBuffer.trim();
86
113
  if (!state.activeDetailKey || !text) {
87
- setPromptMode(false);
88
- setPromptBuffer("");
114
+ resetPromptComposer();
89
115
  return;
90
116
  }
91
117
  // Add synthetic userMessage to timeline immediately
@@ -94,36 +120,184 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
94
120
  method: "item/started",
95
121
  params: { item: { id: `prompt-${Date.now()}`, type: "userMessage", status: "completed", text } },
96
122
  });
97
- setPromptMode(false);
98
- setPromptBuffer("");
99
- setPromptStatus("sending...");
123
+ setPromptHistory((history) => {
124
+ const next = history.at(-1) === text ? history : [...history, text];
125
+ return next.slice(-20);
126
+ });
127
+ resetPromptComposer();
128
+ showPersistentStatus("sending...");
100
129
  void postPrompt(baseUrl, state.activeDetailKey, text, bearerToken).then((result) => {
101
130
  if (result.delivered) {
102
- setPromptStatus("delivered");
131
+ showStatus("delivered");
103
132
  }
104
133
  else if (result.queued) {
105
- setPromptStatus("queued for next run");
134
+ showStatus("queued for next run");
106
135
  }
107
136
  else if (result.reason) {
108
- setPromptStatus(`failed: ${result.reason}`);
137
+ showStatus(`failed: ${result.reason}`);
109
138
  }
110
- setTimeout(() => setPromptStatus(null), 3000);
111
139
  });
112
- }, [baseUrl, bearerToken, state.activeDetailKey, promptBuffer]);
140
+ }, [baseUrl, bearerToken, dispatch, promptBuffer, resetPromptComposer, showPersistentStatus, showStatus, state.activeDetailKey]);
141
+ const withActiveIssueExport = useCallback(() => {
142
+ if (!activeIssue)
143
+ return null;
144
+ return {
145
+ issue: activeIssue,
146
+ timeline: state.timeline,
147
+ activeRunStartedAt: state.activeRunStartedAt,
148
+ activeRunId: state.activeRunId,
149
+ tokenUsage: state.tokenUsage,
150
+ diffSummary: state.diffSummary,
151
+ plan: state.plan,
152
+ issueContext: state.issueContext,
153
+ detailTab: state.detailTab,
154
+ rawRuns: state.rawRuns,
155
+ rawFeedEvents: state.rawFeedEvents,
156
+ };
157
+ }, [
158
+ activeIssue,
159
+ state.activeRunId,
160
+ state.activeRunStartedAt,
161
+ state.detailTab,
162
+ state.diffSummary,
163
+ state.issueContext,
164
+ state.plan,
165
+ state.rawFeedEvents,
166
+ state.rawRuns,
167
+ state.timeline,
168
+ state.tokenUsage,
169
+ ]);
170
+ const handleCopyLastAssistant = useCallback(() => {
171
+ const text = findLastAssistantMessage(state.timeline);
172
+ if (!text) {
173
+ showStatus("no assistant message to copy");
174
+ return;
175
+ }
176
+ showStatus(writeTextToClipboard(text) ? "copied assistant message" : "clipboard unavailable");
177
+ }, [showStatus, state.timeline]);
178
+ const handleCopyLastCommand = useCallback(() => {
179
+ const text = findLastCommand(state.timeline);
180
+ if (!text) {
181
+ showStatus("no command to copy");
182
+ return;
183
+ }
184
+ showStatus(writeTextToClipboard(text) ? "copied last command" : "clipboard unavailable");
185
+ }, [showStatus, state.timeline]);
186
+ const handleCopyLastCommandOutput = useCallback(() => {
187
+ const text = findLastCommandOutput(state.timeline);
188
+ if (!text) {
189
+ showStatus("no command output to copy");
190
+ return;
191
+ }
192
+ showStatus(writeTextToClipboard(text) ? "copied command output" : "clipboard unavailable");
193
+ }, [showStatus, state.timeline]);
194
+ const handleExportTranscript = useCallback(() => {
195
+ const exportInput = withActiveIssueExport();
196
+ if (!exportInput)
197
+ return;
198
+ const text = buildWatchDetailExportText(exportInput);
199
+ const filePath = exportWatchTextToTempFile(text, exportInput.issue.issueKey ?? exportInput.issue.projectId);
200
+ showStatus(`exported transcript: ${filePath}`);
201
+ }, [showStatus, withActiveIssueExport]);
202
+ const handleOpenTranscriptInPager = useCallback(() => {
203
+ const exportInput = withActiveIssueExport();
204
+ if (!exportInput)
205
+ return;
206
+ const text = buildWatchDetailExportText(exportInput);
207
+ const result = openTextInPager(text);
208
+ if (result.ok) {
209
+ showStatus("opened transcript in pager");
210
+ return;
211
+ }
212
+ const filePath = exportWatchTextToTempFile(text, exportInput.issue.issueKey ?? exportInput.issue.projectId);
213
+ showStatus(`pager failed, exported transcript: ${filePath}`);
214
+ }, [showStatus, withActiveIssueExport]);
215
+ const insertPromptText = useCallback((text) => {
216
+ setPromptBuffer((buffer) => `${buffer.slice(0, promptCursor)}${text}${buffer.slice(promptCursor)}`);
217
+ setPromptCursor((cursor) => cursor + text.length);
218
+ setPromptHistoryIndex(null);
219
+ }, [promptCursor]);
220
+ const movePromptCursor = useCallback((delta) => {
221
+ setPromptCursor((cursor) => Math.max(0, Math.min(promptBuffer.length, cursor + delta)));
222
+ }, [promptBuffer.length]);
223
+ const recallPromptHistory = useCallback((direction) => {
224
+ if (promptHistory.length === 0)
225
+ return;
226
+ if (direction === "older") {
227
+ if (promptHistoryIndex === null) {
228
+ setPromptDraftBeforeHistory(promptBuffer);
229
+ const nextIndex = promptHistory.length - 1;
230
+ const next = promptHistory[nextIndex] ?? "";
231
+ setPromptHistoryIndex(nextIndex);
232
+ setPromptBuffer(next);
233
+ setPromptCursor(next.length);
234
+ return;
235
+ }
236
+ const nextIndex = Math.max(0, promptHistoryIndex - 1);
237
+ const next = promptHistory[nextIndex] ?? "";
238
+ setPromptHistoryIndex(nextIndex);
239
+ setPromptBuffer(next);
240
+ setPromptCursor(next.length);
241
+ return;
242
+ }
243
+ if (promptHistoryIndex === null)
244
+ return;
245
+ if (promptHistoryIndex >= promptHistory.length - 1) {
246
+ setPromptHistoryIndex(null);
247
+ setPromptBuffer(promptDraftBeforeHistory);
248
+ setPromptCursor(promptDraftBeforeHistory.length);
249
+ return;
250
+ }
251
+ const nextIndex = promptHistoryIndex + 1;
252
+ const next = promptHistory[nextIndex] ?? "";
253
+ setPromptHistoryIndex(nextIndex);
254
+ setPromptBuffer(next);
255
+ setPromptCursor(next.length);
256
+ }, [promptBuffer, promptDraftBeforeHistory, promptHistory, promptHistoryIndex]);
113
257
  useInput((input, key) => {
114
258
  if (promptMode) {
115
259
  if (key.escape) {
116
- setPromptMode(false);
117
- setPromptBuffer("");
260
+ resetPromptComposer();
261
+ }
262
+ else if (key.ctrl && input === "n") {
263
+ insertPromptText("\n");
118
264
  }
119
265
  else if (key.return) {
120
266
  handlePromptSubmit();
121
267
  }
122
- else if (key.backspace || key.delete) {
123
- setPromptBuffer((b) => b.slice(0, -1));
268
+ else if (key.leftArrow) {
269
+ movePromptCursor(-1);
270
+ }
271
+ else if (key.rightArrow) {
272
+ movePromptCursor(1);
273
+ }
274
+ else if (key.home) {
275
+ setPromptCursor(0);
276
+ }
277
+ else if (key.end) {
278
+ setPromptCursor(promptBuffer.length);
279
+ }
280
+ else if (key.upArrow) {
281
+ recallPromptHistory("older");
282
+ }
283
+ else if (key.downArrow) {
284
+ recallPromptHistory("newer");
285
+ }
286
+ else if (key.backspace) {
287
+ if (promptCursor > 0) {
288
+ setPromptBuffer((buffer) => `${buffer.slice(0, promptCursor - 1)}${buffer.slice(promptCursor)}`);
289
+ setPromptCursor((cursor) => Math.max(0, cursor - 1));
290
+ setPromptHistoryIndex(null);
291
+ }
292
+ }
293
+ else if (key.delete) {
294
+ if (promptCursor < promptBuffer.length) {
295
+ setPromptBuffer((buffer) => `${buffer.slice(0, promptCursor)}${buffer.slice(promptCursor + 1)}`);
296
+ setPromptHistoryIndex(null);
297
+ }
124
298
  }
125
299
  else if (input && !key.ctrl && !key.meta) {
126
- setPromptBuffer((b) => b + input);
300
+ insertPromptText(input);
127
301
  }
128
302
  return;
129
303
  }
@@ -164,16 +338,31 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
164
338
  }
165
339
  else if (input === "p") {
166
340
  setPromptMode(true);
341
+ setPromptCursor(promptBuffer.length);
167
342
  }
168
343
  else if (input === "s") {
169
344
  if (state.activeDetailKey) {
170
- setPromptStatus("stopping...");
345
+ showPersistentStatus("stopping...");
171
346
  void postStop(baseUrl, state.activeDetailKey, bearerToken).then((result) => {
172
- setPromptStatus(result.ok ? "stop sent" : `stop failed: ${result.reason ?? "unknown"}`);
173
- setTimeout(() => setPromptStatus(null), 3000);
347
+ showStatus(result.ok ? "stop sent" : `stop failed: ${result.reason ?? "unknown"}`);
174
348
  });
175
349
  }
176
350
  }
351
+ else if (input === "y") {
352
+ handleCopyLastAssistant();
353
+ }
354
+ else if (input === "c") {
355
+ handleCopyLastCommand();
356
+ }
357
+ else if (input === "o") {
358
+ handleCopyLastCommandOutput();
359
+ }
360
+ else if (input === "e") {
361
+ handleExportTranscript();
362
+ }
363
+ else if (input === "v") {
364
+ handleOpenTranscriptInPager();
365
+ }
177
366
  else if (input === "h") {
178
367
  dispatch({ type: "switch-detail-tab", tab: "history" });
179
368
  }
@@ -206,7 +395,17 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
206
395
  }
207
396
  }
208
397
  });
209
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: 1 + ((promptMode || promptStatus) ? 1 : 0), onLayoutChange: (viewportRows, contentRows) => {
398
+ const reservedRows = 1 + (promptMode
399
+ ? measurePromptComposerRows(promptBuffer, promptCursor, width)
400
+ : promptStatus
401
+ ? measureRenderedTextRows(promptStatus, width)
402
+ : 0);
403
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
210
404
  dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
211
- } }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
405
+ } }), promptMode && (_jsx(PromptComposer, { buffer: promptBuffer, cursor: promptCursor })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
406
+ }
407
+ function PromptComposer({ buffer, cursor }) {
408
+ const withCursor = `${buffer.slice(0, cursor)}|${buffer.slice(cursor)}`;
409
+ const lines = withCursor.split("\n");
410
+ return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, index) => (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: index === 0 ? "prompt> " : " " }), _jsx(Text, { children: line })] }, `prompt-line-${index}`))), _jsx(Text, { dimColor: true, children: PROMPT_COMPOSER_HINT })] }));
212
411
  }
@@ -1,18 +1,27 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function HelpBar({ view, follow, detailTab }) {
4
- let text;
3
+ export function buildHelpBarText(view, follow, detailTab) {
5
4
  if (view === "detail") {
6
5
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
7
- text = [tabHint, "j/k: scroll", "Ctrl-U/Ctrl-D: page", "[ ]: issue", "Home/End: jump", `f: live ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry"]
6
+ return [
7
+ tabHint,
8
+ "j/k: scroll",
9
+ "Ctrl-U/Ctrl-D: page",
10
+ "[ ]: issue",
11
+ "Home/End: jump",
12
+ `f: live ${follow ? "on" : "off"}`,
13
+ "p: prompt",
14
+ "y/c/o: copy",
15
+ "v/e: transcript",
16
+ "s: stop",
17
+ "r: retry",
18
+ ]
8
19
  .filter(Boolean)
9
20
  .join(" ");
10
21
  }
11
- else if (view === "feed") {
12
- text = "Legacy feed view Esc: back";
13
- }
14
- else {
15
- text = "Enter: detail Tab: filter";
16
- }
22
+ return "Enter: detail Tab: filter";
23
+ }
24
+ export function HelpBar({ view, follow, detailTab }) {
25
+ const text = buildHelpBarText(view, follow, detailTab);
17
26
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
18
27
  }
@@ -1,19 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useReducer } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
- import { HelpBar } from "./HelpBar.js";
4
+ import { HelpBar, buildHelpBarText } from "./HelpBar.js";
5
5
  import { buildDetailLines } from "./detail-rows.js";
6
+ import { buildDetailStatusSegments, buildDetailStatusText } from "./detail-status.js";
7
+ import { measureRenderedTextRows } from "./layout-measure.js";
6
8
  export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, onLayoutChange, }) {
7
- const [, tick] = useReducer((value) => value + 1, 0);
8
9
  const { stdout } = useStdout();
9
10
  const width = Math.max(20, stdout?.columns ?? 80);
10
11
  const totalRows = stdout?.rows ?? 24;
11
- const footerRows = 1 + (unreadBelow > 0 ? 1 : 0);
12
+ const footerRows = useMemo(() => {
13
+ const statusRows = measureRenderedTextRows(buildDetailStatusText({
14
+ follow,
15
+ unreadBelow,
16
+ activeRunStartedAt,
17
+ connected,
18
+ lastServerMessageAt,
19
+ }), width);
20
+ const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab), width);
21
+ return statusRows + helpRows;
22
+ }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width]);
12
23
  const viewportRows = Math.max(4, totalRows - reservedRows - footerRows);
13
- useEffect(() => {
14
- const id = setInterval(tick, 1_000);
15
- return () => clearInterval(id);
16
- }, []);
17
24
  const lines = useMemo(() => {
18
25
  if (!issue) {
19
26
  return [{ key: "loading", segments: [{ text: "Loading issue…", dimColor: true }] }];
@@ -30,9 +37,6 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
30
37
  detailTab,
31
38
  rawRuns,
32
39
  rawFeedEvents,
33
- follow,
34
- connected,
35
- lastServerMessageAt,
36
40
  width,
37
41
  });
38
42
  }, [
@@ -47,9 +51,6 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
47
51
  detailTab,
48
52
  rawRuns,
49
53
  rawFeedEvents,
50
- follow,
51
- connected,
52
- lastServerMessageAt,
53
54
  width,
54
55
  ]);
55
56
  useEffect(() => {
@@ -59,7 +60,7 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
59
60
  const start = Math.min(scrollOffset, maxOffset);
60
61
  const visibleLines = lines.slice(start, start + viewportRows);
61
62
  const fillerCount = Math.max(0, viewportRows - visibleLines.length);
62
- return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), unreadBelow > 0 && (_jsx(Text, { color: "yellow", children: `${unreadBelow} below · End jumps back to live` })), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
63
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), _jsx(DetailStatusStrip, { follow: follow, unreadBelow: unreadBelow, activeRunStartedAt: activeRunStartedAt, connected: connected, lastServerMessageAt: lastServerMessageAt }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
63
64
  }
64
65
  function RenderedLine({ line }) {
65
66
  if (line.segments.length === 0) {
@@ -69,3 +70,20 @@ function RenderedLine({ line }) {
69
70
  // eslint-disable-next-line react/no-array-index-key
70
71
  , { ...(segment.color ? { color: segment.color } : {}), ...(segment.dimColor ? { dimColor: true } : {}), ...(segment.bold ? { bold: true } : {}), children: segment.text }, `${line.key}-${index}`))) }));
71
72
  }
73
+ function DetailStatusStrip({ follow, unreadBelow, activeRunStartedAt, connected, lastServerMessageAt, }) {
74
+ const [tick, bumpTick] = useReducer((value) => value + 1, 0);
75
+ useEffect(() => {
76
+ const id = setInterval(bumpTick, 1_000);
77
+ return () => clearInterval(id);
78
+ }, []);
79
+ const segments = useMemo(() => buildDetailStatusSegments({
80
+ follow,
81
+ unreadBelow,
82
+ activeRunStartedAt,
83
+ connected,
84
+ lastServerMessageAt,
85
+ }), [activeRunStartedAt, connected, follow, lastServerMessageAt, tick, unreadBelow]);
86
+ return (_jsx(Text, { children: segments.map((segment, index) => (_jsx(Text
87
+ // eslint-disable-next-line react/no-array-index-key
88
+ , { ...(segment.color ? { color: segment.color } : {}), ...(segment.dimColor ? { dimColor: true } : {}), ...(segment.bold ? { bold: true } : {}), children: segment.text }, `detail-status-${index}`))) }));
89
+ }
@@ -2,7 +2,6 @@ import { buildStateHistory } from "./history-builder.js";
2
2
  import { buildTimelineRows } from "./timeline-presentation.js";
3
3
  import { planStepColor, planStepSymbol } from "./plan-helpers.js";
4
4
  import { progressBar } from "./format-utils.js";
5
- import { describePatchRelayFreshness } from "./freshness.js";
6
5
  import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
7
6
  import { renderRichTextLines, renderTextLines } from "./render-rich-text.js";
8
7
  const SESSION_DISPLAY = {
@@ -85,20 +84,10 @@ function buildHeaderLines(input, width) {
85
84
  headerSegments.push({ text: " ", dimColor: true });
86
85
  headerSegments.push(...joinFactSegments(facts));
87
86
  }
88
- if (input.activeRunStartedAt) {
89
- headerSegments.push({ text: " ", dimColor: true });
90
- headerSegments.push({ text: elapsedLabel(input.activeRunStartedAt), dimColor: true });
91
- }
92
87
  if (meta.length > 0) {
93
88
  headerSegments.push({ text: " ", dimColor: true });
94
89
  headerSegments.push({ text: meta.join(" "), dimColor: true });
95
90
  }
96
- headerSegments.push({ text: " ", dimColor: true });
97
- headerSegments.push(...freshnessSegments(input.connected, input.lastServerMessageAt));
98
- if (input.follow) {
99
- headerSegments.push({ text: " " });
100
- headerSegments.push({ text: "live", color: "yellow", bold: true });
101
- }
102
91
  const lines = renderTextLines(segmentsToText(headerSegments), {
103
92
  key: "detail-header",
104
93
  width,
@@ -255,7 +244,7 @@ function renderTimelineItemLines(key, item, width, indent) {
255
244
  ? { color: "white" }
256
245
  : { dimColor: item.type !== "commandExecution" },
257
246
  });
258
- if (item.output && item.status === "inProgress") {
247
+ if (item.output && item.type === "commandExecution") {
259
248
  lines.push(...renderTextLines(lastNonEmptyLine(item.output), {
260
249
  key: `${key}-output`,
261
250
  width,
@@ -462,12 +451,6 @@ function formatDuration(startedAt, endedAt) {
462
451
  function formatTime(iso) {
463
452
  return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
464
453
  }
465
- function elapsedLabel(startedAt) {
466
- const elapsed = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
467
- const minutes = Math.floor(elapsed / 60);
468
- const seconds = elapsed % 60;
469
- return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
470
- }
471
454
  function itemSummary(item) {
472
455
  switch (item.type) {
473
456
  case "commandExecution": {
@@ -598,10 +581,6 @@ function feedGlyph(status) {
598
581
  return "✓";
599
582
  return "●";
600
583
  }
601
- function freshnessSegments(connected, lastServerMessageAt) {
602
- const freshness = describePatchRelayFreshness(connected, lastServerMessageAt);
603
- return [{ text: freshness.label, color: freshness.color, bold: true }];
604
- }
605
584
  function blankLine(key) {
606
585
  return { key, segments: [] };
607
586
  }
@@ -0,0 +1,38 @@
1
+ import { describePatchRelayFreshness } from "./freshness.js";
2
+ export function buildDetailStatusSegments(input, now = Date.now()) {
3
+ const groups = [];
4
+ groups.push(input.follow
5
+ ? [{ text: "live edge", color: "green", bold: true }]
6
+ : [{ text: "anchored review", color: "yellow", bold: true }]);
7
+ if (input.unreadBelow > 0) {
8
+ groups.push([{ text: `${input.unreadBelow} new below`, color: "yellow", bold: true }]);
9
+ }
10
+ if (input.activeRunStartedAt) {
11
+ groups.push([{ text: `run ${formatElapsed(input.activeRunStartedAt, now)}`, dimColor: true }]);
12
+ }
13
+ const freshness = describePatchRelayFreshness(input.connected, input.lastServerMessageAt, now);
14
+ groups.push([{ text: freshness.label, color: freshness.color, bold: true }]);
15
+ return joinGroups(groups);
16
+ }
17
+ export function buildDetailStatusText(input, now = Date.now()) {
18
+ return buildDetailStatusSegments(input, now).map((segment) => segment.text).join("");
19
+ }
20
+ function formatElapsed(startedAt, now) {
21
+ const startedMs = Date.parse(startedAt);
22
+ if (!Number.isFinite(startedMs))
23
+ return "0m 00s";
24
+ const elapsed = Math.max(0, Math.floor((now - startedMs) / 1000));
25
+ const minutes = Math.floor(elapsed / 60);
26
+ const seconds = elapsed % 60;
27
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
28
+ }
29
+ function joinGroups(groups) {
30
+ const segments = [];
31
+ for (const [index, group] of groups.entries()) {
32
+ if (index > 0) {
33
+ segments.push({ text: " ", dimColor: true });
34
+ }
35
+ segments.push(...group);
36
+ }
37
+ return segments;
38
+ }
@@ -0,0 +1,7 @@
1
+ import { renderTextLines } from "./render-rich-text.js";
2
+ export function measureRenderedTextRows(text, width) {
3
+ return renderTextLines(text, {
4
+ key: "measure",
5
+ width,
6
+ }).length;
7
+ }
@@ -0,0 +1,14 @@
1
+ import { measureRenderedTextRows } from "./layout-measure.js";
2
+ export const PROMPT_COMPOSER_HINT = "Enter: send Ctrl-N: newline Up/Down: history Esc: cancel";
3
+ export function buildPromptComposerDisplayLines(buffer, cursor) {
4
+ const withCursor = `${buffer.slice(0, cursor)}|${buffer.slice(cursor)}`;
5
+ const contentLines = withCursor.split("\n");
6
+ return [
7
+ ...contentLines.map((line, index) => `${index === 0 ? "prompt> " : " "}${line}`),
8
+ PROMPT_COMPOSER_HINT,
9
+ ];
10
+ }
11
+ export function measurePromptComposerRows(buffer, cursor, width) {
12
+ return buildPromptComposerDisplayLines(buffer, cursor)
13
+ .reduce((count, line) => count + measureRenderedTextRows(line, width), 0);
14
+ }
@@ -2,6 +2,7 @@ import { getThreadTurns } from "../../codex-thread-utils.js";
2
2
  // ─── Build Timeline from Rehydration Data ─────────────────────────
3
3
  export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId) {
4
4
  const entries = [];
5
+ const activeRun = activeRunId ? runs.find((run) => run.id === activeRunId) : undefined;
5
6
  // 1. Add run boundaries and items from reports
6
7
  for (const run of runs) {
7
8
  entries.push({
@@ -32,22 +33,28 @@ export function buildTimelineFromRehydration(runs, feedEvents, liveThread, activ
32
33
  }
33
34
  // 2. Items from live thread (active run)
34
35
  if (liveThread && activeRunId) {
35
- entries.push(...itemsFromThread(activeRunId, liveThread));
36
+ entries.push(...itemsFromThread(activeRunId, liveThread, activeRun?.startedAt));
36
37
  }
37
38
  // 3. Feed events → feed entries + CI check aggregation
38
39
  entries.push(...feedEventsToEntries(feedEvents));
39
40
  // 4. Sort by timestamp, then by entry order for stability
40
- entries.sort((a, b) => {
41
- const cmp = a.at.localeCompare(b.at);
42
- if (cmp !== 0)
43
- return cmp;
44
- // Within same timestamp: run-start before items, items before run-end
45
- const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
46
- if (kindCmp !== 0)
47
- return kindCmp;
48
- return a.id.localeCompare(b.id);
41
+ return sortTimelineEntries(entries);
42
+ }
43
+ export function reconcileTimelineFromRehydration(previousTimeline, runs, feedEvents, liveThread, activeRunId) {
44
+ const rehydrated = buildTimelineFromRehydration(runs, feedEvents, liveThread, activeRunId);
45
+ if (previousTimeline.length === 0) {
46
+ return rehydrated;
47
+ }
48
+ const previousById = new Map(previousTimeline.map((entry) => [entry.id, entry]));
49
+ const rehydratedIds = new Set(rehydrated.map((entry) => entry.id));
50
+ const liveUserMessages = collectUserMessageCounts(rehydrated, activeRunId);
51
+ const merged = rehydrated.map((entry) => mergeTimelineEntry(previousById.get(entry.id), entry));
52
+ const carriedForward = previousTimeline.filter((entry) => {
53
+ if (rehydratedIds.has(entry.id))
54
+ return false;
55
+ return shouldCarryForwardEntry(entry, activeRunId, liveUserMessages);
49
56
  });
50
- return entries;
57
+ return sortTimelineEntries([...merged, ...carriedForward]);
51
58
  }
52
59
  function kindOrder(kind) {
53
60
  switch (kind) {
@@ -131,27 +138,37 @@ function syntheticTimestamp(startMs, endMs, index, total) {
131
138
  return new Date(startMs + fraction * (endMs - startMs)).toISOString();
132
139
  }
133
140
  // ─── Items from Live Thread ───────────────────────────────────────
134
- function itemsFromThread(runId, thread) {
141
+ function itemsFromThread(runId, thread, runStartedAt) {
135
142
  const entries = [];
143
+ let itemIndex = 0;
136
144
  for (const turn of getThreadTurns(thread)) {
137
145
  for (const item of turn.items) {
138
146
  entries.push({
139
147
  id: `live-${item.id}`,
140
- at: new Date().toISOString(), // live items don't have timestamps; they'll sort to the end
148
+ at: liveItemTimestamp(runStartedAt, itemIndex),
141
149
  kind: "item",
142
150
  runId,
143
151
  item: materializeItem(item),
144
152
  });
153
+ itemIndex += 1;
145
154
  }
146
155
  }
147
156
  return entries;
148
157
  }
158
+ const LIVE_ITEM_FALLBACK_START_MS = Date.UTC(9999, 0, 1, 0, 0, 0, 0);
159
+ function liveItemTimestamp(runStartedAt, itemIndex) {
160
+ const baseMs = runStartedAt ? Date.parse(runStartedAt) : LIVE_ITEM_FALLBACK_START_MS;
161
+ const stableBaseMs = Number.isFinite(baseMs) ? baseMs : LIVE_ITEM_FALLBACK_START_MS;
162
+ return new Date(stableBaseMs + itemIndex).toISOString();
163
+ }
149
164
  function materializeItem(item) {
150
165
  const r = item;
151
166
  const id = String(r.id ?? "unknown");
152
167
  const type = String(r.type ?? "unknown");
153
168
  const base = { id, type, status: "completed" };
154
169
  switch (type) {
170
+ case "userMessage":
171
+ return { ...base, text: extractUserMessageText(r.content) };
155
172
  case "agentMessage":
156
173
  return { ...base, text: String(r.text ?? "") };
157
174
  case "commandExecution":
@@ -340,16 +357,150 @@ function mergeDefinedItemFields(base, patch) {
340
357
  ...base,
341
358
  id: patch.id,
342
359
  type: patch.type,
343
- status: patch.status,
344
- ...(patch.text !== undefined ? { text: patch.text } : {}),
360
+ status: preferredItemStatus(base.status, patch.status),
361
+ ...(mergePreferredString(base.text, patch.text) !== undefined ? { text: mergePreferredString(base.text, patch.text) } : {}),
345
362
  ...(patch.command !== undefined ? { command: patch.command } : {}),
346
- ...(patch.output !== undefined ? { output: patch.output } : {}),
363
+ ...(mergePreferredString(base.output, patch.output) !== undefined ? { output: mergePreferredString(base.output, patch.output) } : {}),
347
364
  ...(patch.exitCode !== undefined ? { exitCode: patch.exitCode } : {}),
348
- ...(patch.durationMs !== undefined ? { durationMs: patch.durationMs } : {}),
349
- ...(patch.changes !== undefined ? { changes: patch.changes } : {}),
365
+ ...(patch.durationMs !== undefined || base.durationMs !== undefined
366
+ ? { durationMs: preferredNumber(base.durationMs, patch.durationMs) }
367
+ : {}),
368
+ ...(patch.changes !== undefined || base.changes !== undefined
369
+ ? { changes: preferredChanges(base.changes, patch.changes) }
370
+ : {}),
350
371
  ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}),
351
372
  };
352
373
  }
374
+ function mergeTimelineEntry(existing, incoming) {
375
+ if (!existing || existing.kind !== incoming.kind) {
376
+ return incoming;
377
+ }
378
+ switch (incoming.kind) {
379
+ case "item":
380
+ return {
381
+ ...incoming,
382
+ at: existing.at,
383
+ ...(existing.runId !== undefined && incoming.runId === undefined ? { runId: existing.runId } : {}),
384
+ item: incoming.item && existing.item ? mergeDefinedItemFields(existing.item, incoming.item) : incoming.item,
385
+ };
386
+ case "run-start":
387
+ case "run-end":
388
+ return {
389
+ ...incoming,
390
+ at: existing.at,
391
+ run: existing.run && incoming.run ? { ...existing.run, ...incoming.run } : incoming.run,
392
+ };
393
+ case "feed":
394
+ return {
395
+ ...incoming,
396
+ at: existing.at,
397
+ feed: existing.feed && incoming.feed ? { ...existing.feed, ...incoming.feed } : incoming.feed,
398
+ };
399
+ case "ci-checks":
400
+ return {
401
+ ...incoming,
402
+ at: existing.at,
403
+ ciChecks: incoming.ciChecks ?? existing.ciChecks,
404
+ };
405
+ }
406
+ }
407
+ function shouldCarryForwardEntry(entry, activeRunId, liveUserMessages) {
408
+ if (entry.kind !== "item" || entry.runId !== activeRunId) {
409
+ return false;
410
+ }
411
+ if (entry.item?.id.startsWith("prompt-") === true) {
412
+ const text = normalizePromptText(entry.item.text);
413
+ return !text || !consumeUserMessageMatch(liveUserMessages, text);
414
+ }
415
+ return entry.item?.status === "inProgress";
416
+ }
417
+ function sortTimelineEntries(entries) {
418
+ return [...entries].sort((a, b) => {
419
+ const cmp = a.at.localeCompare(b.at);
420
+ if (cmp !== 0)
421
+ return cmp;
422
+ // Within same timestamp: run-start before items, items before run-end
423
+ const kindCmp = kindOrder(a.kind) - kindOrder(b.kind);
424
+ if (kindCmp !== 0)
425
+ return kindCmp;
426
+ return a.id.localeCompare(b.id);
427
+ });
428
+ }
429
+ function preferredItemStatus(existing, incoming) {
430
+ return itemStatusRank(incoming) >= itemStatusRank(existing) ? incoming : existing;
431
+ }
432
+ function itemStatusRank(status) {
433
+ switch (status) {
434
+ case "failed":
435
+ case "completed":
436
+ case "declined":
437
+ return 2;
438
+ case "inProgress":
439
+ return 1;
440
+ default:
441
+ return 0;
442
+ }
443
+ }
444
+ function mergePreferredString(existing, incoming) {
445
+ if (incoming === undefined)
446
+ return existing;
447
+ if (existing === undefined)
448
+ return incoming;
449
+ return incoming.length >= existing.length ? incoming : existing;
450
+ }
451
+ function preferredNumber(existing, incoming) {
452
+ return incoming ?? existing;
453
+ }
454
+ function preferredChanges(existing, incoming) {
455
+ if (incoming === undefined)
456
+ return existing;
457
+ if (existing === undefined)
458
+ return incoming;
459
+ return incoming.length >= existing.length ? incoming : existing;
460
+ }
461
+ function collectUserMessageCounts(entries, activeRunId) {
462
+ const texts = new Map();
463
+ for (const entry of entries) {
464
+ if (entry.kind !== "item" || entry.runId !== activeRunId || entry.item?.type !== "userMessage") {
465
+ continue;
466
+ }
467
+ const text = normalizePromptText(entry.item.text);
468
+ if (text) {
469
+ texts.set(text, (texts.get(text) ?? 0) + 1);
470
+ }
471
+ }
472
+ return texts;
473
+ }
474
+ function consumeUserMessageMatch(messages, text) {
475
+ const count = messages.get(text) ?? 0;
476
+ if (count <= 0) {
477
+ return false;
478
+ }
479
+ if (count === 1) {
480
+ messages.delete(text);
481
+ }
482
+ else {
483
+ messages.set(text, count - 1);
484
+ }
485
+ return true;
486
+ }
487
+ function normalizePromptText(text) {
488
+ const normalized = text?.trim();
489
+ return normalized && normalized.length > 0 ? normalized : null;
490
+ }
491
+ function extractUserMessageText(content) {
492
+ if (!Array.isArray(content))
493
+ return "";
494
+ return content
495
+ .map((entry) => {
496
+ if (!entry || typeof entry !== "object")
497
+ return undefined;
498
+ const value = entry.text;
499
+ return typeof value === "string" ? value : undefined;
500
+ })
501
+ .filter((value) => Boolean(value))
502
+ .join("\n\n");
503
+ }
353
504
  // ─── Feed Events to Timeline Entries ──────────────────────────────
354
505
  function feedEventsToEntries(feedEvents) {
355
506
  const entries = [];
@@ -76,6 +76,7 @@ function buildCompactTimelineRows(entries) {
76
76
  }
77
77
  for (const run of runs.values()) {
78
78
  const status = resolveCompactRunStatus(run.run, run.items);
79
+ const verboseItems = status === "running" ? selectVerboseItems(run.items) : run.items;
79
80
  rows.push({
80
81
  id: run.id,
81
82
  kind: "run",
@@ -83,7 +84,7 @@ function buildCompactTimelineRows(entries) {
83
84
  finalized: status !== "running",
84
85
  run: { ...run.run, status, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
85
86
  details: summarizeRunDetails(run.items),
86
- items: run.items.map((item) => ({ at: run.at, item })),
87
+ items: verboseItems.map((item) => ({ at: run.at, item })),
87
88
  });
88
89
  }
89
90
  rows.sort((left, right) => {
@@ -167,6 +168,25 @@ function summarizeRunDetails(items) {
167
168
  }
168
169
  return dedupeDetails(details).slice(0, 3);
169
170
  }
171
+ function selectVerboseItems(items) {
172
+ const latestAssistantMessage = findLatest(items, (item) => item.type === "agentMessage" && Boolean(item.text?.trim()));
173
+ const latestUserMessage = !latestAssistantMessage
174
+ ? findLatest(items, (item) => item.type === "userMessage" && Boolean(item.text?.trim()))
175
+ : undefined;
176
+ const activeCommand = findLatest(items, (item) => item.type === "commandExecution" && item.status === "inProgress");
177
+ const latestCommandWithOutput = findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.output?.trim()));
178
+ const latestCommand = activeCommand
179
+ ?? latestCommandWithOutput
180
+ ?? findLatest(items, (item) => item.type === "commandExecution" && Boolean(item.command?.trim()));
181
+ const latestFileChange = findLatest(items, (item) => item.type === "fileChange" && Array.isArray(item.changes) && item.changes.length > 0);
182
+ const latestToolCall = !latestFileChange
183
+ ? findLatest(items, (item) => item.type === "mcpToolCall" || item.type === "dynamicToolCall")
184
+ : undefined;
185
+ const selectedIds = new Set([latestUserMessage, latestAssistantMessage, latestCommand, latestFileChange, latestToolCall]
186
+ .filter((item) => Boolean(item))
187
+ .map((item) => item.id));
188
+ return items.filter((item) => selectedIds.has(item.id));
189
+ }
170
190
  function summarizeNarrative(input) {
171
191
  const normalized = input
172
192
  .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
@@ -0,0 +1,28 @@
1
+ export const defaultTimerApi = {
2
+ setTimeout(callback, delayMs) {
3
+ return setTimeout(callback, delayMs);
4
+ },
5
+ clearTimeout(timer) {
6
+ clearTimeout(timer);
7
+ },
8
+ };
9
+ export function showTransientStatus(controller, message, setStatus, timers, delayMs = 3_000) {
10
+ setStatus(message);
11
+ if (controller.timer !== null) {
12
+ timers.clearTimeout(controller.timer);
13
+ }
14
+ controller.timer = timers.setTimeout(() => {
15
+ controller.timer = null;
16
+ setStatus(null);
17
+ }, delayMs);
18
+ }
19
+ export function setPersistentStatus(controller, message, setStatus, timers) {
20
+ clearTransientStatus(controller, timers);
21
+ setStatus(message);
22
+ }
23
+ export function clearTransientStatus(controller, timers) {
24
+ if (controller.timer !== null) {
25
+ timers.clearTimeout(controller.timer);
26
+ controller.timer = null;
27
+ }
28
+ }
@@ -0,0 +1,76 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { buildDetailLines } from "./detail-rows.js";
6
+ import { lineToPlainText } from "./render-rich-text.js";
7
+ export function findLastAssistantMessage(timeline) {
8
+ return findLastItemField(timeline, (entry) => entry.item?.type === "agentMessage", "text");
9
+ }
10
+ export function findLastCommand(timeline) {
11
+ return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution", "command");
12
+ }
13
+ export function findLastCommandOutput(timeline) {
14
+ return findLastItemField(timeline, (entry) => entry.item?.type === "commandExecution" && Boolean(entry.item?.output?.trim()), "output");
15
+ }
16
+ export function buildWatchDetailExportText(input) {
17
+ const lines = buildDetailLines({
18
+ ...input,
19
+ width: input.width ?? 100,
20
+ });
21
+ return `${lines.map(lineToPlainText).join("\n").trimEnd()}\n`;
22
+ }
23
+ export function writeTextToClipboard(text, stream = process.stderr) {
24
+ if (!stream.isTTY || text.length === 0) {
25
+ return false;
26
+ }
27
+ const encoded = Buffer.from(text, "utf8").toString("base64");
28
+ stream.write(`\u001b]52;c;${encoded}\u0007`);
29
+ return true;
30
+ }
31
+ export function exportWatchTextToTempFile(text, issueKey) {
32
+ const directory = mkdtempSync(join(tmpdir(), "patchrelay-watch-"));
33
+ const safeIssueKey = issueKey.replace(/[^a-zA-Z0-9._-]+/g, "-");
34
+ const filePath = join(directory, `${safeIssueKey || "issue"}-transcript.txt`);
35
+ writeFileSync(filePath, text, "utf8");
36
+ return filePath;
37
+ }
38
+ export function openTextInPager(text, stream = process.stderr) {
39
+ if (!stream.isTTY) {
40
+ return { ok: false, reason: "interactive TTY required" };
41
+ }
42
+ const streamWithFd = stream;
43
+ if (typeof streamWithFd.fd !== "number") {
44
+ return { ok: false, reason: "TTY stream fd unavailable" };
45
+ }
46
+ const pagerCommand = process.env.PAGER?.trim() || "less -R";
47
+ stream.write("\u001b[?1049l");
48
+ try {
49
+ const result = spawnSync("/bin/sh", ["-lc", pagerCommand], {
50
+ input: text,
51
+ stdio: ["pipe", streamWithFd.fd, streamWithFd.fd],
52
+ });
53
+ if (result.error) {
54
+ return { ok: false, reason: result.error.message };
55
+ }
56
+ if (typeof result.status === "number" && result.status !== 0) {
57
+ return { ok: false, reason: `${pagerCommand} exited with status ${result.status}` };
58
+ }
59
+ return { ok: true };
60
+ }
61
+ finally {
62
+ stream.write("\u001b[?1049h\u001b[2J\u001b[H");
63
+ }
64
+ }
65
+ function findLastItemField(timeline, predicate, field) {
66
+ for (let index = timeline.length - 1; index >= 0; index -= 1) {
67
+ const entry = timeline[index];
68
+ if (!predicate(entry))
69
+ continue;
70
+ const value = entry.item?.[field];
71
+ if (typeof value === "string" && value.trim().length > 0) {
72
+ return value;
73
+ }
74
+ }
75
+ return null;
76
+ }
@@ -1,8 +1,7 @@
1
- import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
1
+ import { reconcileTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
2
  // ─── Array size caps (prevent OOM) ───────────────────────────────
3
3
  const MAX_TIMELINE_ENTRIES = 2000;
4
4
  const MAX_RAW_FEED_EVENTS = 2000;
5
- const MAX_FEED_EVENTS = 1000;
6
5
  function capArray(arr, max) {
7
6
  return arr.length > max ? arr.slice(arr.length - max) : arr;
8
7
  }
@@ -32,7 +31,6 @@ export const initialWatchState = {
32
31
  filter: "non-done",
33
32
  follow: true,
34
33
  ...DETAIL_INITIAL,
35
- feedEvents: [],
36
34
  };
37
35
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
38
36
  function effectiveSessionState(issue) {
@@ -216,7 +214,7 @@ export function watchReducer(state, action) {
216
214
  };
217
215
  }
218
216
  case "timeline-rehydrate": {
219
- const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
217
+ const timeline = reconcileTimelineFromRehydration(state.timeline, action.runs, action.feedEvents, action.liveThread, action.activeRunId);
220
218
  const activeRun = action.runs.find((r) => r.id === action.activeRunId);
221
219
  return {
222
220
  ...state,
@@ -243,14 +241,6 @@ export function watchReducer(state, action) {
243
241
  ...state,
244
242
  ...detailStateForPosition(state, maxDetailScrollOffset(state.detailContentRows, state.detailViewportRows), true),
245
243
  };
246
- case "enter-feed":
247
- return { ...state, view: "feed", activeDetailKey: null, ...DETAIL_INITIAL };
248
- case "exit-feed":
249
- return { ...state, view: "list" };
250
- case "feed-snapshot":
251
- return { ...state, feedEvents: action.events };
252
- case "feed-new-event":
253
- return { ...state, feedEvents: capArray([...state.feedEvents, action.event], MAX_FEED_EVENTS) };
254
244
  case "switch-detail-tab":
255
245
  return { ...state, follow: true, ...DETAIL_INITIAL, detailTab: action.tab };
256
246
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.19",
3
+ "version": "0.37.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,80 +0,0 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- function cleanCommand(raw) {
4
- const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
5
- if (bashMatch?.[1])
6
- return bashMatch[1];
7
- const bashMatch2 = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+"(.+?)"$/s);
8
- if (bashMatch2?.[1])
9
- return bashMatch2[1];
10
- return raw;
11
- }
12
- function summarizeFileChange(item) {
13
- const count = item.changes?.length ?? 0;
14
- return `updated ${count} file${count === 1 ? "" : "s"}`;
15
- }
16
- function summarizeToolCall(item) {
17
- return `used ${item.toolName ?? item.type}`;
18
- }
19
- function summarizeText(item) {
20
- return (item.text ?? "").replace(/\s+/g, " ").trim();
21
- }
22
- function itemPrefix(item) {
23
- if (item.type === "commandExecution")
24
- return "$ ";
25
- return "";
26
- }
27
- function formatItemDuration(ms) {
28
- if (ms === undefined || ms === null)
29
- return "";
30
- const seconds = Math.floor(ms / 1000);
31
- if (seconds < 1)
32
- return "";
33
- if (seconds < 60)
34
- return ` ${seconds}s`;
35
- const minutes = Math.floor(seconds / 60);
36
- return ` ${minutes}m`;
37
- }
38
- function itemText(item) {
39
- switch (item.type) {
40
- case "agentMessage":
41
- case "plan":
42
- case "reasoning":
43
- return summarizeText(item);
44
- case "commandExecution": {
45
- const cmd = cleanCommand(item.command ?? "?");
46
- const exit = item.exitCode !== undefined && item.exitCode !== null && item.exitCode !== 0
47
- ? ` exit ${item.exitCode}` : "";
48
- const dur = formatItemDuration(item.durationMs);
49
- return `${cmd}${exit}${dur}`;
50
- }
51
- case "fileChange":
52
- return summarizeFileChange(item);
53
- case "mcpToolCall":
54
- case "dynamicToolCall": {
55
- const dur = formatItemDuration(item.durationMs);
56
- return `${summarizeToolCall(item)}${dur}`;
57
- }
58
- case "userMessage":
59
- return `you: ${summarizeText(item)}`;
60
- default:
61
- return item.text ? summarizeText(item) : item.type;
62
- }
63
- }
64
- function itemColor(item) {
65
- if (item.status === "failed" || item.status === "declined")
66
- return "red";
67
- if (item.status === "inProgress")
68
- return "yellow";
69
- if (item.type === "userMessage")
70
- return "yellow";
71
- return undefined;
72
- }
73
- export function ItemLine({ item }) {
74
- const text = itemText(item);
75
- if (!text) {
76
- return _jsx(_Fragment, {});
77
- }
78
- const color = itemColor(item);
79
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { wrap: "wrap", bold: item.type === "agentMessage", ...(color ? { color } : {}), children: [itemPrefix(item), text] }), item.output && item.status === "inProgress" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: item.output.split("\n").filter(Boolean).at(-1) ?? "" }) }))] }));
80
- }
@@ -1,22 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
- import { Box, Static, Text, useStdout } from "ink";
4
- import { buildTimelineRows } from "./timeline-presentation.js";
5
- import { TimelineRow } from "./TimelineRow.js";
6
- const ACTIVE_TAIL = 8;
7
- export function Timeline({ entries, follow }) {
8
- const { stdout } = useStdout();
9
- const rows = stdout?.rows ?? 24;
10
- const maxActive = Math.max(ACTIVE_TAIL, rows - 12);
11
- const displayRows = useMemo(() => buildTimelineRows(entries), [entries]);
12
- // Always cap the rendered entries to prevent OOM/WASM crashes.
13
- // In follow mode: older entries go to Static (terminal scrollback).
14
- // Without follow: show last maxActive entries only.
15
- const splitIndex = Math.max(0, displayRows.length - maxActive);
16
- const finalized = follow ? displayRows.slice(0, splitIndex) : [];
17
- const active = displayRows.slice(splitIndex);
18
- if (displayRows.length === 0) {
19
- return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
20
- }
21
- return (_jsxs(Box, { flexDirection: "column", children: [finalized.length > 0 && (_jsx(Static, { items: finalized, children: (entry) => _jsx(TimelineRow, { entry: entry }, entry.id) })), active.map((entry) => (_jsx(TimelineRow, { entry: entry }, entry.id)))] }));
22
- }
@@ -1,77 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { ItemLine } from "./ItemLine.js";
4
- function formatDuration(startedAt, endedAt) {
5
- const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
6
- const seconds = Math.floor(ms / 1000);
7
- if (seconds < 60)
8
- return `${seconds}s`;
9
- const minutes = Math.floor(seconds / 60);
10
- const s = seconds % 60;
11
- return `${minutes}m ${String(s).padStart(2, "0")}s`;
12
- }
13
- const CHECK_SYMBOLS = { passed: "\u2713", failed: "\u2717", pending: "\u25cf" };
14
- const CHECK_COLORS = { passed: "green", failed: "red", pending: "yellow" };
15
- const RUN_LABELS = {
16
- implementation: "implement",
17
- ci_repair: "ci fix",
18
- review_fix: "review fix",
19
- branch_upkeep: "branch upkeep",
20
- queue_repair: "merge fix",
21
- };
22
- function runDotColor(status) {
23
- if (status === "completed")
24
- return "green";
25
- if (status === "failed")
26
- return "red";
27
- if (status === "released")
28
- return "magenta";
29
- if (status === "running")
30
- return "yellow";
31
- return "white";
32
- }
33
- function detailColor(detail) {
34
- if (detail.tone === "command")
35
- return "white";
36
- if (detail.tone === "user")
37
- return "yellow";
38
- return undefined;
39
- }
40
- function detailPrefix(detail) {
41
- if (detail.tone === "command")
42
- return "$ ";
43
- return "";
44
- }
45
- function FeedRow({ entry }) {
46
- const label = entry.feed.status ?? entry.feed.feedKind;
47
- const repeatSuffix = entry.repeatCount && entry.repeatCount > 1 ? ` \u00d7${entry.repeatCount}` : "";
48
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u25cf" }), _jsx(Text, { color: "cyan", children: ` ${label}` }), _jsx(Text, { dimColor: true, children: ` ${entry.feed.summary}${repeatSuffix}` })] }));
49
- }
50
- function RunRow({ entry, }) {
51
- const run = entry.run;
52
- const dotColor = runDotColor(run.status);
53
- const duration = run.endedAt ? formatDuration(run.startedAt, run.endedAt) : undefined;
54
- const showItems = entry.items.length > 0;
55
- const showDetails = !showItems && entry.details.length > 0;
56
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: dotColor, children: "\u25cf" }), _jsx(Text, { bold: true, color: "yellow", children: ` ${RUN_LABELS[run.runType] ?? run.runType}` }), _jsx(Text, { bold: true, color: dotColor, children: ` ${run.status}` }), duration ? _jsx(Text, { dimColor: true, children: ` ${duration}` }) : null] }), showItems && entry.items.map((itemEntry, index) => (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: itemEntry.item }) }, `${entry.id}-item-${index}`))), showDetails && entry.details.map((detail, index) => (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { wrap: "wrap", ...(detailColor(detail) ? { color: detailColor(detail) } : { dimColor: true }), bold: detail.tone === "message", children: [detailPrefix(detail), detail.text] }) }, `${entry.id}-detail-${index}`)))] }));
57
- }
58
- function ItemRow({ entry, }) {
59
- return (_jsx(Box, { paddingLeft: 2, children: _jsx(ItemLine, { item: entry.item }) }));
60
- }
61
- function CIChecksRow({ entry }) {
62
- const ci = entry.ciChecks;
63
- const dotColor = CHECK_COLORS[ci.overall] ?? "white";
64
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: dotColor, children: "\u25cf" }), _jsx(Text, { color: dotColor, bold: true, children: ` checks` }), _jsx(Text, { children: ` ` }), ci.checks.map((check, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { children: ` ` }) : null, _jsx(Text, { color: CHECK_COLORS[check.status] ?? "white", children: CHECK_SYMBOLS[check.status] ?? " " }), _jsx(Text, { dimColor: true, children: ` ${check.name}` })] }, `c-${i}`)))] }));
65
- }
66
- export function TimelineRow({ entry }) {
67
- switch (entry.kind) {
68
- case "feed":
69
- return _jsx(FeedRow, { entry: entry });
70
- case "run":
71
- return _jsx(RunRow, { entry: entry });
72
- case "item":
73
- return _jsx(ItemRow, { entry: entry });
74
- case "ci-checks":
75
- return _jsx(CIChecksRow, { entry: entry });
76
- }
77
- }