patchrelay 0.36.19 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/cluster-health.js +25 -22
  3. package/dist/cli/data.js +1 -0
  4. package/dist/cli/formatters/text.js +5 -1
  5. package/dist/cli/watch/App.js +226 -27
  6. package/dist/cli/watch/HelpBar.js +18 -9
  7. package/dist/cli/watch/IssueDetailView.js +32 -14
  8. package/dist/cli/watch/IssueRow.js +4 -3
  9. package/dist/cli/watch/StatusBar.js +2 -1
  10. package/dist/cli/watch/detail-rows.js +5 -25
  11. package/dist/cli/watch/detail-status.js +38 -0
  12. package/dist/cli/watch/layout-measure.js +7 -0
  13. package/dist/cli/watch/pr-status.js +2 -1
  14. package/dist/cli/watch/prompt-layout.js +14 -0
  15. package/dist/cli/watch/state-visualization.js +5 -1
  16. package/dist/cli/watch/timeline-builder.js +169 -18
  17. package/dist/cli/watch/timeline-presentation.js +21 -1
  18. package/dist/cli/watch/transient-status.js +28 -0
  19. package/dist/cli/watch/watch-actions.js +76 -0
  20. package/dist/cli/watch/watch-state.js +2 -12
  21. package/dist/factory-state.js +1 -1
  22. package/dist/github-webhook-handler.js +26 -4
  23. package/dist/idle-reconciliation.js +19 -2
  24. package/dist/implementation-outcome-policy.js +3 -1
  25. package/dist/issue-overview-query.js +5 -0
  26. package/dist/linear-session-reporting.js +15 -6
  27. package/dist/linear-status-comment-sync.js +13 -1
  28. package/dist/pr-state.js +49 -0
  29. package/dist/service-issue-actions.js +5 -4
  30. package/dist/tracked-issue-list-query.js +3 -1
  31. package/dist/tracked-issue-projector.js +5 -0
  32. package/dist/waiting-reason.js +3 -2
  33. package/package.json +1 -1
  34. package/dist/cli/watch/ItemLine.js +0 -80
  35. package/dist/cli/watch/Timeline.js +0 -22
  36. package/dist/cli/watch/TimelineRow.js +0 -77
@@ -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.1",
4
+ "commit": "e2c1f3d45497",
5
+ "builtAt": "2026-04-10T17:31:17.533Z"
6
6
  }
@@ -1,5 +1,6 @@
1
1
  import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
2
2
  import { ACTIVE_RUN_STATES } from "../factory-state.js";
3
+ import { hasOpenPr, resolveClosedPrDisposition } from "../pr-state.js";
3
4
  const RECONCILIATION_GRACE_MS = 120_000;
4
5
  const DOWNSTREAM_STALE_MS = 900_000;
5
6
  export async function collectClusterHealth(config, db, runCommand) {
@@ -98,7 +99,7 @@ export async function collectClusterHealth(config, db, runCommand) {
98
99
  }
99
100
  checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
100
101
  for (const snapshot of snapshots) {
101
- if (!snapshot.issue.prNumber) {
102
+ if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
102
103
  continue;
103
104
  }
104
105
  const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
@@ -277,19 +278,8 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
277
278
  const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
278
279
  const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
279
280
  const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
280
- const ciEntry = buildCiEntry({
281
- issue,
282
- gateCheckStatus,
283
- reviewDecision,
284
- reviewRequested,
285
- currentHeadSha: pr.headRefOid,
286
- latestBlockingReviewHeadSha,
287
- mergeConflictDetected,
288
- reviewQuillAttempt,
289
- });
290
281
  if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
291
282
  return {
292
- ciEntry,
293
283
  finding: {
294
284
  status: "fail",
295
285
  scope: "github:reconcile",
@@ -297,16 +287,29 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
297
287
  },
298
288
  };
299
289
  }
300
- if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
301
- return {
302
- ciEntry,
303
- finding: {
304
- status: "fail",
305
- scope: "github:reconcile",
306
- message: "PR is closed but the issue is still waiting on PR state",
307
- },
308
- };
290
+ if (pr.state === "CLOSED") {
291
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
292
+ if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
293
+ return {
294
+ finding: {
295
+ status: "fail",
296
+ scope: "github:reconcile",
297
+ message: "PR is closed but unfinished work has not been re-delegated",
298
+ },
299
+ };
300
+ }
301
+ return {};
309
302
  }
