patchrelay 0.45.1 → 0.46.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.45.1",
4
- "commit": "79f73cac4bc4",
5
- "builtAt": "2026-04-16T18:26:48.632Z"
3
+ "version": "0.46.0",
4
+ "commit": "3700d085b9ac",
5
+ "builtAt": "2026-04-17T22:27:38.085Z"
6
6
  }
@@ -6,6 +6,7 @@ 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 { LogView } from "./LogView.js";
9
10
  import { buildWatchDetailExportText, exportWatchTextToTempFile, findLastAssistantMessage, findLastCommand, findLastCommandOutput, openTextInPager, writeTextToClipboard, } from "./watch-actions.js";
10
11
  import { measureRenderedTextRows } from "./layout-measure.js";
11
12
  import { PROMPT_COMPOSER_HINT, measurePromptComposerRows } from "./prompt-layout.js";
@@ -323,32 +324,18 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
323
324
  dispatch({ type: "enter-detail", issueKey: issue.issueKey });
324
325
  }
325
326
  }
326
- else if (key.tab) {
327
+ else if (input === "a" || key.tab) {
327
328
  dispatch({ type: "cycle-filter" });
328
329
  }
330
+ return;
329
331
  }
330
- else if (state.view === "detail") {
332
+ if (state.view === "log") {
331
333
  if (key.escape || key.backspace || key.delete) {
332
- dispatch({ type: "exit-detail" });
334
+ dispatch({ type: "exit-log" });
333
335
  }
334
336
  else if (input === "f") {
335
337
  dispatch({ type: "toggle-follow" });
336
338
  }
337
- else if (input === "r") {
338
- handleRetry();
339
- }
340
- else if (input === "p") {
341
- setPromptMode(true);
342
- setPromptCursor(promptBuffer.length);
343
- }
344
- else if (input === "s") {
345
- if (state.activeDetailKey) {
346
- showPersistentStatus("stopping...");
347
- void postStop(baseUrl, state.activeDetailKey, bearerToken).then((result) => {
348
- showStatus(result.ok ? "stop sent" : `stop failed: ${result.reason ?? "unknown"}`);
349
- });
350
- }
351
- }
352
339
  else if (input === "y") {
353
340
  handleCopyLastAssistant();
354
341
  }
@@ -364,22 +351,16 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
364
351
  else if (input === "v") {
365
352
  handleOpenTranscriptInPager();
366
353
  }
367
- else if (input === "h") {
368
- dispatch({ type: "switch-detail-tab", tab: "history" });
369
- }
370
- else if (input === "t") {
371
- dispatch({ type: "switch-detail-tab", tab: "timeline" });
372
- }
373
354
  else if (input === "j" || key.downArrow) {
374
355
  dispatch({ type: "detail-scroll", delta: 1 });
375
356
  }
376
357
  else if (input === "k" || key.upArrow) {
377
358
  dispatch({ type: "detail-scroll", delta: -1 });
378
359
  }
379
- else if (key.pageDown || (key.ctrl && input === "d") || (key.ctrl && key.downArrow)) {
360
+ else if (key.pageDown || (key.ctrl && input === "d")) {
380
361
  dispatch({ type: "detail-page", direction: "down" });
381
362
  }
382
- else if (key.pageUp || (key.ctrl && input === "u") || (key.ctrl && key.upArrow)) {
363
+ else if (key.pageUp || (key.ctrl && input === "u")) {
383
364
  dispatch({ type: "detail-page", direction: "up" });
384
365
  }
385
366
  else if (key.home) {
@@ -394,6 +375,56 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
394
375
  else if (input === "]" || key.rightArrow) {
395
376
  dispatch({ type: "detail-navigate", direction: "next", filtered });
396
377
  }
378
+ return;
379
+ }
380
+ // detail view
381
+ if (key.escape || key.backspace || key.delete) {
382
+ dispatch({ type: "exit-detail" });
383
+ }
384
+ else if (input === "l") {
385
+ dispatch({ type: "enter-log" });
386
+ }
387
+ else if (input === "f") {
388
+ dispatch({ type: "toggle-follow" });
389
+ }
390
+ else if (input === "r") {
391
+ handleRetry();
392
+ }
393
+ else if (input === "p") {
394
+ setPromptMode(true);
395
+ setPromptCursor(promptBuffer.length);
396
+ }
397
+ else if (input === "s") {
398
+ if (state.activeDetailKey) {
399
+ showPersistentStatus("stopping...");
400
+ void postStop(baseUrl, state.activeDetailKey, bearerToken).then((result) => {
401
+ showStatus(result.ok ? "stop sent" : `stop failed: ${result.reason ?? "unknown"}`);
402
+ });
403
+ }
404
+ }
405
+ else if (input === "j" || key.downArrow) {
406
+ dispatch({ type: "detail-scroll", delta: 1 });
407
+ }
408
+ else if (input === "k" || key.upArrow) {
409
+ dispatch({ type: "detail-scroll", delta: -1 });
410
+ }
411
+ else if (key.pageDown || (key.ctrl && input === "d")) {
412
+ dispatch({ type: "detail-page", direction: "down" });
413
+ }
414
+ else if (key.pageUp || (key.ctrl && input === "u")) {
415
+ dispatch({ type: "detail-page", direction: "up" });
416
+ }
417
+ else if (key.home) {
418
+ dispatch({ type: "detail-jump", target: "start" });
419
+ }
420
+ else if (key.end) {
421
+ dispatch({ type: "detail-jump", target: "end" });
422
+ }
423
+ else if (input === "[" || key.leftArrow) {
424
+ dispatch({ type: "detail-navigate", direction: "prev", filtered });
425
+ }
426
+ else if (input === "]" || key.rightArrow) {
427
+ dispatch({ type: "detail-navigate", direction: "next", filtered });
397
428
  }
398
429
  });
399
430
  const reservedRows = 1 + (promptMode
@@ -401,9 +432,11 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
401
432
  : promptStatus
402
433
  ? measureRenderedTextRows(promptStatus, width)
403
434
  : 0);
404
- 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, compact: compact })) : 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, compact: compact, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
435
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, frozen: frozen, compact: compact })) : state.view === "log" ? (_jsx(LogView, { issue: activeIssue, timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, activeRunId: state.activeRunId, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
436
+ dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
437
+ } })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: activeIssue, 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, compact: compact, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
405
438
  dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
