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