303
+ const ciEntry = buildCiEntry({
304
+ issue,
305
+ gateCheckStatus,
306
+ reviewDecision,
307
+ reviewRequested,
308
+ currentHeadSha: pr.headRefOid,
309
+ latestBlockingReviewHeadSha,
310
+ mergeConflictDetected,
311
+ reviewQuillAttempt,
312
+ });
310
313
  if (gateCheckStatus === "failure" && issue.factoryState !== "repairing_ci" && issue.activeRunId === undefined && ageMs >= RECONCILIATION_GRACE_MS) {
311
314
  return {
312
315
  ciEntry,
@@ -521,7 +524,7 @@ function needsReviewAutomation(issue) {
521
524
  if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
522
525
  return false;
523
526
  }
524
- return issue.prNumber !== undefined;
527
+ return hasOpenPr(issue.prNumber, issue.prState);
525
528
  }
526
529
  async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
527
530
  const owners = new Map();
package/dist/cli/data.js CHANGED
@@ -119,6 +119,7 @@ export class CliDataAccess extends CliOperatorApiClient {
119
119
  ...(latestReport ? { latestReport } : {}),
120
120
  ...(latestSummary ? { latestSummary } : {}),
121
121
  ...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
122
+ ...(dbIssue.prState ? { prState: dbIssue.prState } : {}),
122
123
  ...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
123
124
  ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
124
125
  ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
@@ -20,7 +20,11 @@ export function formatInspect(result) {
20
20
  value("Debug stage", result.issue?.factoryState),
21
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
22
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
23
- result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
23
+ result.prNumber
24
+ ? value("PR", `#${result.prNumber}${result.prState || result.prReviewState
25
+ ? ` [${[result.prState, result.prReviewState].filter(Boolean).join(", ")}]`
26
+ : ""}`)
27
+ : undefined,
24
28
  result.completionCheckOutcome ? value("Completion check", result.completionCheckOutcome) : undefined,
25
29
  result.completionCheckSummary ? value("Completion summary", truncateLine(result.completionCheckSummary)) : undefined,
26
30
  result.completionCheckQuestion ? value("Question", truncateLine(result.completionCheckQuestion)) : undefined,
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
5
  import { relativeTime, truncate } from "./format-utils.js";
5
6
  import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
@@ -19,7 +20,7 @@ function effectiveState(issue) {
19
20
  return "blocked";
20
21
  if (issue.sessionState === "waiting_input")
21
22
  return "awaiting_input";
22
- if (issue.prNumber !== undefined)
23
+ if (hasOpenPr(issue.prNumber, issue.prState))
23
24
  return issue.factoryState;
24
25
  if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
25
26
  return "ready";
@@ -94,7 +95,7 @@ function buildFacts(issue, selected) {
94
95
  else if (isChangesRequestedReviewState(issue.prReviewState)) {
95
96
  facts.push({ text: "changes requested", color: "yellow" });
96
97
  }
97
- else if (issue.prNumber !== undefined
98
+ else if (hasOpenPr(issue.prNumber, issue.prState)
98
99
  && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
99
100
  facts.push({ text: "awaiting review", color: "yellow" });
100
101
  }
@@ -146,7 +147,7 @@ function blockerText(issue) {
146
147
  return "Awaiting re-review after requested changes";
147
148
  if (isChangesRequestedReviewState(issue.prReviewState))
148
149
  return "Review changes requested";
149
- if (issue.prNumber !== undefined && isAwaitingReviewState(issue.prReviewState))
150
+ if (hasOpenPr(issue.prNumber, issue.prState) && isAwaitingReviewState(issue.prReviewState))
150
151
  return "Awaiting review";
151
152
  return null;
152
153
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { hasOpenPr } from "../../pr-state.js";
3
4
  import { computeAggregates } from "./watch-state.js";
4
5
  import { FreshnessBadge } from "./FreshnessBadge.js";
5
6
  const FILTER_LABELS = {
@@ -11,7 +12,7 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
11
12
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
13
  const aggregateSource = filter === "all" ? allIssues : issues;
13
14
  const agg = computeAggregates(aggregateSource);
14
- const withPr = aggregateSource.filter((i) => i.prNumber !== undefined).length;
15
+ const withPr = aggregateSource.filter((i) => hasOpenPr(i.prNumber, i.prState)).length;
15
16
  const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
16
17
  const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
17
18
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;