406
- } }), promptMode && (_jsx(PromptComposer, { buffer: promptBuffer, cursor: promptCursor })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
439
+ } }), promptMode && (_jsx(PromptComposer, { buffer: promptBuffer, cursor: promptCursor })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) }));
407
440
  }
408
441
  function PromptComposer({ buffer, cursor }) {
409
442
  const withCursor = `${buffer.slice(0, cursor)}|${buffer.slice(cursor)}`;
@@ -1,33 +1,32 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function buildHelpBarText(view, follow, detailTab, compact = false) {
4
- if (view === "detail") {
5
- const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
6
- if (compact) {
7
- return "j/k: scroll f: live p: prompt y/c/o: copy r: retry s: stop esc: list q: quit";
8
- }
3
+ export function buildHelpBarText(view, follow) {
4
+ if (view === "log") {
9
5
  return [
10
- tabHint,
11
- "j/k: scroll",
12
- "Ctrl-U/Ctrl-D: page",
13
- "[ ]: issue",
14
- "Home/End: jump",
15
- `f: live ${follow ? "on" : "off"}`,
16
- "p: prompt",
17
- "y/c/o: copy",
18
- "v/e: transcript",
19
- "s: stop",
20
- "r: retry",
21
- ]
22
- .filter(Boolean)
23
- .join(" ");
6
+ "j/k scroll",
7
+ "[ ] turn",
8
+ `f live ${follow ? "on" : "off"}`,
9
+ "y/c/o copy",
10
+ "e export",
11
+ "v pager",
12
+ "esc back",
13
+ "q quit",
14
+ ].join(" ");
24
15
  }
25
- if (compact) {
26
- return "Enter: detail Tab: filter x: pause q: quit";
16
+ if (view === "detail") {
17
+ return [
18
+ "j/k scroll",
19
+ "[ ] issue",
20
+ "l log",
21
+ "p prompt",
22
+ "r retry",
23
+ "s stop",
24
+ "esc list",
25
+ "q quit",
26
+ ].join(" ");
27
27
  }
28
- return "Enter: detail Tab: filter";
28
+ return "↑↓ select enter detail a filter x pause q quit";
29
29
  }
30
- export function HelpBar({ view, follow, detailTab, compact = false }) {
31
- const text = buildHelpBarText(view, follow, detailTab, compact);
32
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
30
+ export function HelpBar({ view, follow }) {
31
+ return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: buildHelpBarText(view, follow) }) }));
33
32
  }
