nugit-cli 0.0.1 → 0.1.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 (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -0,0 +1,2129 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdin, useStdout } from "ink";
3
+ import chalk from "chalk";
4
+ import { openUrl } from "../../stack-view/open-url.js";
5
+ import { githubGetGitTree, githubGetBlobText } from "../../github-rest.js";
6
+ import { buildAsciiTreeRows } from "../../stack-view/tree-ascii.js";
7
+ import { buildMergedPreviewFromPatch } from "../../stack-view/patch-preview-merge.js";
8
+ import { buildStackBranchGraphLines } from "../../stack-view/stack-branch-graph.js";
9
+ import { markdownToPlainLines } from "../../stack-view/view-md-plain.js";
10
+ import {
11
+ parseSgrMouse,
12
+ enableSgrMouse,
13
+ disableSgrMouse,
14
+ isWheelUp,
15
+ isWheelDown
16
+ } from "../../stack-view/sgr-mouse.js";
17
+
18
+ /** @returns {{ next: null | Record<string, unknown> }} */
19
+ export function createExitPayload() {
20
+ return { next: null };
21
+ }
22
+
23
+ const TAB_COUNT = 5;
24
+ const MAX_PREVIEW_CHARS = 800_000;
25
+ const MAX_PREVIEW_LINES = 12_000;
26
+ const SPIN_FRAMES = ["|", "/", "-", "\\"];
27
+
28
+ /** Shell: single full-screen workspace tabs (lazyql-style) */
29
+ const SHELL_TAB_LABELS = ["Tree", "Description", "Conversation", "Review"];
30
+ const SHELL_TAB_COUNT = 4;
31
+
32
+ /**
33
+ * Line change totals for a stack row (GitHub pull or sum of file stats).
34
+ * @param {{ pull?: unknown, files?: unknown[] }} r
35
+ */
36
+ function prAddDelTotals(r) {
37
+ const pull =
38
+ r?.pull && typeof r.pull === "object"
39
+ ? /** @type {Record<string, unknown>} */ (r.pull)
40
+ : null;
41
+ let add = pull != null ? Number(pull.additions) : NaN;
42
+ let del = pull != null ? Number(pull.deletions) : NaN;
43
+ if (!Number.isFinite(add)) add = 0;
44
+ if (!Number.isFinite(del)) del = 0;
45
+ if (add === 0 && del === 0 && Array.isArray(r?.files) && r.files.length > 0) {
46
+ for (const f of r.files) {
47
+ if (!f || typeof f !== "object") continue;
48
+ const o = /** @type {Record<string, unknown>} */ (f);
49
+ add += Number(o.additions) || 0;
50
+ del += Number(o.deletions) || 0;
51
+ }
52
+ }
53
+ return { add, del };
54
+ }
55
+
56
+ /**
57
+ * Tree tab layout: never show patch + file preview at once — preview carries diff styling.
58
+ * @param {number} bodyMax
59
+ * @param {boolean} previewOpen
60
+ * @param {boolean} showPatchPane unified diff below tree (only when preview closed)
61
+ */
62
+ function computeShellTreePaneHeights(bodyMax, previewOpen, showPatchPane) {
63
+ if (previewOpen) {
64
+ const treeBlockH = Math.max(3, Math.floor(bodyMax * 0.34));
65
+ const previewH = Math.max(4, bodyMax - treeBlockH - 3);
66
+ return { treeBlockH, patchH: 0, previewH };
67
+ }
68
+ if (showPatchPane) {
69
+ const treeBlockH = Math.max(3, Math.floor(bodyMax * 0.36));
70
+ const patchH = Math.max(4, bodyMax - treeBlockH - 3);
71
+ return { treeBlockH, patchH, previewH: 0 };
72
+ }
73
+ return { treeBlockH: Math.max(2, bodyMax - 1), patchH: 0, previewH: 0 };
74
+ }
75
+
76
+ /**
77
+ * @param {string} s
78
+ * @param {number} w
79
+ */
80
+ function truncVis(s, w) {
81
+ if (w < 4) return "";
82
+ if (s.length <= w) return s;
83
+ return s.slice(0, w - 1) + "\u2026";
84
+ }
85
+
86
+ /**
87
+ * @template T
88
+ * @param {T[]} items
89
+ * @param {number} cursor
90
+ * @param {number} maxVisible
91
+ */
92
+ function sliceViewport(items, cursor, maxVisible) {
93
+ const n = items.length;
94
+ if (n === 0) return { slice: [], start: 0 };
95
+ if (n <= maxVisible) return { slice: items, start: 0 };
96
+ const half = Math.floor(maxVisible / 2);
97
+ const start = Math.max(0, Math.min(cursor - half, n - maxVisible));
98
+ return { slice: items.slice(start, start + maxVisible), start };
99
+ }
100
+
101
+ /**
102
+ * @param {object} props
103
+ * @param {Awaited<ReturnType<import('./fetch-pr-data.js').fetchStackPrDetails>>} props.rows
104
+ * @param {{ next: null | Record<string, unknown> }} props.exitPayload
105
+ * @param {string} [props.viewTitle]
106
+ * @param {string} [props.browseOwner]
107
+ * @param {string} [props.browseRepo]
108
+ * @param {string} [props.repoFullName]
109
+ * @param {boolean} [props.shellMode] multi-page layout with sidebar (nugit view)
110
+ * @param {unknown[] | null} [props.alternateStacks] when >1, Backspace from PR-pick opens stack picker
111
+ */
112
+ export function StackInkApp({
113
+ rows: stackRows,
114
+ exitPayload,
115
+ viewTitle = "nugit view",
116
+ browseOwner = "",
117
+ browseRepo = "",
118
+ repoFullName = "",
119
+ shellMode = false,
120
+ alternateStacks = null
121
+ }) {
122
+ const canPickAlternateStack = Array.isArray(alternateStacks) && alternateStacks.length > 1;
123
+ const { exit } = useApp();
124
+ const { stdout } = useStdout();
125
+ const { stdin } = useStdin();
126
+ const [termCols, setTermCols] = useState(() => stdout?.columns || 80);
127
+ const [termRows, setTermRows] = useState(() => stdout?.rows || 24);
128
+ const [pulse, setPulse] = useState(0);
129
+
130
+ const cols = Math.max(40, termCols || 80);
131
+ const ttyRows = Math.max(16, termRows || 24);
132
+
133
+ useEffect(() => {
134
+ const upd = () => {
135
+ setTermCols(stdout?.columns || 80);
136
+ setTermRows(stdout?.rows || 24);
137
+ };
138
+ upd();
139
+ stdout?.on?.("resize", upd);
140
+ return () => stdout?.off?.("resize", upd);
141
+ }, [stdout]);
142
+
143
+ useEffect(() => {
144
+ const t = setInterval(() => setPulse((p) => (p + 1) % 256), 350);
145
+ return () => clearInterval(t);
146
+ }, []);
147
+
148
+ useEffect(() => {
149
+ if (!shellMode) {
150
+ enableSgrMouse(stdout);
151
+ return () => disableSgrMouse(stdout);
152
+ }
153
+ return undefined;
154
+ }, [stdout, shellMode]);
155
+
156
+ /** @type {React.MutableRefObject<{ bodyStartR: number, nLad: number, ladderStart: number } | null>} */
157
+ const layoutRef = useRef(null);
158
+ /** @type {React.MutableRefObject<Array<{ r1: number, r2: number, kind: string, [k: string]: unknown }>>} */
159
+ const regionsRef = useRef([]);
160
+ /** @type {React.MutableRefObject<{ t: number, idx: number } | null>} */
161
+ const lastTreeClickRef = useRef(null);
162
+
163
+ const [showFullHelp, setShowFullHelp] = useState(false);
164
+ /** Shell: main panel tab (tree first — diffs inline when cursor is on a changed file) */
165
+ const [shellMainTab, setShellMainTab] = useState(0);
166
+ /** Shell: keyboard focus on sidebar PR ladder; starts false for single-PR stacks (no ladder to navigate) */
167
+ const [shellStackFocus, setShellStackFocus] = useState(
168
+ () => shellMode && (stackRows?.length ?? 0) !== 1
169
+ );
170
+
171
+ /** Shell: -1 = no PR loaded yet (tree waits); single-PR stacks auto-select index 0; non-shell starts at 0 */
172
+ const [prIndex, setPrIndex] = useState(() => (shellMode && (stackRows?.length ?? 0) !== 1 ? -1 : 0));
173
+ const [tab, setTab] = useState(0);
174
+ const [lineIndex, setLineIndex] = useState(0);
175
+ const [fileIndex, setFileIndex] = useState(0);
176
+ const [filePatchOffset, setFilePatchOffset] = useState(0);
177
+ const [patchCursorLine, setPatchCursorLine] = useState(0);
178
+ const [fileCommentIndex, setFileCommentIndex] = useState(0);
179
+
180
+ const [treeLines, setTreeLines] = useState(/** @type {{ path: string, type: string }[]} */ ([]));
181
+ const [treeLoading, setTreeLoading] = useState(false);
182
+ const [treeError, setTreeError] = useState(/** @type {string | null} */ (null));
183
+ const [treeLineIndex, setTreeLineIndex] = useState(0);
184
+ const [treePreview, setTreePreview] = useState("");
185
+ const [treePreviewPath, setTreePreviewPath] = useState("");
186
+ const [treePreviewScroll, setTreePreviewScroll] = useState(0);
187
+
188
+ const asciiTreeRows = useMemo(() => buildAsciiTreeRows(treeLines), [treeLines]);
189
+ const treePreviewLines = useMemo(() => (treePreview ? treePreview.split("\n") : []), [treePreview]);
190
+
191
+ useEffect(() => {
192
+ setTreeLineIndex((i) => Math.min(i, Math.max(0, asciiTreeRows.length - 1)));
193
+ }, [asciiTreeRows.length]);
194
+
195
+ const len = stackRows?.length ?? 0;
196
+
197
+ // Auto-select the single PR in shellMode so approve/review keys work immediately
198
+ useEffect(() => {
199
+ if (shellMode && len === 1 && prIndex < 0) {
200
+ setPrIndex(0);
201
+ setShellStackFocus(false);
202
+ }
203
+ }, [shellMode, len, prIndex]);
204
+
205
+ const hasPrPick = !shellMode || prIndex >= 0;
206
+ const safePr = len === 0 || !hasPrPick ? 0 : Math.min(prIndex, len - 1);
207
+ const row = len && hasPrPick ? stackRows[safePr] : null;
208
+ const issueList = row?.issueComments || [];
209
+ const reviewList = row?.reviewComments || [];
210
+ const fileList = row?.files || [];
211
+ const headSha =
212
+ row?.pull?.head && typeof row.pull.head === "object"
213
+ ? String(/** @type {Record<string, unknown>} */ (row.pull.head).sha || "")
214
+ : "";
215
+
216
+ const listLen = useMemo(() => {
217
+ if (shellMode) {
218
+ if (shellMainTab === 0) {
219
+ return Math.max(1, asciiTreeRows.length);
220
+ }
221
+ if (shellMainTab === 1) {
222
+ const bodyRaw = row?.pull && typeof row.pull.body === "string" ? row.pull.body : "";
223
+ const plain = markdownToPlainLines(bodyRaw);
224
+ const lines = plain ? plain.split("\n").filter((ln) => ln.length > 0) : ["(no description)"];
225
+ return Math.max(1, lines.length);
226
+ }
227
+ if (shellMainTab === 2) {
228
+ return issueList.length;
229
+ }
230
+ if (shellMainTab === 3) {
231
+ return reviewList.length;
232
+ }
233
+ }
234
+ if (tab === 1) return issueList.length;
235
+ if (tab === 2) return reviewList.length;
236
+ if (tab === 3) return fileList.length;
237
+ if (tab === 4) return asciiTreeRows.length;
238
+ return 0;
239
+ }, [
240
+ shellMode,
241
+ shellMainTab,
242
+ tab,
243
+ issueList.length,
244
+ reviewList.length,
245
+ fileList.length,
246
+ asciiTreeRows.length,
247
+ row
248
+ ]);
249
+
250
+ const treeSafeLine = Math.min(treeLineIndex, Math.max(0, asciiTreeRows.length - 1));
251
+ const safeLine = shellMode
252
+ ? shellMainTab === 0
253
+ ? treeSafeLine
254
+ : Math.min(lineIndex, Math.max(0, listLen - 1))
255
+ : tab === 4
256
+ ? treeSafeLine
257
+ : Math.min(lineIndex, Math.max(0, listLen - 1));
258
+ const safeFile = fileList.length === 0 ? 0 : Math.min(fileIndex, fileList.length - 1);
259
+ const selectedFile = fileList[safeFile] || null;
260
+ const patchLines =
261
+ selectedFile && typeof selectedFile.patch === "string"
262
+ ? String(selectedFile.patch).split("\n")
263
+ : [];
264
+
265
+ const browsePreviewOpen = !!(
266
+ treePreviewPath &&
267
+ (shellMode ? shellMainTab === 0 : tab === 4)
268
+ );
269
+
270
+ const pathToChangedFile = useMemo(() => {
271
+ /** @type {Map<string, Record<string, unknown>>} */
272
+ const m = new Map();
273
+ for (const f of fileList) {
274
+ const n = String(f?.filename || "");
275
+ if (n) m.set(n, f);
276
+ }
277
+ return m;
278
+ }, [fileList]);
279
+
280
+ const mergedPreviewRows = useMemo(() => {
281
+ if (!browsePreviewOpen || !treePreviewPath || !treePreview) {
282
+ return null;
283
+ }
284
+ const fe = fileList.find((f) => String(f?.filename || "") === treePreviewPath);
285
+ if (!fe || typeof fe.patch !== "string" || !String(fe.patch).trim()) {
286
+ return null;
287
+ }
288
+ const m = buildMergedPreviewFromPatch(treePreview, String(fe.patch), treePreviewPath);
289
+ return m && m.length > 0 ? m : null;
290
+ }, [browsePreviewOpen, treePreviewPath, treePreview, fileList]);
291
+
292
+ const previewLineCount = mergedPreviewRows?.length ?? treePreviewLines.length;
293
+
294
+ useEffect(() => {
295
+ if (!browsePreviewOpen) {
296
+ return;
297
+ }
298
+ const maxScroll = Math.max(0, previewLineCount - 1);
299
+ setTreePreviewScroll((s) => Math.min(s, maxScroll));
300
+ }, [browsePreviewOpen, treePreviewPath, previewLineCount]);
301
+
302
+ const fileComments = useMemo(() => {
303
+ if (!selectedFile) return [];
304
+ const fileName = String(selectedFile.filename || "");
305
+ return reviewList.filter((c) => String(c?.path || "") === fileName);
306
+ }, [selectedFile, reviewList]);
307
+ const safeFileComment = fileComments.length
308
+ ? Math.min(fileCommentIndex, fileComments.length - 1)
309
+ : 0;
310
+
311
+ /** Layout: keep total output within terminal height */
312
+ const sidebarW = shellMode ? 28 : 0;
313
+ const innerW = shellMode
314
+ ? Math.max(24, cols - 2 - sidebarW - 1 - 1)
315
+ : Math.max(20, cols - 2);
316
+
317
+ useEffect(() => {
318
+ if (!shellMode) {
319
+ return;
320
+ }
321
+ if (shellMainTab === 0) {
322
+ setTab(4);
323
+ } else if (shellMainTab === 2) {
324
+ setTab(1);
325
+ } else if (shellMainTab === 3) {
326
+ setTab(2);
327
+ } else {
328
+ setTab(0);
329
+ }
330
+ }, [shellMode, shellMainTab]);
331
+
332
+ useEffect(() => {
333
+ if (!shellMode || shellMainTab !== 0) {
334
+ setShellStackFocus(false);
335
+ }
336
+ }, [shellMode, shellMainTab]);
337
+
338
+ useEffect(() => {
339
+ if (shellMode && prIndex < 0 && shellMainTab !== 0) {
340
+ setShellMainTab(0);
341
+ }
342
+ }, [shellMode, prIndex, shellMainTab]);
343
+
344
+ useEffect(() => {
345
+ if (!shellMode || shellMainTab !== 0) {
346
+ return;
347
+ }
348
+ const ent = asciiTreeRows[treeSafeLine];
349
+ const p = ent?.path;
350
+ if (!p) {
351
+ return;
352
+ }
353
+ const idx = fileList.findIndex((f) => String(f?.filename || "") === p);
354
+ if (idx >= 0) {
355
+ setFileIndex(idx);
356
+ }
357
+ }, [shellMode, shellMainTab, treeSafeLine, asciiTreeRows, fileList]);
358
+
359
+ const exitRef = useRef(exit);
360
+ exitRef.current = exit;
361
+ const exitPayloadRef = useRef(exitPayload);
362
+ exitPayloadRef.current = exitPayload;
363
+ /** @type {React.MutableRefObject<{ canScrollPatch: boolean, maxPatchOffset: number, step: number, canScrollMergedPreview: boolean, mergedMaxScroll: number, mergedStep: number }>} */
364
+ const patchScrollRef = useRef({
365
+ canScrollPatch: false,
366
+ maxPatchOffset: 0,
367
+ step: 3,
368
+ canScrollMergedPreview: false,
369
+ mergedMaxScroll: 0,
370
+ mergedStep: 3
371
+ });
372
+
373
+ const SIDEBAR_GRAPH_MAX = 7;
374
+
375
+ /** Shell: title + tab row + rule + gap; footer: rule + up to 2 hint rows */
376
+ const reservedTop = shellMode ? 5 : 3;
377
+ const reservedHelp = shellMode ? 5 : 2;
378
+ const ladderMaxForDisplay = shellMode
379
+ ? Math.min(len, Math.max(2, ttyRows - (18 + SIDEBAR_GRAPH_MAX)))
380
+ : Math.min(len, Math.max(2, Math.min(7, Math.floor((ttyRows - 10) / 3))));
381
+ const ladderMaxReserved = shellMode ? 0 : ladderMaxForDisplay;
382
+ const bodyMax = Math.max(
383
+ 6,
384
+ ttyRows - reservedTop - reservedHelp - ladderMaxReserved - 2
385
+ );
386
+
387
+ const selPathForPatch = asciiTreeRows[treeSafeLine]?.path;
388
+ const hasPatchForShellTree =
389
+ !!selPathForPatch &&
390
+ selPathForPatch === String(selectedFile?.filename || "") &&
391
+ patchLines.length > 0;
392
+ const shellTreePanes =
393
+ shellMode && shellMainTab === 0
394
+ ? computeShellTreePaneHeights(
395
+ bodyMax,
396
+ browsePreviewOpen,
397
+ hasPatchForShellTree && !browsePreviewOpen
398
+ )
399
+ : null;
400
+
401
+ let patchPageSize = 12;
402
+ if (tab === 3) {
403
+ patchPageSize = Math.max(4, Math.min(20, bodyMax - 8));
404
+ } else if (shellTreePanes && shellTreePanes.patchH > 0) {
405
+ patchPageSize = Math.max(4, shellTreePanes.patchH);
406
+ } else if (shellMode && shellMainTab === 0) {
407
+ patchPageSize = Math.max(4, Math.min(20, bodyMax - 8));
408
+ }
409
+ const maxPatchOffset = Math.max(0, patchLines.length - patchPageSize);
410
+
411
+ const canScrollPatchShell =
412
+ shellMode &&
413
+ shellMainTab === 0 &&
414
+ hasPatchForShellTree &&
415
+ !browsePreviewOpen;
416
+ const previewHForMergedScroll = shellTreePanes?.previewH ?? 4;
417
+ const mergedLen = mergedPreviewRows?.length ?? 0;
418
+ const mergedMaxScroll = Math.max(0, mergedLen - previewHForMergedScroll);
419
+ const canScrollMergedPreview =
420
+ shellMode && shellMainTab === 0 && browsePreviewOpen && mergedLen > 0;
421
+ patchScrollRef.current = {
422
+ canScrollPatch: canScrollPatchShell,
423
+ maxPatchOffset,
424
+ step: Math.max(3, Math.min(10, patchPageSize)),
425
+ canScrollMergedPreview,
426
+ mergedMaxScroll,
427
+ mergedStep: Math.max(2, Math.min(8, previewHForMergedScroll))
428
+ };
429
+
430
+ useEffect(() => {
431
+ if (!shellMode || !stdin) {
432
+ return undefined;
433
+ }
434
+ const onData = (buf) => {
435
+ const s = buf.toString();
436
+ if (/\x1b\[15~/.test(s)) {
437
+ exitPayloadRef.current.next = { type: "refresh" };
438
+ exitRef.current();
439
+ return;
440
+ }
441
+ const r = patchScrollRef.current;
442
+ if (/\x1b\[5~/.test(s)) {
443
+ if (r.canScrollMergedPreview) {
444
+ setTreePreviewScroll((o) => Math.max(0, o - r.mergedStep));
445
+ } else if (r.canScrollPatch) {
446
+ setFilePatchOffset((o) => Math.max(0, o - r.step));
447
+ }
448
+ return;
449
+ }
450
+ if (/\x1b\[6~/.test(s)) {
451
+ if (r.canScrollMergedPreview) {
452
+ setTreePreviewScroll((o) => Math.min(r.mergedMaxScroll, o + r.mergedStep));
453
+ } else if (r.canScrollPatch) {
454
+ setFilePatchOffset((o) => Math.min(r.maxPatchOffset, o + r.step));
455
+ }
456
+ }
457
+ };
458
+ stdin.on("data", onData);
459
+ return () => {
460
+ stdin.off("data", onData);
461
+ };
462
+ }, [shellMode, stdin]);
463
+
464
+ const treeViewActive = shellMode ? shellMainTab === 0 : tab === 4;
465
+
466
+ useEffect(() => {
467
+ if (!treeViewActive || !browseOwner || !browseRepo || !headSha) {
468
+ return;
469
+ }
470
+ let cancelled = false;
471
+ setTreeLoading(true);
472
+ setTreeError(null);
473
+ setTreeLines([]);
474
+ setTreePreview("");
475
+ setTreePreviewPath("");
476
+ setTreePreviewScroll(0);
477
+ githubGetGitTree(browseOwner, browseRepo, headSha, true)
478
+ .then((data) => {
479
+ if (cancelled) return;
480
+ const tree = Array.isArray(data.tree) ? data.tree : [];
481
+ if (tree.length > 2500) {
482
+ setTreeError(`Large tree (${tree.length} entries); showing first 2500`);
483
+ }
484
+ const cap = tree.slice(0, 2500);
485
+ const lines = cap
486
+ .filter((t) => t && typeof t === "object" && typeof t.path === "string")
487
+ .map((t) => {
488
+ const o = /** @type {Record<string, unknown>} */ (t);
489
+ return { path: String(o.path), type: String(o.type || "blob") };
490
+ })
491
+ .sort((a, b) => a.path.localeCompare(b.path));
492
+ setTreeLines(lines);
493
+ setTreeLoading(false);
494
+ })
495
+ .catch((e) => {
496
+ if (!cancelled) {
497
+ setTreeError(String(/** @type {Error} */ (e)?.message || e));
498
+ setTreeLoading(false);
499
+ }
500
+ });
501
+ return () => {
502
+ cancelled = true;
503
+ };
504
+ }, [treeViewActive, browseOwner, browseRepo, headSha, safePr]);
505
+
506
+ useEffect(() => {
507
+ setTreeLineIndex(0);
508
+ setTreePreview("");
509
+ setTreePreviewPath("");
510
+ setTreePreviewScroll(0);
511
+ }, [tab, treeViewActive, headSha]);
512
+
513
+ const patchOffsetForLine = (lines, lineNo) => {
514
+ if (!lineNo || !Number.isInteger(lineNo) || lineNo < 1) return 0;
515
+ let newLine = 0;
516
+ for (let i = 0; i < lines.length; i++) {
517
+ const t = lines[i];
518
+ if (t.startsWith("@@")) {
519
+ const m = t.match(/\+(\d+)/);
520
+ if (m) newLine = Number.parseInt(m[1], 10) - 1;
521
+ continue;
522
+ }
523
+ if (t.startsWith("+") || t.startsWith(" ")) {
524
+ newLine += 1;
525
+ }
526
+ if (newLine >= lineNo) {
527
+ return Math.max(0, i - 2);
528
+ }
529
+ }
530
+ return 0;
531
+ };
532
+
533
+ const closeBrowsePreview = () => {
534
+ setTreePreview("");
535
+ setTreePreviewPath("");
536
+ setTreePreviewScroll(0);
537
+ };
538
+
539
+ const loadBrowseBlob = (blobPath) => {
540
+ if (!browseOwner || !browseRepo || !headSha || !blobPath) {
541
+ return;
542
+ }
543
+ githubGetBlobText(browseOwner, browseRepo, blobPath, headSha).then((text) => {
544
+ let t = text || "(empty or unreadable)";
545
+ if (t.length > MAX_PREVIEW_CHARS) {
546
+ t = t.slice(0, MAX_PREVIEW_CHARS) + "\n\u2026 [truncated at char limit]";
547
+ }
548
+ const linesArr = t.split("\n");
549
+ if (linesArr.length > MAX_PREVIEW_LINES) {
550
+ t = linesArr.slice(0, MAX_PREVIEW_LINES).join("\n") + "\n\u2026 [truncated at line limit]";
551
+ }
552
+ setTreePreviewPath(blobPath);
553
+ setTreePreview(t);
554
+ setTreePreviewScroll(0);
555
+ });
556
+ };
557
+
558
+ useInput((input, key) => {
559
+ if (shellMode && parseSgrMouse(input)) {
560
+ return;
561
+ }
562
+ const mouse = parseSgrMouse(input);
563
+ if (mouse) {
564
+ const L = layoutRef.current;
565
+ if (!L) {
566
+ return;
567
+ }
568
+ const { row, col, button, release } = mouse;
569
+ if (col < 2) {
570
+ return;
571
+ }
572
+
573
+ if (isWheelUp(button) || isWheelDown(button)) {
574
+ const down = isWheelDown(button);
575
+ const step = down ? 3 : -3;
576
+ const hit = regionsRef.current.find((z) => row >= z.r1 && row <= z.r2);
577
+ if (hit?.kind === "preview" && L.browsePreviewOpen && L.treePreviewLinesLen) {
578
+ const pv = Math.max(2, L.browsePreviewH || 2);
579
+ const maxS = Math.max(0, L.treePreviewLinesLen - pv);
580
+ setTreePreviewScroll((s) => Math.max(0, Math.min(s + step, maxS)));
581
+ return;
582
+ }
583
+ if (hit?.kind === "patch") {
584
+ setFilePatchOffset((i) =>
585
+ Math.max(0, Math.min(i + (down ? 3 : -3), L.maxPatchOffset || 0))
586
+ );
587
+ return;
588
+ }
589
+ if (hit?.kind === "issueLine" || hit?.kind === "reviewLine") {
590
+ if (down) {
591
+ setLineIndex((i) => Math.min(i + 1, Math.max(0, L.listLen - 1)));
592
+ } else {
593
+ setLineIndex((i) => Math.max(i - 1, 0));
594
+ }
595
+ return;
596
+ }
597
+ if (hit?.kind === "fileLine") {
598
+ if (down) {
599
+ setFileIndex((i) => Math.min(i + 1, Math.max(0, L.fileListLen - 1)));
600
+ setFilePatchOffset(0); setPatchCursorLine(0);
601
+ setFileCommentIndex(0);
602
+ } else {
603
+ setFileIndex((i) => Math.max(i - 1, 0));
604
+ setFilePatchOffset(0); setPatchCursorLine(0);
605
+ setFileCommentIndex(0);
606
+ }
607
+ return;
608
+ }
609
+ if (
610
+ hit?.kind === "treeLine" &&
611
+ L.treeLen > 0 &&
612
+ ((L.tab === 4 && !L.browsePreviewOpen) ||
613
+ (L.shellMainTab === 0 && L.shellMode))
614
+ ) {
615
+ if (L.shellMode && L.shellMainTab === 0 && L.shellStackFocus && L.len > 0) {
616
+ if (down) {
617
+ setPrIndex((i) => (i < 0 ? 0 : Math.min(i + 1, L.len - 1)));
618
+ } else {
619
+ setPrIndex((i) => (i <= 0 ? -1 : i - 1));
620
+ }
621
+ setTreeLineIndex(0);
622
+ setFilePatchOffset(0); setPatchCursorLine(0);
623
+ return;
624
+ }
625
+ if (L.shellMode && L.shellMainTab === 0 && L.shellStackFocus) {
626
+ return;
627
+ }
628
+ if (down) {
629
+ setTreeLineIndex((i) => Math.min(i + 1, Math.max(0, L.treeLen - 1)));
630
+ } else {
631
+ setTreeLineIndex((i) => Math.max(i - 1, 0));
632
+ }
633
+ return;
634
+ }
635
+ if (L.tab === 0 && L.len > 0) {
636
+ if (down) {
637
+ setPrIndex((i) => Math.min(i + 1, L.len - 1));
638
+ } else {
639
+ setPrIndex((i) => Math.max(i - 1, 0));
640
+ }
641
+ return;
642
+ }
643
+ if ((L.tab === 1 || L.tab === 2) && L.listLen > 0) {
644
+ if (down) {
645
+ setLineIndex((i) => Math.min(i + 1, Math.max(0, L.listLen - 1)));
646
+ } else {
647
+ setLineIndex((i) => Math.max(i - 1, 0));
648
+ }
649
+ return;
650
+ }
651
+ if (L.tab === 3) {
652
+ setFilePatchOffset((i) =>
653
+ Math.max(0, Math.min(i + (down ? 3 : -3), L.maxPatchOffset || 0))
654
+ );
655
+ return;
656
+ }
657
+ if (L.tab === 4 && L.browsePreviewOpen && L.treePreviewLinesLen) {
658
+ const pv = Math.max(2, L.browsePreviewH || 2);
659
+ const maxS = Math.max(0, L.treePreviewLinesLen - pv);
660
+ setTreePreviewScroll((s) => Math.max(0, Math.min(s + step, maxS)));
661
+ return;
662
+ }
663
+ if (L.tab === 4 && L.treeLen > 0) {
664
+ if (down) {
665
+ setTreeLineIndex((i) => Math.min(i + 1, L.treeLen - 1));
666
+ } else {
667
+ setTreeLineIndex((i) => Math.max(i - 1, 0));
668
+ }
669
+ }
670
+ return;
671
+ }
672
+
673
+ if (release) {
674
+ return;
675
+ }
676
+ const rightish = (button & 3) === 2 || button === 34;
677
+ if (rightish && L.browsePreviewOpen) {
678
+ closeBrowsePreview();
679
+ return;
680
+ }
681
+
682
+ const nLad = L.nLad;
683
+ if (row >= 3 && row <= 2 + nLad) {
684
+ setPrIndex(L.ladderStart + (row - 3));
685
+ return;
686
+ }
687
+
688
+ for (const z of regionsRef.current) {
689
+ if (row < z.r1 || row > z.r2) {
690
+ continue;
691
+ }
692
+ if (z.kind === "issueLine") {
693
+ setLineIndex(/** @type {number} */ (z.index));
694
+ return;
695
+ }
696
+ if (z.kind === "reviewLine") {
697
+ setLineIndex(/** @type {number} */ (z.index));
698
+ return;
699
+ }
700
+ if (z.kind === "fileLine") {
701
+ setFileIndex(/** @type {number} */ (z.index));
702
+ setFilePatchOffset(0); setPatchCursorLine(0);
703
+ setFileCommentIndex(0);
704
+ return;
705
+ }
706
+ if (z.kind === "treeLine") {
707
+ const idx = /** @type {number} */ (z.index);
708
+ const p = /** @type {string | null | undefined} */ (z.path);
709
+ setTreeLineIndex(idx);
710
+ const now = Date.now();
711
+ const prev = lastTreeClickRef.current;
712
+ const dbl = prev && prev.idx === idx && now - prev.t < 500;
713
+ lastTreeClickRef.current = { t: now, idx };
714
+ if (dbl && p) {
715
+ loadBrowseBlob(p);
716
+ }
717
+ return;
718
+ }
719
+ if (z.kind === "preview" || z.kind === "patch") {
720
+ return;
721
+ }
722
+ }
723
+ return;
724
+ }
725
+
726
+ const rScroll = patchScrollRef.current;
727
+ if (shellMode && shellMainTab === 0 && rScroll.canScrollMergedPreview) {
728
+ if (key.pageDown) {
729
+ setTreePreviewScroll((i) => Math.min(i + rScroll.mergedStep, rScroll.mergedMaxScroll));
730
+ return;
731
+ }
732
+ if (key.pageUp) {
733
+ setTreePreviewScroll((i) => Math.max(i - rScroll.mergedStep, 0));
734
+ return;
735
+ }
736
+ }
737
+ const shellPatchScroll =
738
+ shellMode &&
739
+ shellMainTab === 0 &&
740
+ asciiTreeRows[treeSafeLine]?.path === String(selectedFile?.filename || "") &&
741
+ patchLines.length > 0 &&
742
+ !browsePreviewOpen;
743
+ if (shellPatchScroll) {
744
+ if (key.pageDown) {
745
+ const { step, maxPatchOffset: maxO } = patchScrollRef.current;
746
+ setFilePatchOffset((i) => Math.min(i + step, maxO));
747
+ setPatchCursorLine((c) => Math.min(c + step, patchLines.length - 1));
748
+ return;
749
+ }
750
+ if (key.pageUp) {
751
+ const { step } = patchScrollRef.current;
752
+ setFilePatchOffset((i) => Math.max(i - step, 0));
753
+ setPatchCursorLine((c) => Math.max(c - step, 0));
754
+ return;
755
+ }
756
+ }
757
+
758
+ if (input === "?") {
759
+ setShowFullHelp((v) => !v);
760
+ return;
761
+ }
762
+
763
+ if (input === "C" && shellPatchScroll && row && selectedFile && !row.error) {
764
+ exitPayload.next = {
765
+ type: "pull_review_line_comment",
766
+ prNumber: row.entry.pr_number,
767
+ path: String(selectedFile.filename || ""),
768
+ patchLineIndex: patchCursorLine,
769
+ commitId: headSha
770
+ };
771
+ exit();
772
+ return;
773
+ }
774
+
775
+ const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
776
+ if (backKey) {
777
+ if (browsePreviewOpen) {
778
+ closeBrowsePreview();
779
+ return;
780
+ }
781
+ if (shellMode && shellStackFocus && canPickAlternateStack) {
782
+ exitPayload.next = { type: "pick_stack" };
783
+ exit();
784
+ return;
785
+ }
786
+ if (shellMode && shellStackFocus) {
787
+ setShellStackFocus(false);
788
+ return;
789
+ }
790
+ if (shellMode && shellMainTab === 0) {
791
+ setShellStackFocus(true);
792
+ return;
793
+ }
794
+ }
795
+
796
+ if ((key.return || input === " ") && shellMode && shellStackFocus) {
797
+ if (prIndex < 0 && len > 0) {
798
+ setPrIndex(0);
799
+ return;
800
+ }
801
+ setShellStackFocus(false);
802
+ return;
803
+ }
804
+
805
+ if (shellMode && /^[1-9]$/.test(input) && len > 0) {
806
+ const n = Number.parseInt(input, 10) - 1;
807
+ if (n < len) {
808
+ setShellStackFocus(false);
809
+ setPrIndex(n);
810
+ setShellMainTab(0);
811
+ setLineIndex(0);
812
+ setTreeLineIndex(0);
813
+ setFilePatchOffset(0); setPatchCursorLine(0);
814
+ return;
815
+ }
816
+ }
817
+
818
+ if (shellMode && input === "[" && len > 0) {
819
+ setShellStackFocus(false);
820
+ setPrIndex((i) => (i <= 0 ? -1 : i - 1));
821
+ setTreeLineIndex(0);
822
+ setFilePatchOffset(0); setPatchCursorLine(0);
823
+ return;
824
+ }
825
+ if (shellMode && input === "]" && len > 0) {
826
+ setShellStackFocus(false);
827
+ setPrIndex((i) => (i < 0 ? 0 : Math.min(len - 1, i + 1)));
828
+ setTreeLineIndex(0);
829
+ setFilePatchOffset(0); setPatchCursorLine(0);
830
+ return;
831
+ }
832
+ if (input === "O" && row?.pull?.html_url) {
833
+ openUrl(String(row.pull.html_url));
834
+ return;
835
+ }
836
+
837
+ if (input === "q" || key.escape) {
838
+ if (browsePreviewOpen) {
839
+ closeBrowsePreview();
840
+ return;
841
+ }
842
+ if (shellMode && shellStackFocus) {
843
+ setShellStackFocus(false);
844
+ return;
845
+ }
846
+ exitPayload.next = { type: "quit" };
847
+ exit();
848
+ return;
849
+ }
850
+ if (input === "u") {
851
+ exitPayload.next = { type: "refresh" };
852
+ exit();
853
+ return;
854
+ }
855
+ if (key.tab) {
856
+ if (shellMode && shellStackFocus) {
857
+ setShellStackFocus(false);
858
+ return;
859
+ }
860
+ if (shellMode && (!row || len === 0)) {
861
+ return;
862
+ }
863
+ if (shellMode) {
864
+ setShellMainTab((t) => (t + 1) % SHELL_TAB_COUNT);
865
+ setLineIndex(0);
866
+ return;
867
+ }
868
+ setTab((t) => (t + 1) % TAB_COUNT);
869
+ setLineIndex(0);
870
+ setTreeLineIndex(0);
871
+ setFilePatchOffset(0); setPatchCursorLine(0);
872
+ setFileCommentIndex(0);
873
+ return;
874
+ }
875
+ if (!shellMode && input === "[") {
876
+ setTab((t) => (t + TAB_COUNT - 1) % TAB_COUNT);
877
+ setLineIndex(0);
878
+ setTreeLineIndex(0);
879
+ setFilePatchOffset(0); setPatchCursorLine(0);
880
+ setFileCommentIndex(0);
881
+ return;
882
+ }
883
+ if (!shellMode && input === "]") {
884
+ setTab((t) => (t + 1) % TAB_COUNT);
885
+ setLineIndex(0);
886
+ setTreeLineIndex(0);
887
+ setFilePatchOffset(0); setPatchCursorLine(0);
888
+ setFileCommentIndex(0);
889
+ return;
890
+ }
891
+
892
+ if (input === "b" && browsePreviewOpen && (tab === 4 || (shellMode && shellMainTab === 0))) {
893
+ closeBrowsePreview();
894
+ return;
895
+ }
896
+
897
+ if (
898
+ browsePreviewOpen &&
899
+ previewLineCount > 0 &&
900
+ input === " " &&
901
+ (tab === 4 || (shellMode && shellMainTab === 0))
902
+ ) {
903
+ const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
904
+ const pv = Math.max(2, bodyMax - prevH - 2);
905
+ const maxS = Math.max(0, previewLineCount - pv);
906
+ setTreePreviewScroll((s) =>
907
+ Math.min(s + Math.max(1, Math.floor(pv / 2)), maxS)
908
+ );
909
+ return;
910
+ }
911
+
912
+ if (!shellMode && (key.return || input === " ") && tab === 0 && row?.pull?.html_url) {
913
+ openUrl(String(row.pull.html_url));
914
+ return;
915
+ }
916
+ if ((key.return || input === " ") && tab === 1) {
917
+ const ic = issueList[safeLine];
918
+ if (ic?.html_url) {
919
+ openUrl(String(ic.html_url));
920
+ return;
921
+ }
922
+ }
923
+ if ((key.return || input === " ") && tab === 2) {
924
+ const c = reviewList[safeLine];
925
+ if (c?.html_url) {
926
+ openUrl(String(c.html_url));
927
+ return;
928
+ }
929
+ }
930
+ if ((key.return || input === " ") && tab === 3 && fileList.length) {
931
+ setFileIndex((i) => Math.min(i + 1, fileList.length - 1));
932
+ setFilePatchOffset(0); setPatchCursorLine(0);
933
+ setFileCommentIndex(0);
934
+ return;
935
+ }
936
+
937
+ if (
938
+ (key.return || input === " ") &&
939
+ tab === 4 &&
940
+ !browsePreviewOpen &&
941
+ !shellMode &&
942
+ browseOwner &&
943
+ browseRepo &&
944
+ headSha &&
945
+ asciiTreeRows[safeLine]
946
+ ) {
947
+ const ent = asciiTreeRows[safeLine];
948
+ if (ent.path) {
949
+ loadBrowseBlob(ent.path);
950
+ }
951
+ return;
952
+ }
953
+
954
+ if (
955
+ shellMode &&
956
+ shellMainTab === 0 &&
957
+ (key.return || input === " ") &&
958
+ !shellStackFocus &&
959
+ !browsePreviewOpen &&
960
+ browseOwner &&
961
+ browseRepo &&
962
+ headSha &&
963
+ asciiTreeRows[treeSafeLine]
964
+ ) {
965
+ const ent = asciiTreeRows[treeSafeLine];
966
+ if (ent.path) {
967
+ loadBrowseBlob(ent.path);
968
+ }
969
+ return;
970
+ }
971
+
972
+ if (input === "j" || key.downArrow) {
973
+ if (shellMode && shellStackFocus && len > 0) {
974
+ setPrIndex((i) => (i < 0 ? 0 : Math.min(i + 1, len - 1)));
975
+ setTreeLineIndex(0);
976
+ setFilePatchOffset(0); setPatchCursorLine(0);
977
+ return;
978
+ }
979
+ if (shellMode && !shellStackFocus && shellMainTab === 0 && len > 0 && prIndex < 0) {
980
+ setShellStackFocus(true);
981
+ setPrIndex(0);
982
+ setTreeLineIndex(0);
983
+ setFilePatchOffset(0); setPatchCursorLine(0);
984
+ return;
985
+ }
986
+ if (
987
+ (shellMode && shellMainTab === 0 && browsePreviewOpen && previewLineCount > 0) ||
988
+ (!shellMode && tab === 4 && browsePreviewOpen && previewLineCount > 0)
989
+ ) {
990
+ const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
991
+ const pv = Math.max(2, bodyMax - prevH - 2);
992
+ const maxS = Math.max(0, previewLineCount - pv);
993
+ setTreePreviewScroll((s) => Math.min(s + 1, maxS));
994
+ return;
995
+ }
996
+ if (
997
+ shellMode &&
998
+ shellMainTab === 0 &&
999
+ !shellStackFocus &&
1000
+ !browsePreviewOpen &&
1001
+ asciiTreeRows.length
1002
+ ) {
1003
+ setTreeLineIndex((i) => Math.min(i + 1, asciiTreeRows.length - 1));
1004
+ return;
1005
+ }
1006
+ if (shellMode && shellMainTab > 0 && listLen > 0) {
1007
+ setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
1008
+ return;
1009
+ }
1010
+ if (tab === 0 && len > 0 && !shellMode) {
1011
+ setPrIndex((i) => Math.min(i + 1, len - 1));
1012
+ } else if (tab === 3) {
1013
+ setFilePatchOffset((i) => Math.min(i + 1, maxPatchOffset));
1014
+ } else if (tab === 4 && asciiTreeRows.length && !shellMode) {
1015
+ setTreeLineIndex((i) => Math.min(i + 1, asciiTreeRows.length - 1));
1016
+ } else {
1017
+ setLineIndex((i) => Math.min(i + 1, Math.max(0, listLen - 1)));
1018
+ }
1019
+ return;
1020
+ }
1021
+ if (input === "k" || key.upArrow) {
1022
+ if (shellMode && shellStackFocus && len > 0) {
1023
+ setPrIndex((i) => (i <= 0 ? -1 : i - 1));
1024
+ setTreeLineIndex(0);
1025
+ setFilePatchOffset(0); setPatchCursorLine(0);
1026
+ return;
1027
+ }
1028
+ if (shellMode && !shellStackFocus && shellMainTab === 0 && len > 0 && prIndex < 0) {
1029
+ setShellStackFocus(true);
1030
+ setPrIndex(Math.max(0, len - 1));
1031
+ setTreeLineIndex(0);
1032
+ setFilePatchOffset(0); setPatchCursorLine(0);
1033
+ return;
1034
+ }
1035
+ if (
1036
+ (shellMode && shellMainTab === 0 && browsePreviewOpen && previewLineCount > 0) ||
1037
+ (!shellMode && tab === 4 && browsePreviewOpen && previewLineCount > 0)
1038
+ ) {
1039
+ const prevH = Math.max(3, Math.floor(bodyMax * 0.35));
1040
+ const pv = Math.max(2, bodyMax - prevH - 2);
1041
+ const maxS = Math.max(0, previewLineCount - pv);
1042
+ setTreePreviewScroll((s) => Math.max(s - 1, 0));
1043
+ return;
1044
+ }
1045
+ if (
1046
+ shellMode &&
1047
+ shellMainTab === 0 &&
1048
+ !shellStackFocus &&
1049
+ !browsePreviewOpen &&
1050
+ asciiTreeRows.length
1051
+ ) {
1052
+ setTreeLineIndex((i) => Math.max(i - 1, 0));
1053
+ return;
1054
+ }
1055
+ if (shellMode && shellMainTab > 0 && listLen > 0) {
1056
+ setLineIndex((i) => Math.max(i - 1, 0));
1057
+ return;
1058
+ }
1059
+ if (tab === 0 && len > 0 && !shellMode) {
1060
+ setPrIndex((i) => Math.max(i - 1, 0));
1061
+ } else if (tab === 3) {
1062
+ setFilePatchOffset((i) => Math.max(i - 1, 0));
1063
+ } else if (tab === 4 && asciiTreeRows.length && !shellMode) {
1064
+ setTreeLineIndex((i) => Math.max(i - 1, 0));
1065
+ } else {
1066
+ setLineIndex((i) => Math.max(i - 1, 0));
1067
+ }
1068
+ return;
1069
+ }
1070
+
1071
+ if (input === "o" && row && !row.error) {
1072
+ if (shellMode) {
1073
+ setShellStackFocus(false);
1074
+ setShellMainTab(0);
1075
+ return;
1076
+ }
1077
+ setTab(3);
1078
+ setFileIndex(0);
1079
+ setFilePatchOffset(0); setPatchCursorLine(0);
1080
+ setFileCommentIndex(0);
1081
+ return;
1082
+ }
1083
+
1084
+ if (tab === 3 && input === "n") {
1085
+ setFileIndex((i) => Math.min(i + 1, Math.max(0, fileList.length - 1)));
1086
+ setFilePatchOffset(0); setPatchCursorLine(0);
1087
+ setFileCommentIndex(0);
1088
+ return;
1089
+ }
1090
+ if (tab === 3 && input === "p") {
1091
+ setFileIndex((i) => Math.max(i - 1, 0));
1092
+ setFilePatchOffset(0); setPatchCursorLine(0);
1093
+ setFileCommentIndex(0);
1094
+ return;
1095
+ }
1096
+ if (tab === 3 && input === "m" && fileComments.length) {
1097
+ setFileCommentIndex((i) => (i + 1) % fileComments.length);
1098
+ const c = fileComments[(safeFileComment + 1) % fileComments.length];
1099
+ const lineNo = c?.line ?? c?.original_line ?? 0;
1100
+ setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
1101
+ return;
1102
+ }
1103
+ if (tab === 3 && input === "M" && fileComments.length) {
1104
+ const next = (safeFileComment - 1 + fileComments.length) % fileComments.length;
1105
+ setFileCommentIndex(next);
1106
+ const c = fileComments[next];
1107
+ const lineNo = c?.line ?? c?.original_line ?? 0;
1108
+ setFilePatchOffset(Math.min(maxPatchOffset, patchOffsetForLine(patchLines, Number(lineNo) || 0)));
1109
+ return;
1110
+ }
1111
+
1112
+ if (input === "K" && row && !row.error && (!shellMode ? tab === 4 : shellMainTab === 0)) {
1113
+ exitPayload.next = {
1114
+ type: "materialize_clone",
1115
+ prNumber: row.entry.pr_number
1116
+ };
1117
+ exit();
1118
+ return;
1119
+ }
1120
+
1121
+ const reviewNav =
1122
+ tab === 2 || (shellMode && shellMainTab === 3);
1123
+ if (input === "l" && reviewNav) {
1124
+ const c = reviewList[safeLine];
1125
+ if (c?.html_url) {
1126
+ openUrl(c.html_url);
1127
+ }
1128
+ return;
1129
+ }
1130
+ if (input === "g" && reviewNav && row && !row.error) {
1131
+ const c = reviewList[safeLine];
1132
+ const targetPath = String(c?.path || "");
1133
+ if (targetPath) {
1134
+ const idx = fileList.findIndex((f) => String(f?.filename || "") === targetPath);
1135
+ if (idx >= 0) {
1136
+ if (shellMode) {
1137
+ setShellMainTab(0);
1138
+ setTab(4);
1139
+ const tidx = asciiTreeRows.findIndex((r) => r.path === targetPath);
1140
+ if (tidx >= 0) {
1141
+ setTreeLineIndex(tidx);
1142
+ }
1143
+ } else {
1144
+ setTab(3);
1145
+ }
1146
+ setFileIndex(idx);
1147
+ const selected = fileList[idx];
1148
+ const pls = typeof selected?.patch === "string" ? String(selected.patch).split("\n") : [];
1149
+ const lineNo = c?.line ?? c?.original_line ?? 0;
1150
+ const off = patchOffsetForLine(pls, Number(lineNo) || 0);
1151
+ setFilePatchOffset(off);
1152
+ const fcIdx = reviewList
1153
+ .filter((rc) => String(rc?.path || "") === targetPath)
1154
+ .findIndex((rc) => rc?.id === c?.id);
1155
+ setFileCommentIndex(fcIdx >= 0 ? fcIdx : 0);
1156
+ }
1157
+ }
1158
+ return;
1159
+ }
1160
+
1161
+ if (input === "S" && row && !row.error) {
1162
+ exitPayload.next = {
1163
+ type: "split",
1164
+ prNumber: row.entry.pr_number
1165
+ };
1166
+ exit();
1167
+ return;
1168
+ }
1169
+
1170
+ if (input === "a" && row && !row.error) {
1171
+ exitPayload.next = {
1172
+ type: "submit_review",
1173
+ prNumber: row.entry.pr_number,
1174
+ event: "APPROVE",
1175
+ body: ""
1176
+ };
1177
+ exit();
1178
+ return;
1179
+ }
1180
+
1181
+ if (input === "e" && row && !row.error) {
1182
+ exitPayload.next = {
1183
+ type: "submit_review_changes",
1184
+ prNumber: row.entry.pr_number
1185
+ };
1186
+ exit();
1187
+ return;
1188
+ }
1189
+
1190
+ if (input === "r" && row && !row.error) {
1191
+ exitPayload.next = {
1192
+ type: "issue_comment",
1193
+ prNumber: row.entry.pr_number
1194
+ };
1195
+ exit();
1196
+ return;
1197
+ }
1198
+
1199
+ if (input === "R" && row && !row.error) {
1200
+ exitPayload.next = {
1201
+ type: "request_reviewers",
1202
+ prNumber: row.entry.pr_number
1203
+ };
1204
+ exit();
1205
+ return;
1206
+ }
1207
+
1208
+ if (input === "t" && reviewNav && row && !row.error) {
1209
+ const c = reviewList[safeLine];
1210
+ if (c?.id != null) {
1211
+ exitPayload.next = {
1212
+ type: "review_reply",
1213
+ commentId: c.id
1214
+ };
1215
+ exit();
1216
+ }
1217
+ }
1218
+ });
1219
+
1220
+ if (len === 0) {
1221
+ layoutRef.current = null;
1222
+ regionsRef.current = [];
1223
+ return React.createElement(
1224
+ Box,
1225
+ { flexDirection: "column", width: cols, padding: 1 },
1226
+ React.createElement(Text, { color: "red" }, "No PRs in stack."),
1227
+ React.createElement(Text, { dimColor: true }, "Press q to quit.")
1228
+ );
1229
+ }
1230
+
1231
+ const ladderTruncW = shellMode ? Math.max(8, sidebarW - 2) : innerW;
1232
+ const ladderBlinkOn = shellStackFocus && Math.floor(pulse / 2) % 2 === 0;
1233
+ const ladderScrollAnchor = hasPrPick ? safePr : 0;
1234
+ const ladderRows = stackRows.map((r, i) => {
1235
+ const mark = hasPrPick && i === safePr ? ">" : " ";
1236
+ const err = r.error ? ` ${r.error}` : "";
1237
+ const title = r.pull?.title || err || "(loading)";
1238
+ const num = r.entry.pr_number;
1239
+ const st = r.pull?.draft ? "draft" : r.pull?.state || "?";
1240
+ const reviewState = String(r.reviewSummary || "none");
1241
+ const vm = r.viewerReviewMeta;
1242
+ const stale = vm?.staleApproval ? "!" : "";
1243
+ const risky = vm?.riskyChangeAfterApproval === true ? "*" : "";
1244
+ const badge =
1245
+ reviewState === "approved"
1246
+ ? "A"
1247
+ : reviewState === "changes_requested"
1248
+ ? "CR"
1249
+ : reviewState === "commented" || (r.reviewComments?.length || 0) > 0
1250
+ ? "C"
1251
+ : "-";
1252
+ const { add, del } = prAddDelTotals(r);
1253
+ const head = `${mark} #${num} `;
1254
+ const statPlain = `+${add} -${del} `;
1255
+ const meta = `[${st}] ${badge}${stale}${risky} `;
1256
+ const titleBudget = Math.max(4, ladderTruncW - head.length - statPlain.length - meta.length);
1257
+ const titleT = truncVis(title, titleBudget);
1258
+ const lineColored =
1259
+ chalk.white(head) +
1260
+ chalk.green(`+${add}`) +
1261
+ chalk.white(" ") +
1262
+ chalk.red(`-${del}`) +
1263
+ chalk.white(` ${meta}${titleT}`);
1264
+ if (hasPrPick && i === safePr) {
1265
+ if (shellMode && shellStackFocus) {
1266
+ return ladderBlinkOn ? chalk.inverse(lineColored) : lineColored;
1267
+ }
1268
+ return chalk.inverse(lineColored);
1269
+ }
1270
+ return lineColored;
1271
+ });
1272
+ const { slice: ladderVis, start: ladderStart } = sliceViewport(
1273
+ ladderRows,
1274
+ ladderScrollAnchor,
1275
+ ladderMaxForDisplay
1276
+ );
1277
+
1278
+ const tabName = ["overview", "conversation", "review", "files", "browse"][tab];
1279
+ const pageLabel = tabName;
1280
+ const nLad = ladderVis.length;
1281
+ const bodyStartR = shellMode ? 6 : 4 + nLad;
1282
+ let rowPtr = bodyStartR;
1283
+ /** @type {string[]} */
1284
+ let bodyLines = [];
1285
+ /** @type {Array<{ r1: number, r2: number, kind: string, [k: string]: unknown }>} */
1286
+ let regions = [];
1287
+ let layoutBrowsePreviewH = 0;
1288
+ let layoutTreeBlockH = 0;
1289
+
1290
+ const pushBody = (line) => {
1291
+ const r = rowPtr;
1292
+ bodyLines.push(line);
1293
+ rowPtr++;
1294
+ return r;
1295
+ };
1296
+
1297
+ if (row?.error) {
1298
+ pushBody(truncVis(row.error, innerW));
1299
+ } else if (shellMode && !row) {
1300
+ if (shellMainTab === 0) {
1301
+ pushBody(
1302
+ chalk.dim(
1303
+ "Pick a PR from the ladder: j/k or [ ] \u00b7 Enter selects and moves focus to the file tree"
1304
+ )
1305
+ );
1306
+ } else {
1307
+ pushBody(chalk.dim("Select a PR in the sidebar to use this tab."));
1308
+ }
1309
+ } else if (shellMode && row?.pull) {
1310
+ if (shellMainTab === 0) {
1311
+ const selPath = asciiTreeRows[treeSafeLine]?.path;
1312
+ const showPatch =
1313
+ !!selPath &&
1314
+ selPath === String(selectedFile?.filename || "") &&
1315
+ patchLines.length > 0 &&
1316
+ !browsePreviewOpen;
1317
+
1318
+ const panes =
1319
+ shellTreePanes ||
1320
+ computeShellTreePaneHeights(bodyMax, browsePreviewOpen, showPatch);
1321
+ const treeBlockH = panes.treeBlockH;
1322
+ const patchH = showPatch ? panes.patchH : 0;
1323
+ const previewH = browsePreviewOpen ? panes.previewH : 0;
1324
+ layoutTreeBlockH = treeBlockH;
1325
+ layoutBrowsePreviewH = previewH;
1326
+
1327
+ if (treeLoading) {
1328
+ pushBody("Loading tree\u2026");
1329
+ } else if (treeError) {
1330
+ pushBody(truncVis(treeError, innerW));
1331
+ }
1332
+ const previewPathOpen = browsePreviewOpen && treePreviewPath ? treePreviewPath : "";
1333
+ const treeDisplay = asciiTreeRows.map((r, i) => {
1334
+ const mark = i === treeSafeLine ? ">" : " ";
1335
+ const f = r.path ? pathToChangedFile.get(r.path) : undefined;
1336
+ const add = f ? Number(f.additions) || 0 : 0;
1337
+ const del = f ? Number(f.deletions) || 0 : 0;
1338
+ const flag = f
1339
+ ? add > 0 && del > 0
1340
+ ? chalk.yellow("*")
1341
+ : add > 0
1342
+ ? chalk.green("+")
1343
+ : del > 0
1344
+ ? chalk.red("-")
1345
+ : chalk.cyan("*")
1346
+ : " ";
1347
+ const tw = Math.max(8, innerW - 4);
1348
+ const plainT = truncVis(r.text, tw);
1349
+ let lineText = plainT;
1350
+ if (f) {
1351
+ if (add > 0 && del > 0) {
1352
+ lineText = chalk.yellow(plainT);
1353
+ } else if (add > 0) {
1354
+ lineText = chalk.green(plainT);
1355
+ } else if (del > 0) {
1356
+ lineText = chalk.red(plainT);
1357
+ } else {
1358
+ lineText = chalk.cyan(plainT);
1359
+ }
1360
+ }
1361
+ if (r.path && previewPathOpen && r.path === previewPathOpen) {
1362
+ lineText = chalk.inverse(lineText);
1363
+ }
1364
+ return `${mark}${flag} ${lineText}`;
1365
+ });
1366
+ const { slice: treeSlice, start: treeStart } = sliceViewport(treeDisplay, treeSafeLine, treeBlockH);
1367
+ if (treeStart > 0) {
1368
+ pushBody(chalk.dim(`\u2191 tree ${treeStart} more`));
1369
+ }
1370
+ const treeSliceStartR = rowPtr;
1371
+ for (const ln of treeSlice) {
1372
+ pushBody(ln);
1373
+ }
1374
+ for (let li = 0; li < treeSlice.length; li++) {
1375
+ const idx = treeStart + li;
1376
+ const tr = asciiTreeRows[idx];
1377
+ regions.push({
1378
+ kind: "treeLine",
1379
+ r1: treeSliceStartR + li,
1380
+ r2: treeSliceStartR + li,
1381
+ index: idx,
1382
+ path: tr?.path ?? null
1383
+ });
1384
+ }
1385
+ if (treeStart + treeSlice.length < treeDisplay.length) {
1386
+ pushBody(chalk.dim(`\u2193 tree ${treeDisplay.length - treeStart - treeSlice.length} more`));
1387
+ }
1388
+ if (!treeLoading && asciiTreeRows.length === 0 && !treeError) {
1389
+ pushBody("(empty tree)");
1390
+ }
1391
+
1392
+ if (showPatch) {
1393
+ pushBody("");
1394
+ const safePatchCursor = Math.min(patchCursorLine, patchLines.length - 1);
1395
+ pushBody(
1396
+ chalk.bold(
1397
+ truncVis(
1398
+ `PR diff: ${String(selectedFile?.filename || "?")} (${filePatchOffset + 1}\u2013${Math.min(filePatchOffset + patchPageSize, patchLines.length)}/${patchLines.length}) C: comment on line`,
1399
+ innerW
1400
+ )
1401
+ )
1402
+ );
1403
+ const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
1404
+ let used = bodyLines.length;
1405
+ const patchStartR = rowPtr;
1406
+ for (let pi = 0; pi < page.length; pi++) {
1407
+ if (bodyLines.length - used >= patchH) {
1408
+ break;
1409
+ }
1410
+ const absIdx = filePatchOffset + pi;
1411
+ const isCursor = absIdx === safePatchCursor;
1412
+ const pl = page[pi];
1413
+ let line = pl;
1414
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) {
1415
+ line = chalk.bold(line);
1416
+ } else if (line.startsWith("@@")) {
1417
+ line = chalk.cyan(line);
1418
+ } else if (line.startsWith("+")) {
1419
+ line = chalk.green(line);
1420
+ } else if (line.startsWith("-")) {
1421
+ line = chalk.red(line);
1422
+ } else {
1423
+ line = chalk.gray(line);
1424
+ }
1425
+ if (isCursor) {
1426
+ line = chalk.inverse(line);
1427
+ }
1428
+ pushBody(truncVis(line, innerW));
1429
+ }
1430
+ const patchEndR = rowPtr - 1;
1431
+ if (patchEndR >= patchStartR) {
1432
+ regions.push({ kind: "patch", r1: patchStartR, r2: patchEndR });
1433
+ }
1434
+ if (filePatchOffset + patchPageSize < patchLines.length) {
1435
+ pushBody(chalk.dim("PgUp/Dn scroll · C: comment on highlighted line"));
1436
+ }
1437
+ } else if (!browsePreviewOpen && !showPatch && asciiTreeRows.length && !treeLoading) {
1438
+ pushBody(
1439
+ chalk.dim(
1440
+ "Highlight a path marked +/−/* for this PR\u2019s diff. Enter opens file text."
1441
+ )
1442
+ );
1443
+ }
1444
+
1445
+ if (browsePreviewOpen && treePreviewPath) {
1446
+ pushBody("");
1447
+ const merged = mergedPreviewRows;
1448
+ const sub =
1449
+ merged && merged.length > 0
1450
+ ? "diff in file "
1451
+ : "";
1452
+ pushBody(
1453
+ chalk.bold(truncVis(`File: ${treePreviewPath}`, innerW)) +
1454
+ chalk.whiteBright(
1455
+ ` ${sub}[${treePreviewScroll + 1}-${Math.min(treePreviewScroll + previewH, previewLineCount)}/${previewLineCount}] wheel/Space scroll Backspace closes`
1456
+ )
1457
+ );
1458
+ const prevSlice = merged
1459
+ ? merged.slice(treePreviewScroll, treePreviewScroll + previewH)
1460
+ : treePreviewLines.slice(treePreviewScroll, treePreviewScroll + previewH).map((t) => ({
1461
+ text: t,
1462
+ kind: /** @type {"ctx"} */ ("ctx")
1463
+ }));
1464
+ const prevStartR = rowPtr;
1465
+ for (const row of prevSlice) {
1466
+ const tv = truncVis(row.text, innerW);
1467
+ const styled =
1468
+ row.kind === "add"
1469
+ ? chalk.green(tv)
1470
+ : row.kind === "del"
1471
+ ? chalk.red(tv)
1472
+ : chalk.whiteBright(tv);
1473
+ pushBody(styled);
1474
+ }
1475
+ const prevEndR = rowPtr - 1;
1476
+ if (prevEndR >= prevStartR) {
1477
+ regions.push({ kind: "preview", r1: prevStartR, r2: prevEndR });
1478
+ }
1479
+ }
1480
+ } else if (shellMainTab === 1) {
1481
+ const bodyRaw = typeof row.pull.body === "string" ? row.pull.body : "";
1482
+ const plain = markdownToPlainLines(bodyRaw);
1483
+ const bl = plain ? plain.split("\n") : [];
1484
+ const lines = bl.length ? bl : ["(no description)"];
1485
+ const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1486
+ if (start > 0) {
1487
+ pushBody(chalk.dim(`\u2191 ${start} lines above`));
1488
+ }
1489
+ for (let si = 0; si < slice.length; si++) {
1490
+ const ln = slice[si];
1491
+ const gi = start + si;
1492
+ const out = truncVis(ln, innerW);
1493
+ pushBody(gi === safeLine ? chalk.inverse(out) : out);
1494
+ }
1495
+ if (start + slice.length < lines.length) {
1496
+ pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1497
+ }
1498
+ } else if (shellMainTab === 2) {
1499
+ const lines = issueList.map((c, i) => {
1500
+ const mark = i === safeLine ? ">" : " ";
1501
+ const who = c.user?.login || "?";
1502
+ const one = (c.body || "").split("\n")[0];
1503
+ const t = truncVis(`${mark} @${who}: ${one}`, innerW);
1504
+ return i === safeLine ? chalk.inverse(t) : t;
1505
+ });
1506
+ const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1507
+ if (start > 0) {
1508
+ pushBody(chalk.dim(`\u2191 ${start} more`));
1509
+ }
1510
+ const sliceStartR = rowPtr;
1511
+ for (const ln of slice) {
1512
+ pushBody(ln);
1513
+ }
1514
+ for (let li = 0; li < slice.length; li++) {
1515
+ regions.push({
1516
+ kind: "issueLine",
1517
+ r1: sliceStartR + li,
1518
+ r2: sliceStartR + li,
1519
+ index: start + li
1520
+ });
1521
+ }
1522
+ if (start + slice.length < lines.length) {
1523
+ pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1524
+ }
1525
+ if (lines.length === 0) {
1526
+ pushBody("(no conversation comments)");
1527
+ }
1528
+ } else if (shellMainTab === 3) {
1529
+ const lines = reviewList.map((c, i) => {
1530
+ const mark = i === safeLine ? ">" : " ";
1531
+ const path = c.path || "?";
1532
+ const ln = c.line ?? c.original_line ?? "?";
1533
+ const one = (c.body || "").split("\n")[0];
1534
+ const t = truncVis(`${mark} ${path}:${ln} ${one}`, innerW);
1535
+ return i === safeLine ? chalk.inverse(t) : t;
1536
+ });
1537
+ const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1538
+ if (start > 0) {
1539
+ pushBody(chalk.dim(`\u2191 ${start} more`));
1540
+ }
1541
+ const sliceStartR = rowPtr;
1542
+ for (const ln of slice) {
1543
+ pushBody(ln);
1544
+ }
1545
+ for (let li = 0; li < slice.length; li++) {
1546
+ regions.push({
1547
+ kind: "reviewLine",
1548
+ r1: sliceStartR + li,
1549
+ r2: sliceStartR + li,
1550
+ index: start + li
1551
+ });
1552
+ }
1553
+ if (start + slice.length < lines.length) {
1554
+ pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1555
+ }
1556
+ if (lines.length === 0) {
1557
+ pushBody("(no review thread comments)");
1558
+ }
1559
+ }
1560
+ } else if (tab === 0 && row?.pull && !shellMode) {
1561
+ const p = row.pull;
1562
+ pushBody(truncVis(`Title: ${p.title || ""}`, innerW));
1563
+ pushBody(truncVis(`Head: ${p.head?.ref || ""} Base: ${p.base?.ref || ""}`, innerW));
1564
+ pushBody(
1565
+ truncVis(`Comments: ${issueList.length} conv / ${reviewList.length} review (line)`, innerW)
1566
+ );
1567
+ const rs = String(row.reviewSummary || "none");
1568
+ pushBody(
1569
+ truncVis(
1570
+ `Review: ${
1571
+ rs === "approved"
1572
+ ? "approved"
1573
+ : rs === "changes_requested"
1574
+ ? "changes requested"
1575
+ : rs === "commented"
1576
+ ? "commented"
1577
+ : "no review state"
1578
+ }`,
1579
+ innerW
1580
+ )
1581
+ );
1582
+ const vm = row.viewerReviewMeta;
1583
+ if (vm) {
1584
+ if (vm.dismissedReview) {
1585
+ pushBody(truncVis("Note: a prior review may have been dismissed/superseded.", innerW));
1586
+ }
1587
+ if (vm.staleApproval) {
1588
+ pushBody(
1589
+ truncVis(
1590
+ `Your approval is on an older commit (+${vm.commitsSinceApproval} commits). Press a to re-approve.`,
1591
+ innerW
1592
+ )
1593
+ );
1594
+ }
1595
+ if (vm.riskyChangeAfterApproval === true) {
1596
+ pushBody(truncVis("Commits since your approval look non-merge — please re-read.", innerW));
1597
+ } else if (vm.riskyChangeAfterApproval === false && vm.staleApproval) {
1598
+ pushBody(truncVis("Delta since your approval looks merge-only.", innerW));
1599
+ }
1600
+ }
1601
+ } else if (tab === 1 && !shellMode) {
1602
+ const lines = issueList.map((c, i) => {
1603
+ const mark = i === safeLine ? ">" : " ";
1604
+ const who = c.user?.login || "?";
1605
+ const one = (c.body || "").split("\n")[0];
1606
+ return truncVis(`${mark} @${who}: ${one}`, innerW);
1607
+ });
1608
+ const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1609
+ if (start > 0) {
1610
+ pushBody(chalk.dim(`\u2191 ${start} more`));
1611
+ }
1612
+ const sliceStartR = rowPtr;
1613
+ for (const ln of slice) {
1614
+ pushBody(ln);
1615
+ }
1616
+ for (let li = 0; li < slice.length; li++) {
1617
+ regions.push({
1618
+ kind: "issueLine",
1619
+ r1: sliceStartR + li,
1620
+ r2: sliceStartR + li,
1621
+ index: start + li
1622
+ });
1623
+ }
1624
+ if (start + slice.length < lines.length) {
1625
+ pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1626
+ }
1627
+ if (lines.length === 0) {
1628
+ pushBody("(no issue comments)");
1629
+ }
1630
+ } else if (tab === 2 && !shellMode) {
1631
+ const lines = reviewList.map((c, i) => {
1632
+ const mark = i === safeLine ? ">" : " ";
1633
+ const path = c.path || "?";
1634
+ const ln = c.line ?? c.original_line ?? "?";
1635
+ const one = (c.body || "").split("\n")[0];
1636
+ return truncVis(`${mark} ${path}:${ln} ${one}`, innerW);
1637
+ });
1638
+ const { slice, start } = sliceViewport(lines, safeLine, bodyMax);
1639
+ if (start > 0) {
1640
+ pushBody(chalk.dim(`\u2191 ${start} more`));
1641
+ }
1642
+ const sliceStartR = rowPtr;
1643
+ for (const ln of slice) {
1644
+ pushBody(ln);
1645
+ }
1646
+ for (let li = 0; li < slice.length; li++) {
1647
+ regions.push({
1648
+ kind: "reviewLine",
1649
+ r1: sliceStartR + li,
1650
+ r2: sliceStartR + li,
1651
+ index: start + li
1652
+ });
1653
+ }
1654
+ if (start + slice.length < lines.length) {
1655
+ pushBody(chalk.dim(`\u2193 ${lines.length - start - slice.length} more`));
1656
+ }
1657
+ if (lines.length === 0) {
1658
+ pushBody("(no review comments)");
1659
+ pushBody(chalk.dim("Tip: press g on a review comment to jump to file diff"));
1660
+ }
1661
+ } else if (tab === 3) {
1662
+ const fileListH = Math.min(6, Math.max(3, Math.floor(bodyMax * 0.28)));
1663
+ const patchH = Math.max(3, bodyMax - fileListH - 3);
1664
+ const flLines = fileList.map((f, i) => {
1665
+ const mark = i === safeFile ? ">" : " ";
1666
+ const name = String(f.filename || "?");
1667
+ const st = String(f.status || "?");
1668
+ const add = Number(f.additions) || 0;
1669
+ const del = Number(f.deletions) || 0;
1670
+ const ch = `+${add} -${del}`;
1671
+ const meta = chalk.gray(`[${st}] ${ch}`);
1672
+ const nameTrunc = truncVis(name, Math.max(12, innerW - 18));
1673
+ let nameStyled = chalk.whiteBright(nameTrunc);
1674
+ if (add > 0 && del > 0) {
1675
+ nameStyled = chalk.yellow(nameTrunc);
1676
+ } else if (add > 0) {
1677
+ nameStyled = chalk.green(nameTrunc);
1678
+ } else if (del > 0) {
1679
+ nameStyled = chalk.red(nameTrunc);
1680
+ }
1681
+ return `${mark} ${meta} ${nameStyled}`;
1682
+ });
1683
+ const { slice: flSlice, start: flStart } = sliceViewport(flLines, safeFile, fileListH);
1684
+ if (fileList.length === 0) {
1685
+ pushBody("(no changed files)");
1686
+ } else {
1687
+ if (flStart > 0) {
1688
+ pushBody(chalk.dim(`\u2191 files ${flStart} more`));
1689
+ }
1690
+ const flStartR = rowPtr;
1691
+ for (const ln of flSlice) {
1692
+ pushBody(ln);
1693
+ }
1694
+ for (let li = 0; li < flSlice.length; li++) {
1695
+ regions.push({
1696
+ kind: "fileLine",
1697
+ r1: flStartR + li,
1698
+ r2: flStartR + li,
1699
+ index: flStart + li
1700
+ });
1701
+ }
1702
+ if (flStart + flSlice.length < flLines.length) {
1703
+ pushBody(chalk.dim(`\u2193 files ${flLines.length - flStart - flSlice.length} more`));
1704
+ }
1705
+ if (selectedFile) {
1706
+ pushBody("");
1707
+ pushBody(
1708
+ truncVis(
1709
+ `Patch: ${String(selectedFile.filename || "?")} (${filePatchOffset + 1}-${Math.min(filePatchOffset + patchPageSize, patchLines.length)}/${patchLines.length || 0})`,
1710
+ innerW
1711
+ )
1712
+ );
1713
+ const patch = typeof selectedFile.patch === "string" ? selectedFile.patch : "";
1714
+ if (!patch) {
1715
+ pushBody("(patch not available from GitHub API for this file)");
1716
+ } else {
1717
+ const page = patchLines.slice(filePatchOffset, filePatchOffset + patchPageSize);
1718
+ let used = bodyLines.length;
1719
+ const patchStartR = rowPtr;
1720
+ for (const pl of page) {
1721
+ if (bodyLines.length - used >= patchH) {
1722
+ break;
1723
+ }
1724
+ let line = pl;
1725
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) {
1726
+ line = chalk.bold(line);
1727
+ } else if (line.startsWith("@@")) {
1728
+ line = chalk.cyan(line);
1729
+ } else if (line.startsWith("+")) {
1730
+ line = chalk.green(line);
1731
+ } else if (line.startsWith("-")) {
1732
+ line = chalk.red(line);
1733
+ } else {
1734
+ line = chalk.gray(line);
1735
+ }
1736
+ pushBody(truncVis(line, innerW));
1737
+ }
1738
+ const patchEndR = rowPtr - 1;
1739
+ if (patchEndR >= patchStartR) {
1740
+ regions.push({ kind: "patch", r1: patchStartR, r2: patchEndR });
1741
+ }
1742
+ if (filePatchOffset + patchPageSize < patchLines.length) {
1743
+ pushBody(chalk.dim("j/k = more patch"));
1744
+ }
1745
+ }
1746
+ if (fileComments.length) {
1747
+ const c = fileComments[safeFileComment];
1748
+ pushBody(
1749
+ truncVis(
1750
+ `Comment ${safeFileComment + 1}/${fileComments.length} @${c?.user?.login || "?"}`,
1751
+ innerW
1752
+ )
1753
+ );
1754
+ pushBody(truncVis(String(c?.body || "").split("\n")[0], innerW));
1755
+ }
1756
+ }
1757
+ }
1758
+ } else if (tab === 4 && !shellMode) {
1759
+ const treeBlockH = browsePreviewOpen
1760
+ ? Math.max(4, Math.floor(bodyMax * 0.38))
1761
+ : bodyMax - 1;
1762
+ const previewH = browsePreviewOpen ? Math.max(3, bodyMax - treeBlockH - 1) : 0;
1763
+ layoutTreeBlockH = treeBlockH;
1764
+ layoutBrowsePreviewH = previewH;
1765
+
1766
+ if (treeLoading) {
1767
+ pushBody("Loading tree\u2026");
1768
+ } else if (treeError) {
1769
+ pushBody(truncVis(treeError, innerW));
1770
+ }
1771
+ const previewPathOpenNs = browsePreviewOpen && treePreviewPath ? treePreviewPath : "";
1772
+ const treeDisplay = asciiTreeRows.map((r, i) => {
1773
+ const mark = i === safeLine ? ">" : " ";
1774
+ const f = r.path ? pathToChangedFile.get(r.path) : undefined;
1775
+ const add = f ? Number(f.additions) || 0 : 0;
1776
+ const del = f ? Number(f.deletions) || 0 : 0;
1777
+ const flag = f
1778
+ ? add > 0 && del > 0
1779
+ ? chalk.yellow("*")
1780
+ : add > 0
1781
+ ? chalk.green("+")
1782
+ : del > 0
1783
+ ? chalk.red("-")
1784
+ : chalk.cyan("*")
1785
+ : " ";
1786
+ const tw = Math.max(8, innerW - 4);
1787
+ const plainT = truncVis(r.text, tw);
1788
+ let lineText = plainT;
1789
+ if (f) {
1790
+ if (add > 0 && del > 0) {
1791
+ lineText = chalk.yellow(plainT);
1792
+ } else if (add > 0) {
1793
+ lineText = chalk.green(plainT);
1794
+ } else if (del > 0) {
1795
+ lineText = chalk.red(plainT);
1796
+ } else {
1797
+ lineText = chalk.cyan(plainT);
1798
+ }
1799
+ }
1800
+ if (r.path && previewPathOpenNs && r.path === previewPathOpenNs) {
1801
+ lineText = chalk.inverse(lineText);
1802
+ }
1803
+ return `${mark}${flag} ${lineText}`;
1804
+ });
1805
+ const { slice: treeSlice, start: treeStart } = sliceViewport(treeDisplay, safeLine, treeBlockH);
1806
+ if (treeStart > 0) {
1807
+ pushBody(chalk.dim(`\u2191 tree ${treeStart} more`));
1808
+ }
1809
+ const treeSliceStartR = rowPtr;
1810
+ for (const ln of treeSlice) {
1811
+ pushBody(ln);
1812
+ }
1813
+ for (let li = 0; li < treeSlice.length; li++) {
1814
+ const idx = treeStart + li;
1815
+ const tr = asciiTreeRows[idx];
1816
+ regions.push({
1817
+ kind: "treeLine",
1818
+ r1: treeSliceStartR + li,
1819
+ r2: treeSliceStartR + li,
1820
+ index: idx,
1821
+ path: tr?.path ?? null
1822
+ });
1823
+ }
1824
+ if (treeStart + treeSlice.length < treeDisplay.length) {
1825
+ pushBody(chalk.dim(`\u2193 tree ${treeDisplay.length - treeStart - treeSlice.length} more`));
1826
+ }
1827
+ if (!treeLoading && asciiTreeRows.length === 0 && !treeError) {
1828
+ pushBody("(empty tree)");
1829
+ }
1830
+
1831
+ if (browsePreviewOpen && treePreviewPath) {
1832
+ pushBody("");
1833
+ const mergedNs = mergedPreviewRows;
1834
+ const subNs = mergedNs && mergedNs.length > 0 ? "diff in file " : "";
1835
+ pushBody(
1836
+ chalk.bold(truncVis(`File: ${treePreviewPath}`, innerW)) +
1837
+ chalk.whiteBright(
1838
+ ` ${subNs}[${treePreviewScroll + 1}-${Math.min(treePreviewScroll + previewH, previewLineCount)}/${previewLineCount}] wheel/Space scroll BS/Esc back`
1839
+ )
1840
+ );
1841
+ const prevSliceNs = mergedNs
1842
+ ? mergedNs.slice(treePreviewScroll, treePreviewScroll + previewH)
1843
+ : treePreviewLines.slice(treePreviewScroll, treePreviewScroll + previewH).map((t) => ({
1844
+ text: t,
1845
+ kind: /** @type {"ctx"} */ ("ctx")
1846
+ }));
1847
+ const prevStartRNs = rowPtr;
1848
+ for (const row of prevSliceNs) {
1849
+ const tv = truncVis(row.text, innerW);
1850
+ const styled =
1851
+ row.kind === "add"
1852
+ ? chalk.green(tv)
1853
+ : row.kind === "del"
1854
+ ? chalk.red(tv)
1855
+ : chalk.whiteBright(tv);
1856
+ pushBody(styled);
1857
+ }
1858
+ const prevEndRNs = rowPtr - 1;
1859
+ if (prevEndRNs >= prevStartRNs) {
1860
+ regions.push({ kind: "preview", r1: prevStartRNs, r2: prevEndRNs });
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ if (bodyLines.length > bodyMax) {
1866
+ const lastR = bodyStartR + bodyMax - 1;
1867
+ bodyLines = bodyLines.slice(0, bodyMax);
1868
+ regions = regions
1869
+ .filter((reg) => reg.r1 <= lastR)
1870
+ .map((reg) => ({ ...reg, r2: Math.min(reg.r2, lastR) }))
1871
+ .filter((reg) => reg.r1 <= reg.r2);
1872
+ }
1873
+
1874
+ const browsePreviewHForLayout = treeViewActive ? layoutBrowsePreviewH : 0;
1875
+ layoutRef.current = {
1876
+ bodyStartR,
1877
+ nLad: shellMode ? 0 : nLad,
1878
+ ladderStart,
1879
+ browsePreviewH: browsePreviewHForLayout,
1880
+ bodyMax,
1881
+ tab,
1882
+ shellMode,
1883
+ shellMainTab: shellMode ? shellMainTab : undefined,
1884
+ shellStackFocus: shellMode ? shellStackFocus : false,
1885
+ browsePreviewOpen,
1886
+ treePreviewLinesLen: previewLineCount,
1887
+ maxPatchOffset,
1888
+ patchPageSize,
1889
+ listLen,
1890
+ len,
1891
+ fileListLen: fileList.length,
1892
+ treeLen: asciiTreeRows.length
1893
+ };
1894
+ regionsRef.current = regions;
1895
+
1896
+ const selectedReview =
1897
+ tab === 2 || (shellMode && shellMainTab === 3) ? reviewList[safeLine] : null;
1898
+ const helpParts = ["Tab/[]", "o files", "j/k", "Enter/Space", "BS back", "wheel", "u", "q"];
1899
+ if (tab === 0) {
1900
+ helpParts.push("PR ladder+mouse");
1901
+ }
1902
+ if (tab === 3) {
1903
+ helpParts.push("n/p m/M");
1904
+ }
1905
+ if (tab === 4) {
1906
+ if (browsePreviewOpen) {
1907
+ helpParts.push("Space page", "b");
1908
+ } else {
1909
+ helpParts.push("tree click", "dbl-open", "K");
1910
+ }
1911
+ }
1912
+ if (row && !row.error) {
1913
+ if (tab === 0) {
1914
+ helpParts.push("S a e");
1915
+ }
1916
+ helpParts.push("r R");
1917
+ }
1918
+ if (tab === 2 && selectedReview?.html_url) {
1919
+ helpParts.push("l g t");
1920
+ }
1921
+ if (shellMode) {
1922
+ helpParts.push("[ ] PR", "1-9", "PgUp/Dn diff", "O", "K", "Enter Space", "BS stack", "b preview", "Esc q", "?");
1923
+ }
1924
+ const helpRaw = helpParts.join(" · ");
1925
+ const fullFooterW = Math.max(40, cols - 2);
1926
+ /** @type {string} */
1927
+ let helpLine1;
1928
+ /** @type {string} */
1929
+ let helpLine2;
1930
+ /** @type {string} */
1931
+ let helpLine3 = "";
1932
+ if (shellMode) {
1933
+ if (showFullHelp) {
1934
+ helpLine1 = truncVis(helpRaw, fullFooterW);
1935
+ helpLine2 =
1936
+ helpRaw.length > fullFooterW ? truncVis(helpRaw.slice(fullFooterW, fullFooterW * 2), fullFooterW) : "";
1937
+ helpLine3 =
1938
+ helpRaw.length > fullFooterW * 2 ? truncVis(helpRaw.slice(fullFooterW * 2), fullFooterW) : "";
1939
+ } else if (shellStackFocus) {
1940
+ helpLine1 = "Tab to cycle functions.";
1941
+ helpLine2 = truncVis(
1942
+ canPickAlternateStack
1943
+ ? "j/k or [ ] pick PR · Enter/Space tree · Backspace pick another stack · Tab Esc cancel"
1944
+ : "j/k or [ ] pick PR · Enter or Space back to tree · Backspace Tab Esc cancel pick",
1945
+ fullFooterW
1946
+ );
1947
+ } else if (browsePreviewOpen) {
1948
+ helpLine1 = "Tab to cycle functions.";
1949
+ helpLine2 = truncVis(
1950
+ "Enter or Space open file · Backspace or b close file · [ ] 1-9 PR · PgUp/Dn wheel diff/preview · O · K · ? · Esc q",
1951
+ fullFooterW
1952
+ );
1953
+ } else {
1954
+ helpLine1 = "Tab to cycle functions.";
1955
+ helpLine2 = truncVis(
1956
+ canPickAlternateStack
1957
+ ? "Enter/Space file · Backspace PR pick · Backspace again other stack · [ ] 1-9 · PgUp/Dn wheel · C comment · O · K · ? · Esc q"
1958
+ : "Enter or Space open file · Backspace pick PR · [ ] 1-9 PR · PgUp/Dn wheel diff · C comment · O · K · ? · Esc q",
1959
+ fullFooterW
1960
+ );
1961
+ }
1962
+ } else if (showFullHelp) {
1963
+ helpLine1 = truncVis(helpRaw, innerW);
1964
+ helpLine2 =
1965
+ helpRaw.length > innerW ? truncVis(helpRaw.slice(innerW), innerW) : "";
1966
+ } else {
1967
+ helpLine1 = truncVis("Tab/[] · j/k · Enter/Space · BS · mouse · ? details · u · q", innerW);
1968
+ helpLine2 = "";
1969
+ }
1970
+
1971
+ const spin = SPIN_FRAMES[pulse % SPIN_FRAMES.length];
1972
+ const clock = new Date().toLocaleTimeString(undefined, { hour12: false });
1973
+
1974
+ const footerRule = "\u2500".repeat(Math.max(0, innerW));
1975
+
1976
+ const displayRepo =
1977
+ repoFullName || (browseOwner && browseRepo ? `${browseOwner}/${browseRepo}` : viewTitle);
1978
+
1979
+ const mainColumn = React.createElement(
1980
+ Box,
1981
+ { flexDirection: "column", width: innerW },
1982
+ React.createElement(
1983
+ Box,
1984
+ { flexDirection: "row", width: innerW, justifyContent: "space-between" },
1985
+ React.createElement(Text, { color: "cyan", bold: true }, truncVis(viewTitle, Math.floor(innerW * 0.5))),
1986
+ React.createElement(
1987
+ Text,
1988
+ { dimColor: false, color: "green" },
1989
+ `${spin} ${clock}`
1990
+ )
1991
+ ),
1992
+ ...(shellMode
1993
+ ? []
1994
+ : [
1995
+ ...(ladderVis.length > 0
1996
+ ? [
1997
+ React.createElement(
1998
+ Text,
1999
+ { key: "ladsum", dimColor: true },
2000
+ ladderStart > 0 || ladderStart + ladderVis.length < ladderRows.length
2001
+ ? `PRs ${ladderStart + 1}-${ladderStart + ladderVis.length}/${len}`
2002
+ : ""
2003
+ )
2004
+ ]
2005
+ : []),
2006
+ ...ladderVis.map((line, i) =>
2007
+ React.createElement(Text, { key: `l-${ladderStart + i}` }, line)
2008
+ )
2009
+ ]),
2010
+ shellMode
2011
+ ? React.createElement(
2012
+ Box,
2013
+ { key: "shell-tabs", flexDirection: "column", width: innerW, marginBottom: 1 },
2014
+ React.createElement(
2015
+ Box,
2016
+ { flexDirection: "row", width: innerW, flexWrap: "wrap" },
2017
+ ...SHELL_TAB_LABELS.map((lab, i) =>
2018
+ React.createElement(
2019
+ Text,
2020
+ {
2021
+ key: `st-${i}`,
2022
+ color: i === shellMainTab ? "yellow" : "gray",
2023
+ bold: i === shellMainTab
2024
+ },
2025
+ i === shellMainTab ? `${lab} ` : `${lab} `
2026
+ )
2027
+ )
2028
+ ),
2029
+ React.createElement(Text, { dimColor: true, color: "gray" }, footerRule)
2030
+ )
2031
+ : React.createElement(Text, { color: "magenta" }, `Tab: ${tabName}`),
2032
+ React.createElement(
2033
+ Box,
2034
+ { flexDirection: "column", width: innerW },
2035
+ ...bodyLines.map((line, idx) => React.createElement(Text, { key: `b-${idx}` }, line))
2036
+ ),
2037
+ ...(!shellMode
2038
+ ? [
2039
+ React.createElement(Text, { key: "h1", dimColor: true }, helpLine1),
2040
+ helpLine2 ? React.createElement(Text, { key: "h2", dimColor: true }, helpLine2) : null
2041
+ ]
2042
+ : [])
2043
+ );
2044
+
2045
+ const footerRuleFull = "\u2500".repeat(fullFooterW);
2046
+ const shellFooterBlock = shellMode
2047
+ ? React.createElement(
2048
+ Box,
2049
+ { key: "shell-foot", flexDirection: "column", width: fullFooterW, marginTop: 1 },
2050
+ React.createElement(Text, { color: "cyan" }, footerRuleFull),
2051
+ React.createElement(Text, { color: "cyan" }, helpLine1),
2052
+ helpLine2 ? React.createElement(Text, { color: "cyan" }, helpLine2) : null,
2053
+ helpLine3 ? React.createElement(Text, { color: "cyan" }, helpLine3) : null
2054
+ )
2055
+ : null;
2056
+
2057
+ if (!shellMode) {
2058
+ return React.createElement(Box, { flexDirection: "column", width: cols, paddingX: 1 }, mainColumn);
2059
+ }
2060
+
2061
+ const sepW = Math.max(2, sidebarW - 2);
2062
+ const sepBar = "\u2500".repeat(sepW);
2063
+ const branchGraphW = Math.max(10, sidebarW - 2);
2064
+ const shellBranchGraphLines = buildStackBranchGraphLines(
2065
+ stackRows,
2066
+ hasPrPick ? safePr : -1,
2067
+ SIDEBAR_GRAPH_MAX,
2068
+ branchGraphW
2069
+ );
2070
+ const sidebarInner = [
2071
+ React.createElement(
2072
+ Text,
2073
+ { key: "sr", bold: true, color: "cyan" },
2074
+ truncVis(displayRepo, sidebarW - 2)
2075
+ ),
2076
+ React.createElement(Text, { key: "ssep0", color: "gray" }, sepBar),
2077
+ React.createElement(Text, { key: "ss1", color: "white" }, `PRs: ${len}`),
2078
+ React.createElement(Text, { key: "ss2", color: "white" }, `Changed files: ${fileList.length}`),
2079
+ React.createElement(
2080
+ Text,
2081
+ { key: "ss4", color: hasPrPick ? "whiteBright" : "gray", dimColor: !hasPrPick },
2082
+ `Focus: ${hasPrPick ? `#${row.entry.pr_number}` : "\u2014"}`
2083
+ ),
2084
+ React.createElement(Text, { key: "ssep1", color: "gray" }, sepBar),
2085
+ ladderVis.length > 0
2086
+ ? React.createElement(
2087
+ Text,
2088
+ { key: "slab", color: "white", dimColor: true },
2089
+ ladderStart > 0 || ladderStart + ladderVis.length < ladderRows.length
2090
+ ? `Ladder ${ladderStart + 1}\u2013${ladderStart + ladderVis.length}/${len}`
2091
+ : "Stack"
2092
+ )
2093
+ : null,
2094
+ ...ladderVis.map((line, i) =>
2095
+ React.createElement(Text, { key: `ls-${ladderStart + i}` }, line)
2096
+ ),
2097
+ React.createElement(Text, { key: "ssep2", color: "gray" }, sepBar),
2098
+ React.createElement(Text, { key: "sbranch", color: "white", dimColor: true }, "Branch"),
2099
+ ...shellBranchGraphLines.map((ln, gi) =>
2100
+ React.createElement(Text, { key: `sg-${gi}` }, ln)
2101
+ )
2102
+ ];
2103
+
2104
+ const divRows = Math.min(Math.max(14, ttyRows - 8), 64);
2105
+ const dividerCol = React.createElement(
2106
+ Box,
2107
+ { key: "sdiv", flexDirection: "column", width: 1, marginRight: 0 },
2108
+ ...Array.from({ length: divRows }, (_, i) =>
2109
+ React.createElement(Text, { key: `dv${i}`, color: "gray" }, "\u2502")
2110
+ )
2111
+ );
2112
+
2113
+ return React.createElement(
2114
+ Box,
2115
+ { flexDirection: "column", width: cols, paddingX: 1 },
2116
+ React.createElement(
2117
+ Box,
2118
+ { flexDirection: "row", width: cols - 2 },
2119
+ React.createElement(
2120
+ Box,
2121
+ { flexDirection: "column", width: sidebarW, marginRight: 0 },
2122
+ ...sidebarInner.filter(Boolean)
2123
+ ),
2124
+ dividerCol,
2125
+ mainColumn
2126
+ ),
2127
+ shellFooterBlock
2128
+ );
2129
+ }