patchrelay 0.36.18 → 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.18",
4
- "commit": "2d0aa4ca856e",
5
- "builtAt": "2026-04-10T12:35:59.590Z"
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
+ }