@@ -17,9 +17,9 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
17
17
  connected,
18
18
  lastServerMessageAt,
19
19
  }), width);
20
- const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab, compact), width);
20
+ const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow), width);
21
21
  return statusRows + helpRows;
22
- }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width, compact]);
22
+ }, [activeRunStartedAt, connected, follow, lastServerMessageAt, unreadBelow, width]);
23
23
  const viewportRows = Math.max(4, totalRows - reservedRows - footerRows);
24
24
  const lines = useMemo(() => {
25
25
  if (!issue) {
@@ -60,7 +60,7 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
60
60
  const start = Math.min(scrollOffset, maxOffset);
61
61
  const visibleLines = lines.slice(start, start + viewportRows);
62
62
  const fillerCount = Math.max(0, viewportRows - visibleLines.length);
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, compact: compact })] }));
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 })] }));
64
64
  }
65
65
  function RenderedLine({ line }) {
66
66
  if (line.segments.length === 0) {
@@ -1,46 +1,28 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
- import { IssueRow, estimateIssueRowHeight } from "./IssueRow.js";
4
+ import { IssueRow } from "./IssueRow.js";
5
5
  import { StatusBar } from "./StatusBar.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
- const FIXED_COLS = 8;
8
- const CHROME_ROWS = 4;
9
- export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, titleWidth, compact) {
7
+ const CHROME_ROWS = 3;
8
+ export function computeVisibleWindow(issues, selectedIndex, maxRows) {
10
9
  if (issues.length === 0)
11
10
  return { start: 0, end: 0 };
12
- const clampedSelected = Math.max(0, Math.min(selectedIndex, issues.length - 1));
13
- const heights = issues.map((issue, index) => estimateIssueRowHeight(issue, index === clampedSelected, cols, titleWidth, compact));
14
- let start = clampedSelected;
15
- let end = clampedSelected + 1;
16
- let usedRows = heights[clampedSelected] ?? 1;
17
- while (true) {
18
- const canAddAbove = start > 0 && usedRows + (heights[start - 1] ?? 1) <= maxRows;
19
- const canAddBelow = end < issues.length && usedRows + (heights[end] ?? 1) <= maxRows;
20
- if (!canAddAbove && !canAddBelow)
21
- break;
22
- const aboveDistance = clampedSelected - start;
23
- const belowDistance = end - 1 - clampedSelected;
24
- const preferAbove = canAddAbove && (!canAddBelow || aboveDistance <= belowDistance);
25
- if (preferAbove) {
26
- start -= 1;
27
- usedRows += heights[start] ?? 1;
28
- continue;
29
- }
30
- if (canAddBelow) {
31
- usedRows += heights[end] ?? 1;
32
- end += 1;
33
- }
11
+ const clamped = Math.max(0, Math.min(selectedIndex, issues.length - 1));
12
+ const half = Math.floor(maxRows / 2);
13
+ let start = Math.max(0, clamped - half);
14
+ let end = Math.min(issues.length, start + maxRows);
15
+ if (end - start < maxRows) {
16
+ start = Math.max(0, end - maxRows);
34
17
  }
35
18
  return { start, end };
36
19
  }
37
- export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, compact = false, }) {
20
+ export function IssueListView({ issues, selectedIndex, connected, lastServerMessageAt, filter, frozen, compact = false, }) {
38
21
  const { stdout } = useStdout();
39
22
  const cols = stdout?.columns ?? 80;
40
23
  const rows = stdout?.rows ?? 24;
41
- const titleWidth = Math.max(0, cols - (compact ? 24 : FIXED_COLS));
42
- const maxVisibleRows = Math.max(1, rows - (compact ? 3 : CHROME_ROWS));
43
- // Periodic refresh for elapsed times
24
+ const titleWidth = Math.max(0, cols - 42);
25
+ const maxVisibleRows = Math.max(1, rows - CHROME_ROWS);
44
26
  const [, tick] = useReducer((c) => c + 1, 0);
45
27
  useEffect(() => {
46
28
  if (frozen)
@@ -48,9 +30,9 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
48
30
  const id = setInterval(tick, 5000);
49
31
  return () => clearInterval(id);
50
32
  }, [frozen]);
51
- const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows, cols, titleWidth, compact);
33
+ const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows);
52
34
  const visible = issues.slice(startIndex, endIndex);
53
35
  const hiddenAbove = startIndex;
54
36
  const hiddenBelow = Math.max(0, issues.length - endIndex);
55
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false, compact: compact }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsx(Text, { dimColor: true, children: compact ? `↑${hiddenAbove}` : ` ${hiddenAbove} more above` }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsx(Text, { dimColor: true, children: compact ? `↓${hiddenBelow}` : ` ${hiddenBelow} more below` })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list", compact: compact }) })] }));
37
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, frozen: frozen ?? false }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: " " })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 ? _jsx(Text, { dimColor: true, children: ` ↑${hiddenAbove}` }) : null, visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 ? _jsx(Text, { dimColor: true, children: ` ↓${hiddenBelow}` }) : null] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
56
38
  }
