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
@@ -0,0 +1,119 @@
1
+ import fs from "fs";
2
+ import { findGitRoot, stackJsonPath } from "../nugit-stack.js";
3
+ import { runStackViewCommand } from "./run-stack-view.js";
4
+ import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
5
+ import { RepoPickerBackError } from "./repo-picker-back.js";
6
+ import { enterAlternateScreen, leaveAlternateScreen } from "./terminal-fullscreen.js";
7
+ import { getRepoFullNameFromGitRoot } from "../git-info.js";
8
+
9
+ /**
10
+ * @param {boolean} noTui
11
+ * @param {() => Promise<void>} fn
12
+ */
13
+ async function withViewFullscreen(noTui, fn) {
14
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
15
+ if (tty && !noTui) {
16
+ enterAlternateScreen(process.stdout);
17
+ try {
18
+ await fn();
19
+ } finally {
20
+ leaveAlternateScreen(process.stdout);
21
+ }
22
+ } else {
23
+ await fn();
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Resolve CLI args and open the stack viewer (local file, remote repo, or picker TUI).
29
+ * @param {string | undefined} repoPos
30
+ * @param {string | undefined} refPos
31
+ * @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
32
+ */
33
+ export async function runNugitViewEntry(repoPos, refPos, opts) {
34
+ if (opts.file) {
35
+ await withViewFullscreen(!!opts.noTui, () =>
36
+ runStackViewCommand({ file: opts.file, noTui: opts.noTui })
37
+ );
38
+ return;
39
+ }
40
+
41
+ const explicitRepo = (repoPos && String(repoPos).trim()) || (opts.repo && String(opts.repo).trim()) || "";
42
+ const explicitRef = (refPos && String(refPos).trim()) || (opts.ref && String(opts.ref).trim()) || "";
43
+
44
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
45
+
46
+ if (!tty || opts.noTui) {
47
+ if (explicitRepo) {
48
+ await withViewFullscreen(!!opts.noTui, () =>
49
+ runStackViewCommand({
50
+ repo: explicitRepo,
51
+ ref: explicitRef || undefined,
52
+ noTui: opts.noTui,
53
+ reviewAutoapply: opts.reviewAutoapply
54
+ })
55
+ );
56
+ return;
57
+ }
58
+
59
+ const root = findGitRoot();
60
+ if (root && fs.existsSync(stackJsonPath(root))) {
61
+ await withViewFullscreen(!!opts.noTui, () =>
62
+ runStackViewCommand({ noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
63
+ );
64
+ return;
65
+ }
66
+
67
+ throw new Error(
68
+ "nugit view: pass owner/repo and optional ref, use --file, run inside a repo with .nugit/stack.json, or use a TTY for the repo picker. " +
69
+ "Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
70
+ );
71
+ }
72
+
73
+ /** @type {string | null} */
74
+ let autoRepo = null;
75
+ if (explicitRepo) {
76
+ autoRepo = explicitRepo;
77
+ } else {
78
+ const root = findGitRoot();
79
+ if (root) {
80
+ try {
81
+ autoRepo = getRepoFullNameFromGitRoot(root);
82
+ } catch {
83
+ autoRepo = null;
84
+ }
85
+ }
86
+ }
87
+
88
+ await withViewFullscreen(false, async () => {
89
+ let firstRun = true;
90
+ for (;;) {
91
+ let useRepo;
92
+ if (firstRun && autoRepo) {
93
+ useRepo = autoRepo;
94
+ } else {
95
+ const picked = await runRepoPickerFlow();
96
+ if (!picked) {
97
+ return;
98
+ }
99
+ useRepo = picked.repo;
100
+ }
101
+ firstRun = false;
102
+ try {
103
+ await runStackViewCommand({
104
+ repo: useRepo,
105
+ ref: (useRepo === explicitRepo && explicitRef) ? explicitRef : undefined,
106
+ noTui: false,
107
+ reviewAutoapply: opts.reviewAutoapply,
108
+ allowBackToRepoPicker: true
109
+ });
110
+ break;
111
+ } catch (e) {
112
+ if (e instanceof RepoPickerBackError) {
113
+ continue;
114
+ }
115
+ throw e;
116
+ }
117
+ }
118
+ });
119
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * xterm SGR mouse protocol (CSI ?1006 h).
3
+ * Sequence: ESC [ < Pb ; Px ; Py M (press) or m (release)
4
+ * Px = column (1-based), Py = row (1-based).
5
+ * @see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
6
+ */
7
+
8
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
9
+
10
+ /**
11
+ * @param {string} input
12
+ * @returns {{ button: number, col: number, row: number, release: boolean } | null}
13
+ */
14
+ export function parseSgrMouse(input) {
15
+ if (typeof input !== "string" || !input.startsWith("\x1b[<")) {
16
+ return null;
17
+ }
18
+ const m = SGR_MOUSE_RE.exec(input);
19
+ if (!m) {
20
+ return null;
21
+ }
22
+ const button = Number.parseInt(m[1], 10);
23
+ const col = Number.parseInt(m[2], 10);
24
+ const row = Number.parseInt(m[3], 10);
25
+ const release = m[4] === "m";
26
+ return { button, col, row, release };
27
+ }
28
+
29
+ /** Wheel (motion bit 32 may be set by some terminals). */
30
+ export function isWheelUp(btn) {
31
+ return (btn & ~32) === 64;
32
+ }
33
+
34
+ export function isWheelDown(btn) {
35
+ return (btn & ~32) === 65;
36
+ }
37
+
38
+ /**
39
+ * @param {import('node:stream').Writable} stdout
40
+ */
41
+ export function enableSgrMouse(stdout) {
42
+ if (!stdout?.isTTY) {
43
+ return;
44
+ }
45
+ stdout.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
46
+ }
47
+
48
+ /**
49
+ * @param {import('node:stream').Writable} stdout
50
+ */
51
+ export function disableSgrMouse(stdout) {
52
+ if (!stdout?.isTTY) {
53
+ return;
54
+ }
55
+ stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
56
+ }
@@ -0,0 +1,95 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Compact ASCII “git graph” for stacked PRs (linear chain), aligned with row index.
5
+ * Tip is drawn first (top); base last (bottom). Selection uses a filled node + branch tint.
6
+ *
7
+ * @param {Array<{ entry: { pr_number: number }, pull?: unknown }>} rows
8
+ * @param {number} selectedIndex
9
+ * @param {number} maxLines
10
+ * @param {number} w
11
+ * @param {{ muted?: boolean, fullBranchNames?: boolean }} [opts] muted = dim stack-picker cards; fullBranchNames = no ellipsis (stack picker)
12
+ * @returns {string[]}
13
+ */
14
+ export function buildStackBranchGraphLines(rows, selectedIndex, maxLines, w, opts) {
15
+ const muted = opts?.muted === true;
16
+ const fullBranchNames = opts?.fullBranchNames === true;
17
+ const n = rows.length;
18
+ if (n === 0) return [];
19
+
20
+ const labels = rows.map((r) => {
21
+ const pull = r.pull && typeof r.pull === "object" ? /** @type {Record<string, unknown>} */ (r.pull) : {};
22
+ const head = pull.head && typeof pull.head === "object" ? /** @type {Record<string, unknown>} */ (pull.head) : {};
23
+ const href = typeof head.ref === "string" ? head.ref : "";
24
+ const refLabel = fullBranchNames ? href : href.length > 10 ? `${href.slice(0, 9)}\u2026` : href;
25
+ return { pr: r.entry.pr_number, ref: refLabel };
26
+ });
27
+
28
+ /** @type {string[]} */
29
+ const out = [];
30
+ const tailChalk = muted ? (/** @param {string} s */ (s) => chalk.gray.dim(s)) : chalk.white;
31
+
32
+ for (let i = n - 1; i >= 0; i--) {
33
+ const sel = !muted && i === selectedIndex;
34
+ const node = muted
35
+ ? chalk.gray.dim("\u25cb")
36
+ : sel
37
+ ? chalk.yellow("\u25cf")
38
+ : chalk.gray("\u25cb");
39
+ /** @type {string} */
40
+ let prefix;
41
+ if (i === n - 1) {
42
+ if (n === 1) {
43
+ prefix = " ";
44
+ } else if (muted) {
45
+ prefix = chalk.gray("\u2502 ");
46
+ } else {
47
+ prefix = sel ? chalk.yellow("\u2502 ") : chalk.gray("\u2502 ");
48
+ }
49
+ } else if (i === 0) {
50
+ prefix = muted ? chalk.gray("\u2514\u2500") : sel ? chalk.yellow("\u2514\u2500") : chalk.gray("\u2514\u2500");
51
+ } else {
52
+ prefix = muted ? chalk.gray("\u2502 ") : sel ? chalk.yellow("\u2502 ") : chalk.gray("\u2502 ");
53
+ }
54
+ const prefixPlain = stripAnsi(prefix).length;
55
+ const nodePlain = stripAnsi(node).length;
56
+ const budget = Math.max(4, w - prefixPlain - nodePlain - 1);
57
+ const tailPlain = `#${labels[i].pr}${labels[i].ref ? " " + labels[i].ref : ""}`;
58
+ const tailOut = fullBranchNames ? tailPlain : truncPlain(tailPlain, budget);
59
+ const line = `${prefix}${node} ${tailChalk(tailOut)}`;
60
+ out.push(line);
61
+ if (out.length >= maxLines) break;
62
+ }
63
+
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Map a discovery stack’s `prs` (position-sorted, base → tip) to rows for {@link buildStackBranchGraphLines}.
69
+ *
70
+ * @param {{ prs?: { pr_number: number, head_branch?: string }[] }} stack
71
+ * @returns {Array<{ entry: { pr_number: number }, pull: { head: { ref: string } } }>}
72
+ */
73
+ export function discoveryPrsToGraphRows(stack) {
74
+ const prs = stack && Array.isArray(stack.prs) ? stack.prs : [];
75
+ return prs.map((p) => ({
76
+ entry: { pr_number: p.pr_number },
77
+ pull: { head: { ref: typeof p.head_branch === "string" ? p.head_branch : "" } }
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * @param {string} s
83
+ */
84
+ function stripAnsi(s) {
85
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
86
+ }
87
+
88
+ /**
89
+ * @param {string} s
90
+ * @param {number} w
91
+ */
92
+ function truncPlain(s, w) {
93
+ if (s.length <= w) return s;
94
+ return s.slice(0, Math.max(0, w - 1)) + "\u2026";
95
+ }
@@ -0,0 +1,93 @@
1
+ import chalk from "chalk";
2
+ import { hasPickerViewingContext, pickViewingHighlightIndex } from "./stack-pick-sort.js";
3
+
4
+ /**
5
+ * Overview row: nodes for up to 10 stacks; cursor column in yellow; open-in-viewer in cyan (when set).
6
+ * @param {{ tip_pr_number: number, tip_head_branch?: string }[]} stacks rows shown (often visible subset)
7
+ * @param {number} selectedIndex cursor (j/k)
8
+ * @param {number} w
9
+ * @param {number | null | undefined} [viewingTipPrNumber]
10
+ * @param {string | null | undefined} [viewingHeadRef] tip branch for matching inferred / discovery rows
11
+ * @param {unknown[] | null | undefined} [matchContextStacks] full picker list for head-ref disambiguation (defaults to `stacks`)
12
+ * @returns {string[]}
13
+ */
14
+ export function buildPickStackOverviewLines(
15
+ stacks,
16
+ selectedIndex,
17
+ w,
18
+ viewingTipPrNumber,
19
+ viewingHeadRef,
20
+ matchContextStacks
21
+ ) {
22
+ const matchCtx = matchContextStacks ?? stacks;
23
+ if (stacks.length === 0) {
24
+ return [];
25
+ }
26
+ const viewingIdx = pickViewingHighlightIndex(stacks, viewingTipPrNumber, viewingHeadRef, matchCtx);
27
+ const n = stacks.length;
28
+ const gap = " ";
29
+ /** @type {string[]} */
30
+ const out = [];
31
+ const legend = hasPickerViewingContext(viewingTipPrNumber, viewingHeadRef)
32
+ ? "Yellow = cursor · cyan = open in viewer · gray = other"
33
+ : "Top stacks by GitHub PR update time — cursor highlights one chain:";
34
+ out.push(chalk.dim(legend));
35
+ const nodeParts = [];
36
+ for (let i = 0; i < n; i++) {
37
+ const sel = i === selectedIndex;
38
+ const open = viewingIdx >= 0 && i === viewingIdx;
39
+ /** @type {string} */
40
+ let node;
41
+ if (sel) {
42
+ node = chalk.yellow("\u25cf ");
43
+ } else if (open) {
44
+ node = chalk.cyan("\u25cf ");
45
+ } else {
46
+ node = chalk.gray("\u25cb ");
47
+ }
48
+ /** @type {string} */
49
+ let pr;
50
+ if (sel) {
51
+ pr = chalk.yellowBright(`#${stacks[i].tip_pr_number}`);
52
+ } else if (open) {
53
+ pr = chalk.cyan(`#${stacks[i].tip_pr_number}`);
54
+ } else {
55
+ pr = chalk.gray(`#${stacks[i].tip_pr_number}`);
56
+ }
57
+ nodeParts.push(node + pr);
58
+ }
59
+ const line1 = nodeParts.join(gap);
60
+ out.push(line1.length > w ? chalk.dim(truncPlain(stripAnsi(line1), w)) : line1);
61
+ const brParts = [];
62
+ for (let i = 0; i < n; i++) {
63
+ const sel = i === selectedIndex;
64
+ const open = viewingIdx >= 0 && i === viewingIdx;
65
+ const raw = String(stacks[i].tip_head_branch || "").slice(0, 14);
66
+ if (sel) {
67
+ brParts.push(chalk.white(truncPlain(raw, 14)));
68
+ } else if (open) {
69
+ brParts.push(chalk.cyan(truncPlain(raw, 14)));
70
+ } else {
71
+ brParts.push(chalk.gray(truncPlain(raw, 14)));
72
+ }
73
+ }
74
+ const line2 = brParts.join(gap);
75
+ out.push(line2.length > w ? chalk.dim(truncPlain(stripAnsi(line2), w)) : line2);
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * @param {string} s
81
+ */
82
+ function stripAnsi(s) {
83
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
84
+ }
85
+
86
+ /**
87
+ * @param {string} s
88
+ * @param {number} maxW
89
+ */
90
+ function truncPlain(s, maxW) {
91
+ if (s.length <= maxW) return s;
92
+ return s.slice(0, Math.max(1, maxW - 1)) + "\u2026";
93
+ }
@@ -0,0 +1,270 @@
1
+ import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import chalk from "chalk";
4
+ import { buildPickerVisibleStacks, pickViewingHighlightIndex } from "./stack-pick-sort.js";
5
+ import { buildPickStackOverviewLines } from "./stack-pick-graph.js";
6
+ import { buildStackBranchGraphLines, discoveryPrsToGraphRows } from "./stack-branch-graph.js";
7
+ import { stackPickTerminalLayout } from "./stack-pick-layout.js";
8
+
9
+ const PICKER_GRAPH_MAX = 12;
10
+
11
+ /**
12
+ * @param {object} props
13
+ * @param {{ tip_pr_number: number, tip_head_branch: string, pr_count: number, created_by: string, prs: { pr_number: number, title?: string, head_branch?: string }[], tip_updated_at?: string, inferChainIndex?: number, inferDiffAdd?: number, inferDiffDel?: number, picker_merged_stack?: boolean }[]} props.stacks
14
+ * @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
15
+ * @param {string} [props.title]
16
+ * @param {() => void} [props.onRequestBack] Backspace: return to repo picker (infer flow only)
17
+ * @param {boolean} [props.escapeToRepo] when true with onRequestBack, Esc and q also go to repo (repo-picker loop)
18
+ * @param {number | null | undefined} [props.viewingTipPrNumber] tip PR# of the stack currently open in the viewer
19
+ * @param {string | null | undefined} [props.viewingHeadRef] tip branch ref for matching inferred / discovery rows
20
+ */
21
+ export function StackPickInk({
22
+ stacks,
23
+ onPick,
24
+ title = "Choose stack to view",
25
+ onRequestBack,
26
+ escapeToRepo = false,
27
+ viewingTipPrNumber,
28
+ viewingHeadRef
29
+ }) {
30
+ const { exit } = useApp();
31
+ const { stdout } = useStdout();
32
+ const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
33
+
34
+ const openRows = useMemo(
35
+ () => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
36
+ [stacks]
37
+ );
38
+ const closedRows = useMemo(
39
+ () => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true),
40
+ [stacks]
41
+ );
42
+
43
+ const [pickerSection, setPickerSection] = useState(() =>
44
+ openRows.length > 0 ? "open" : "closed"
45
+ );
46
+
47
+ const activeRows = useMemo(
48
+ () => (pickerSection === "open" ? openRows : closedRows),
49
+ [pickerSection, openRows, closedRows]
50
+ );
51
+
52
+ const visible = useMemo(
53
+ () => buildPickerVisibleStacks(activeRows, { viewingTipPrNumber, viewingHeadRef }),
54
+ [activeRows, viewingTipPrNumber, viewingHeadRef]
55
+ );
56
+
57
+ const [cursor, setCursor] = useState(0);
58
+
59
+ const stacksRef = useRef(stacks);
60
+ stacksRef.current = stacks;
61
+
62
+ useLayoutEffect(() => {
63
+ const all = stacksRef.current;
64
+ const openR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true);
65
+ const closedR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true);
66
+ const act = pickerSection === "open" ? openR : closedR;
67
+ const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
68
+ const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
69
+ setCursor(hi >= 0 ? hi : 0);
70
+ // Only snap when switching tabs or viewer match context — not on every stacks[] identity churn (would break j/k).
71
+ }, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
72
+
73
+ const visibleRef = useRef(visible);
74
+ visibleRef.current = visible;
75
+
76
+ const safe = visible.length ? Math.min(cursor, visible.length - 1) : 0;
77
+ const safeRef = useRef(safe);
78
+ safeRef.current = safe;
79
+
80
+ const viewingHighlightIdx = useMemo(
81
+ () => pickViewingHighlightIndex(visible, viewingTipPrNumber, viewingHeadRef, stacks),
82
+ [visible, viewingTipPrNumber, viewingHeadRef, stacks]
83
+ );
84
+
85
+ const sectionLabel =
86
+ pickerSection === "open"
87
+ ? chalk.green("Open stacks")
88
+ : chalk.gray.dim("Merged / closed (tip PR not open)");
89
+ const backHint =
90
+ onRequestBack && escapeToRepo
91
+ ? " · Esc/Backspace/q: repo list"
92
+ : onRequestBack
93
+ ? " · Backspace: repo list · Esc/q: cancel"
94
+ : " · Esc/q: cancel";
95
+ const hintLine = `${sectionLabel}${chalk.reset("")} · Tab: switch · Showing ${visible.length} of ${activeRows.length} in tab (${openRows.length} open / ${closedRows.length} merged) · j/k · 1-9 · Enter${backHint}`;
96
+
97
+ useInput((input, key) => {
98
+ const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
99
+ if (backKey && onRequestBack) {
100
+ onRequestBack();
101
+ exit();
102
+ return;
103
+ }
104
+ if (input === "q" || key.escape) {
105
+ if (escapeToRepo && onRequestBack) {
106
+ onRequestBack();
107
+ } else {
108
+ onPick(null);
109
+ }
110
+ exit();
111
+ return;
112
+ }
113
+ if (key.tab) {
114
+ setPickerSection((sec) => {
115
+ if (sec === "open") {
116
+ return closedRows.length > 0 ? "closed" : "open";
117
+ }
118
+ return openRows.length > 0 ? "open" : "closed";
119
+ });
120
+ return;
121
+ }
122
+ if (input === "j" || key.downArrow) {
123
+ setCursor((c) => {
124
+ const vis = visibleRef.current;
125
+ const max = Math.max(0, vis.length - 1);
126
+ return Math.min(c + 1, max);
127
+ });
128
+ return;
129
+ }
130
+ if (input === "k" || key.upArrow) {
131
+ setCursor((c) => Math.max(c - 1, 0));
132
+ return;
133
+ }
134
+ if (/^[1-9]$/.test(input)) {
135
+ const n = Number.parseInt(input, 10) - 1;
136
+ const vis = visibleRef.current;
137
+ if (n < vis.length) {
138
+ onPick(vis[n] ?? null);
139
+ exit();
140
+ }
141
+ return;
142
+ }
143
+ if (key.return || input === " ") {
144
+ const vis = visibleRef.current;
145
+ if (vis.length === 0) {
146
+ return;
147
+ }
148
+ const i = Math.min(Math.max(0, safeRef.current), vis.length - 1);
149
+ onPick(vis[i] ?? null);
150
+ exit();
151
+ }
152
+ });
153
+
154
+ const overview = buildPickStackOverviewLines(visible, safe, innerW, viewingTipPrNumber, viewingHeadRef, stacks);
155
+
156
+ const emptyTab =
157
+ visible.length === 0
158
+ ? React.createElement(
159
+ Text,
160
+ { color: "gray", dimColor: true },
161
+ "No stacks in this tab — press Tab to switch."
162
+ )
163
+ : null;
164
+
165
+ return React.createElement(
166
+ Box,
167
+ { flexDirection: "column", padding: 1, width: cols },
168
+ React.createElement(Text, { color: "cyan", bold: true }, title),
169
+ React.createElement(Text, { color: "gray" }, hintLine),
170
+ React.createElement(
171
+ Box,
172
+ { flexDirection: "column", marginY: 1, width: cols },
173
+ ...overview.map((ln, i) => React.createElement(Text, { key: `ov-${i}` }, ln))
174
+ ),
175
+ emptyTab,
176
+ ...visible.map((s, i) => {
177
+ const sel = i === safe;
178
+ const isViewing = viewingHighlightIdx >= 0 && i === viewingHighlightIdx;
179
+ const mark = sel ? "\u25b6 " : " ";
180
+ const inferTag =
181
+ s && typeof s === "object" && (s.inferredOnly || s.inferredFromViewerDoc)
182
+ ? chalk.dim(" (inferred)")
183
+ : "";
184
+ const head =
185
+ chalk.white(mark) +
186
+ chalk.yellow("[" + (i + 1) + "]") +
187
+ " " +
188
+ (sel ? chalk.yellowBright("tip #" + s.tip_pr_number) : chalk.white("tip #" + s.tip_pr_number)) +
189
+ inferTag;
190
+ const graphRows = discoveryPrsToGraphRows(s);
191
+ const tipIdx = graphRows.length ? graphRows.length - 1 : 0;
192
+ const graphW = Math.max(10, innerW - 4);
193
+ const graphMuted = !sel && !isViewing;
194
+ const graphLines =
195
+ graphRows.length === 0
196
+ ? []
197
+ : buildStackBranchGraphLines(graphRows, tipIdx, PICKER_GRAPH_MAX, graphW, {
198
+ muted: graphMuted,
199
+ fullBranchNames: true
200
+ });
201
+
202
+ /** @type {import('react').ReactNode[]} */
203
+ const diffBlock =
204
+ typeof s.inferDiffAdd === "number" || typeof s.inferDiffDel === "number"
205
+ ? [
206
+ React.createElement(
207
+ Text,
208
+ { key: "diff", color: sel ? "white" : "gray" },
209
+ `${chalk.dim("Lines: ")}${chalk.green("+" + (s.inferDiffAdd ?? 0))} ${chalk.red("-" + (s.inferDiffDel ?? 0))}`
210
+ )
211
+ ]
212
+ : [];
213
+
214
+ let core = React.createElement(
215
+ Box,
216
+ { flexDirection: "column" },
217
+ React.createElement(Text, { key: "head" }, head),
218
+ React.createElement(Text, { key: "pc", color: sel ? "white" : "gray" }, `PR count: ${s.pr_count}`),
219
+ ...diffBlock,
220
+ React.createElement(Text, { key: "br", color: sel ? "cyan" : "gray" }, `branch ${s.tip_head_branch}`),
221
+ React.createElement(Text, { key: "by", color: "gray" }, `by ${s.created_by}`),
222
+ React.createElement(Text, { key: "lbl", color: sel ? "white" : "gray", dimColor: !sel }, "Branch"),
223
+ ...graphLines.map((ln, gi) => React.createElement(Text, { key: `br-${gi}` }, ln))
224
+ );
225
+
226
+ if (isViewing) {
227
+ core = React.createElement(
228
+ Box,
229
+ {
230
+ borderStyle: "round",
231
+ borderColor: "cyan",
232
+ paddingLeft: 1,
233
+ paddingRight: 1,
234
+ paddingTop: 1,
235
+ paddingBottom: 1,
236
+ flexDirection: "column"
237
+ },
238
+ React.createElement(Text, { key: "open", color: "cyan" }, " Open in viewer"),
239
+ core
240
+ );
241
+ }
242
+
243
+ if (sel) {
244
+ core = React.createElement(
245
+ Box,
246
+ {
247
+ borderStyle: "round",
248
+ borderColor: "yellow",
249
+ paddingLeft: 1,
250
+ paddingRight: 1,
251
+ paddingTop: 1,
252
+ paddingBottom: 1,
253
+ flexDirection: "column"
254
+ },
255
+ core
256
+ );
257
+ }
258
+
259
+ return React.createElement(
260
+ Box,
261
+ {
262
+ key: String(s.tip_pr_number) + "-" + i,
263
+ flexDirection: "column",
264
+ marginBottom: 1
265
+ },
266
+ core
267
+ );
268
+ })
269
+ );
270
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stack picker layout derived from the TTY width.
3
+ *
4
+ * Previously `Math.max(40, cols - 4)` forced a minimum content width **wider than the
5
+ * terminal** when `stdout.columns < 44`. Ink then reflowed on every j/k render, so the
6
+ * title appeared to flash. Moving the cursor also changes which row has bordered Boxes
7
+ * (padding), which can shift total height slightly. A shared large `minHeight` on every card
8
+ * was avoided—it caused huge empty gaps between stacks in typical terminals.
9
+ * Ink `Static` is not used for the title: it only renders newly appended `items` by length,
10
+ * so a fixed single-item header would disappear after the first frame.
11
+ *
12
+ * @param {number | undefined} stdoutColumns
13
+ * @returns {{ cols: number, innerW: number }}
14
+ */
15
+ export function stackPickTerminalLayout(stdoutColumns) {
16
+ const cols = Math.max(20, stdoutColumns ?? 80);
17
+ const innerW = Math.max(16, cols - 4);
18
+ return { cols, innerW };
19
+ }