@@ -1,208 +1,23 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { hasOpenPr } from "../../pr-state.js";
4
- import { summarizeIssueStatusNote } from "./issue-status-note.js";
5
- import { relativeTime, truncate } from "./format-utils.js";
6
- import { measureRenderedTextRows } from "./layout-measure.js";
7
- import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
8
- // ─── State display ──────────────────────────────────────────────
9
- const TERMINAL_STATES = new Set(["done", "failed", "escalated"]);
10
- function needsOperatorIntervention(issue) {
11
- return issue.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated";
12
- }
13
- function effectiveState(issue) {
14
- if (issue.sessionState === "done")
15
- return "done";
16
- if (issue.sessionState === "failed")
17
- return "failed";
18
- if (issue.completionCheckActive)
19
- return "completion_check";
20
- if (issue.blockedByCount > 0 && !issue.activeRunType)
21
- return "blocked";
22
- if (issue.sessionState === "waiting_input")
23
- return "awaiting_input";
24
- if (hasOpenPr(issue.prNumber, issue.prState))
25
- return issue.factoryState;
26
- if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
27
- return "ready";
28
- return issue.factoryState;
29
- }
30
- function sessionDisplay(issue) {
31
- if (needsOperatorIntervention(issue)) {
32
- return { label: "needs help", color: "red" };
33
- }
34
- switch (issue.sessionState) {
35
- case "running":
36
- return { label: "running", color: "cyan" };
37
- case "idle":
38
- return { label: "idle", color: "blueBright" };
39
- case "waiting_input":
40
- return { label: "needs input", color: "yellow" };
41
- case "done":
42
- return { label: "done", color: "green" };
43
- case "failed":
44
- return { label: "failed", color: "red" };
45
- default:
46
- return { label: "unknown", color: "white" };
47
- }
48
- }
49
- function stageLabel(issue) {
50
- const state = effectiveState(issue);
51
- switch (state) {
52
- case "blocked": return "blocked";
53
- case "ready": return "ready";
54
- case "delegated": return "delegated";
55
- case "implementing": return "implementing";
56
- case "completion_check": return "completion check";
57
- case "pr_open": return "PR open";
58
- case "changes_requested": return "review changes";
59
- case "repairing_ci": return "repairing CI";
60
- case "awaiting_queue": return "waiting downstream";
61
- case "repairing_queue": return "repairing queue";
62
- case "done": return "merged";
63
- case "failed": return "failed";
64
- case "escalated": return "escalated";
65
- case "awaiting_input": return "needs input";
66
- default: return state;
67
- }
68
- }
69
- // ─── Context facts (what matters right now) ─────────────────────
70
- function buildFacts(issue, selected, compact = false) {
71
- const facts = [];
72
- const rereviewNeeded = isRereviewNeeded(issue);
73
- // PR number
74
- if (issue.prNumber !== undefined) {
75
- facts.push({ text: `PR #${issue.prNumber}` });
76
- }
77
- if (!issue.sessionState) {
78
- facts.push({ text: `stage ${stageLabel(issue)}` });
79
- }
80
- else if (selected) {
81
- facts.push({ text: `internal stage ${stageLabel(issue)}` });
82
- }
83
- if (issue.waitingReason && issue.sessionState === "waiting_input") {
84
- facts.push({ text: issue.waitingReason, color: "yellow" });
85
- }
86
- if (needsOperatorIntervention(issue)) {
87
- facts.push({ text: "operator action needed", color: "red" });
88
- }
89
- // Review state — only show when it matters (not yet approved, or changes requested)
90
- if (isApprovedReviewState(issue.prReviewState)) {
91
- facts.push({ text: "approved", color: "green" });
92
- }
93
- else if (rereviewNeeded) {
94
- facts.push({ text: "re-review needed", color: "yellow" });
95
- }
96
- else if (isChangesRequestedReviewState(issue.prReviewState)) {
97
- facts.push({ text: "changes requested", color: "yellow" });
98
- }
99
- else if (hasOpenPr(issue.prNumber, issue.prState)
100
- && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
101
- facts.push({ text: "awaiting review", color: "yellow" });
102
- }
103
- if (issue.factoryState === "awaiting_queue") {
104
- facts.push({ text: "downstream ready", color: "cyan" });
105
- }
106
- // Check status — compact
107
- const checksFact = prChecksFact(issue);
108
- if (checksFact) {
109
- facts.push(checksFact);
110
- }
111
- // Blocker
112
- if (issue.blockedByCount > 0) {
113
- facts.push({ text: `waiting on ${issue.blockedByKeys.join(", ")}`, color: "yellow" });
114
- }
115
- return compact ? facts.slice(0, 3) : facts;
116
- }
117
- // ─── What's blocking progress ───────────────────────────────────
118
- function blockerText(issue) {
119
- const rereviewNeeded = isRereviewNeeded(issue);
120
- if (issue.sessionState === "waiting_input")
121
- return issue.waitingReason ?? "Waiting for input";
122
- if (needsOperatorIntervention(issue))
123
- return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
124
- if (issue.completionCheckActive)
125
- return "No PR found; checking next step";
126
- if (issue.waitingReason && issue.activeRunType && issue.factoryState === "pr_open")
127
- return issue.waitingReason;
128
- if (issue.waitingReason && issue.activeRunType && issue.factoryState === "awaiting_queue")
129
- return issue.waitingReason;
130
- if (issue.waitingReason && !issue.activeRunType)
131
- return issue.waitingReason;
132
- if (issue.blockedByCount > 0)
133
- return `Waiting on ${issue.blockedByKeys.join(", ")}`;
134
- if (effectiveState(issue) === "repairing_queue")
135
- return "Merge queue conflict, repairing branch";
136
- if (effectiveState(issue) === "repairing_ci") {
137
- const check = issue.latestFailureCheckName ?? "CI";
138
- return `Repairing ${check}`;
139
- }
140
- const checksFact = prChecksFact(issue);
141
- if (checksFact?.color === "red") {
142
- return checksFact.text;
143
- }
144
- if (checksFact?.color === "yellow" && checksFact.text.startsWith("checks ")) {
145
- return `${checksFact.text} still running`;
146
- }
147
- if (rereviewNeeded)
148
- return "Awaiting re-review after requested changes";
149
- if (isChangesRequestedReviewState(issue.prReviewState))
150
- return "Review changes requested";
151
- if (hasOpenPr(issue.prNumber, issue.prState) && isAwaitingReviewState(issue.prReviewState))
152
- return "Awaiting review";
153
- return null;
154
- }
155
- // ─── Render ─────────────────────────────────────────────────────
3
+ import { issueTokenFor, prTokenFor } from "./issue-token.js";
4
+ import { truncate } from "./format-utils.js";
5
+ const KEY_WIDTH = 8;
6
+ const GLYPH_WIDTH = 3;
7
+ const PHRASE_WIDTH = 18;
156
8
  export function IssueRow({ issue, selected, titleWidth, compact = false, }) {
157
9
  const key = issue.issueKey ?? issue.projectId;
158
- const tw = titleWidth ?? 60;
159
- const title = issue.title ? truncate(issue.title, tw) : "";
160
- const session = sessionDisplay(issue);
161
- const facts = buildFacts(issue, selected, compact);
162
- const blocker = compact || !selected ? null : blockerText(issue);
163
- const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
164
- const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
165
- // Terminal issues: compact single line
166
- if (isTerminal && !selected) {
167
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label })] }));
168
- }
169
- if (compact) {
170
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && _jsx(Text, { dimColor: true, children: ` · ` }), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }));
171
- }
172
- return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && (_jsx(Text, { dimColor: true, children: ` \u00b7 ` })), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` \u00b7 ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }), title ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: title }) })) : null, blocker ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", children: blocker }) })) : null, detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null, selected && issue.factoryState && issue.sessionState ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: `Debug stage: ${stageLabel(issue)}` }) })) : null] }));
173
- }
174
- export function estimateIssueRowHeight(issue, selected, cols, titleWidth, compact = false) {
175
- const width = Math.max(20, cols);
176
- const key = issue.issueKey ?? issue.projectId;
177
- const tw = titleWidth ?? 60;
178
- const title = issue.title ? truncate(issue.title, tw) : "";
179
- const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
180
- const session = sessionDisplay(issue);
181
- const facts = buildFacts(issue, selected, compact);
182
- const blocker = compact || !selected ? null : blockerText(issue);
183
- const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
184
- if (isTerminal && !selected) {
185
- return 1;
186
- }
187
- if (compact) {
188
- return 1;
189
- }
190
- const line1Parts = [
191
- `${selected ? "\u25b8" : " "} ${key}`,
192
- relativeTime(issue.updatedAt).padStart(4),
193
- session.label,
194
- ...facts.map((fact) => fact.text),
195
- ];
196
- let rows = measureRenderedTextRows(line1Parts.join(" · "), width);
197
- if (title)
198
- rows += measureRenderedTextRows(title, Math.max(8, width - 2));
199
- if (blocker)
200
- rows += measureRenderedTextRows(blocker, Math.max(8, width - 2));
201
- if (detail)
202
- rows += measureRenderedTextRows(detail, Math.max(8, width - 4));
203
- if (selected && issue.factoryState && issue.sessionState)
204
- rows += 1;
205
- if (detail)
206
- rows += 1;
207
- return Math.max(1, rows);
10
+ const token = issueTokenFor(issue);
11
+ const pr = prTokenFor(issue);
12
+ const cursorChar = selected ? "\u25b8" : " ";
13
+ const paddedKey = key.padEnd(KEY_WIDTH, " ");
14
+ const paddedPhrase = token.phrase.padEnd(PHRASE_WIDTH, " ");
15
+ const availableTitleWidth = Math.max(0, (titleWidth ?? 60) - (pr ? 10 : 0));
16
+ const title = !compact && selected && issue.title
17
+ ? ` ${truncate(issue.title, Math.max(0, availableTitleWidth))}`
18
+ : "";
19
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, color: token.color, children: ` ${paddedKey}` }), _jsx(Text, { color: token.color, children: ` ${token.glyph.padEnd(GLYPH_WIDTH - 1, " ")}` }), _jsx(Text, { children: ` ${paddedPhrase}` }), pr ? (_jsx(_Fragment, { children: _jsx(Text, { color: pr.color, children: `#${pr.prNumber} ${pr.glyph}` }) })) : null, title ? _jsx(Text, { dimColor: true, children: title }) : null] }));
20
+ }
21
+ export function estimateIssueRowHeight(_issue, _selected, _cols, _titleWidth, _compact = false) {
22
+ return 1;
208
23
  }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo } from "react";
3
+ import { Box, Text, useStdout } from "ink";
4
+ import { HelpBar } from "./HelpBar.js";
5
+ import { buildCodexLogLines } from "./codex-log-rows.js";
6
+ import { renderTextLines } from "./render-rich-text.js";
7
+ import { issueTokenFor, prTokenFor } from "./issue-token.js";
8
+ export function LogView({ issue, timeline, follow, scrollOffset, activeRunId, reservedRows = 0, onLayoutChange, }) {
9
+ const { stdout } = useStdout();
10
+ const width = Math.max(20, stdout?.columns ?? 80);
11
+ const totalRows = stdout?.rows ?? 24;
12
+ const viewportRows = Math.max(4, totalRows - reservedRows - 2);
13
+ const lines = useMemo(() => {
14
+ if (!issue) {
15
+ return [{ key: "loading", segments: [{ text: "Loading issue…", dimColor: true }] }];
16
+ }
17
+ const headerLines = buildLogHeader(issue, activeRunId, width);
18
+ const bodyLines = buildCodexLogLines(timeline, width);
19
+ if (bodyLines.length === 0) {
20
+ return [
21
+ ...headerLines,
22
+ blankLine("header-gap"),
23
+ ...renderTextLines("No app-server output yet.", { key: "empty", width, style: { dimColor: true } }),
24
+ ];
25
+ }
26
+ return [...headerLines, blankLine("header-gap"), ...bodyLines];
27
+ }, [activeRunId, issue, timeline, width]);
28
+ useEffect(() => {
29
+ onLayoutChange(viewportRows, lines.length);
30
+ }, [viewportRows, lines.length, onLayoutChange]);
31
+ const startIndex = clamp(scrollOffset, 0, Math.max(0, lines.length - viewportRows));
32
+ const visible = lines.slice(startIndex, startIndex + viewportRows);
33
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { flexDirection: "column", children: visible.map((line) => (_jsx(Box, { children: line.segments.map((segment, index) => {
34
+ const props = {};
35
+ if (segment.dimColor)
36
+ props.dimColor = true;
37
+ if (segment.bold)
38
+ props.bold = true;
39
+ if (segment.color)
40
+ props.color = segment.color;
41
+ return (_jsx(Text, { ...props, children: segment.text }, `${line.key}-${index}`));
42
+ }) }, line.key))) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "log", follow: follow }) })] }));
43
+ }
44
+ function buildLogHeader(issue, activeRunId, width) {
45
+ const token = issueTokenFor(issue);
46
+ const pr = prTokenFor(issue);
47
+ const key = issue.issueKey ?? issue.projectId;
48
+ const segments = [
49
+ { text: key, color: token.color, bold: true },
50
+ { text: " " },
51
+ { text: token.glyph, color: token.color },
52
+ { text: " " },
53
+ { text: token.phrase },
54
+ ];
55
+ if (pr) {
56
+ segments.push({ text: " " });
57
+ segments.push({ text: `#${pr.prNumber} ${pr.glyph}`, color: pr.color });
58
+ }
59
+ if (activeRunId !== null) {
60
+ segments.push({ text: " " });
61
+ segments.push({ text: `run #${activeRunId}`, dimColor: true });
62
+ }
63
+ const plain = renderTextLines(segments.map((s) => s.text).join(""), { key: "log-header", width });
64
+ if (plain.length > 0) {
65
+ plain[0] = { key: plain[0].key, segments };
66
+ }
67
+ return plain;
68
+ }
69
+ function clamp(value, min, max) {
70
+ return Math.max(min, Math.min(max, value));
71
+ }
72
+ function blankLine(key) {
73
+ return { key, segments: [{ text: "" }] };
74
+ }