nugit-cli 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +10 -23
  3. package/src/github-device-flow.js +1 -1
  4. package/src/github-oauth-client-id.js +11 -0
  5. package/src/github-pr-social.js +42 -0
  6. package/src/github-rest.js +149 -6
  7. package/src/nugit-config.js +84 -0
  8. package/src/nugit-stack.js +40 -257
  9. package/src/nugit.js +104 -647
  10. package/src/review-hub/review-autoapprove.js +95 -0
  11. package/src/review-hub/review-hub-back.js +10 -0
  12. package/src/review-hub/review-hub-ink.js +169 -0
  13. package/src/review-hub/run-review-hub.js +131 -0
  14. package/src/services/repo-branches.js +151 -0
  15. package/src/services/stack-inference.js +90 -0
  16. package/src/split-view/run-split.js +14 -76
  17. package/src/split-view/split-ink.js +2 -2
  18. package/src/stack-infer-from-prs.js +71 -0
  19. package/src/stack-view/diff-line-map.js +62 -0
  20. package/src/stack-view/fetch-pr-data.js +104 -4
  21. package/src/stack-view/infer-chains-to-pick-stacks.js +80 -0
  22. package/src/stack-view/ink-app.js +3 -421
  23. package/src/stack-view/loader.js +19 -93
  24. package/src/stack-view/loading-ink.js +2 -0
  25. package/src/stack-view/merge-alternate-pick-stacks.js +245 -0
  26. package/src/stack-view/patch-preview-merge.js +108 -0
  27. package/src/stack-view/remote-infer-doc.js +76 -0
  28. package/src/stack-view/repo-picker-back.js +10 -0
  29. package/src/stack-view/run-stack-view.js +508 -150
  30. package/src/stack-view/run-view-entry.js +115 -0
  31. package/src/stack-view/sgr-mouse.js +56 -0
  32. package/src/stack-view/stack-branch-graph.js +95 -0
  33. package/src/stack-view/stack-pick-graph.js +93 -0
  34. package/src/stack-view/stack-pick-ink.js +308 -0
  35. package/src/stack-view/stack-pick-layout.js +19 -0
  36. package/src/stack-view/stack-pick-sort.js +188 -0
  37. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  38. package/src/stack-view/terminal-fullscreen.js +7 -0
  39. package/src/stack-view/tree-ascii.js +73 -0
  40. package/src/stack-view/view-md-plain.js +23 -0
  41. package/src/stack-view/view-repo-picker-ink.js +293 -0
  42. package/src/stack-view/view-tui-sequential.js +126 -0
  43. package/src/tui/pages/home.js +122 -0
  44. package/src/tui/pages/repo-actions.js +81 -0
  45. package/src/tui/pages/repo-branches.js +259 -0
  46. package/src/tui/pages/viewer.js +2129 -0
  47. package/src/tui/router.js +40 -0
  48. package/src/tui/run-tui.js +281 -0
  49. package/src/utilities/loading.js +37 -0
  50. package/src/utilities/terminal.js +31 -0
  51. package/src/cli-output.js +0 -228
  52. package/src/nugit-start.js +0 -211
  53. package/src/stack-discover.js +0 -284
  54. package/src/stack-discovery-config.js +0 -91
  55. package/src/stack-extra-commands.js +0 -353
  56. package/src/stack-graph.js +0 -214
  57. package/src/stack-helpers.js +0 -58
  58. package/src/stack-propagate.js +0 -422
@@ -0,0 +1,115 @@
1
+ import { findGitRoot } from "../nugit-stack.js";
2
+ import { runStackViewCommand } from "./run-stack-view.js";
3
+ import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
4
+ import { RepoPickerBackError } from "./repo-picker-back.js";
5
+ import { enterAlternateScreen, leaveAlternateScreen } from "./terminal-fullscreen.js";
6
+ import { getRepoFullNameFromGitRoot } from "../git-info.js";
7
+
8
+ /**
9
+ * @param {boolean} noTui
10
+ * @param {() => Promise<void>} fn
11
+ */
12
+ async function withViewFullscreen(noTui, fn) {
13
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
14
+ if (tty && !noTui) {
15
+ enterAlternateScreen(process.stdout);
16
+ try {
17
+ await fn();
18
+ } finally {
19
+ leaveAlternateScreen(process.stdout);
20
+ }
21
+ } else {
22
+ await fn();
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Resolve CLI args and open the stack viewer (remote repo by coords, repo picker, or current dir inference).
28
+ * @param {string | undefined} repoPos
29
+ * @param {string | undefined} refPos
30
+ * @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
31
+ */
32
+ export async function runNugitViewEntry(repoPos, refPos, opts) {
33
+ if (opts.file) {
34
+ await withViewFullscreen(!!opts.noTui, () =>
35
+ runStackViewCommand({ file: opts.file, noTui: opts.noTui })
36
+ );
37
+ return;
38
+ }
39
+
40
+ const explicitRepo = (repoPos && String(repoPos).trim()) || (opts.repo && String(opts.repo).trim()) || "";
41
+ const explicitRef = (refPos && String(refPos).trim()) || (opts.ref && String(opts.ref).trim()) || "";
42
+
43
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
44
+
45
+ if (!tty || opts.noTui) {
46
+ if (explicitRepo) {
47
+ await withViewFullscreen(!!opts.noTui, () =>
48
+ runStackViewCommand({
49
+ repo: explicitRepo,
50
+ ref: explicitRef || undefined,
51
+ noTui: opts.noTui,
52
+ reviewAutoapply: opts.reviewAutoapply
53
+ })
54
+ );
55
+ return;
56
+ }
57
+
58
+ // Non-TTY with no explicit repo: infer from git remote
59
+ const root = findGitRoot();
60
+ let inferredRepo = null;
61
+ if (root) {
62
+ try { inferredRepo = getRepoFullNameFromGitRoot(root); } catch { inferredRepo = null; }
63
+ }
64
+ if (inferredRepo) {
65
+ await withViewFullscreen(!!opts.noTui, () =>
66
+ runStackViewCommand({ repo: inferredRepo, noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
67
+ );
68
+ return;
69
+ }
70
+
71
+ throw new Error(
72
+ "nugit view: pass owner/repo and optional ref, or run inside a git clone with a github.com remote. " +
73
+ "Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
74
+ );
75
+ }
76
+
77
+ // TTY: use the repo picker flow (or jump straight to current dir)
78
+ let autoRepo = null;
79
+ if (explicitRepo) {
80
+ autoRepo = explicitRepo;
81
+ } else {
82
+ const root = findGitRoot();
83
+ if (root) {
84
+ try { autoRepo = getRepoFullNameFromGitRoot(root); } catch { autoRepo = null; }
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) return;
97
+ useRepo = picked.repo;
98
+ }
99
+ firstRun = false;
100
+ try {
101
+ await runStackViewCommand({
102
+ repo: useRepo,
103
+ ref: (useRepo === explicitRepo && explicitRef) ? explicitRef : undefined,
104
+ noTui: false,
105
+ reviewAutoapply: opts.reviewAutoapply,
106
+ allowBackToRepoPicker: true
107
+ });
108
+ break;
109
+ } catch (e) {
110
+ if (e instanceof RepoPickerBackError) continue;
111
+ throw e;
112
+ }
113
+ }
114
+ });
115
+ }
@@ -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,308 @@
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 { buildSplitGraphPane } from "./stack-picker-graph-pane.js";
6
+ import { stackPickTerminalLayout } from "./stack-pick-layout.js";
7
+
8
+ /** Minimum terminal width to show the split-pane graph; narrower terminals fall back to text list. */
9
+ const SPLIT_MIN_COLS = 64;
10
+ const GRAPH_W = 30;
11
+
12
+ // ─── Visual design constants ──────────────────────────────────────────────────
13
+ // Cursor (hover) box: round yellow border.
14
+ // Viewing (open in viewer) box: round cyan border, nested inside cursor box
15
+ // when they are the same item — creating a concentric inset effect.
16
+ // Default text: white (not gray) so content is legible at a glance.
17
+ // Dim / secondary metadata: dimColor true (author, hint lines).
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * @param {object} props
22
+ * @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, base_ref?: string }[]} props.stacks
23
+ * @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
24
+ * @param {string} [props.title]
25
+ * @param {() => void} [props.onRequestBack]
26
+ * @param {boolean} [props.escapeToRepo]
27
+ * @param {number | null | undefined} [props.viewingTipPrNumber]
28
+ * @param {string | null | undefined} [props.viewingHeadRef]
29
+ */
30
+ export function StackPickInk({
31
+ stacks,
32
+ onPick,
33
+ title = "Choose stack to view",
34
+ onRequestBack,
35
+ escapeToRepo = false,
36
+ viewingTipPrNumber,
37
+ viewingHeadRef
38
+ }) {
39
+ const { exit } = useApp();
40
+ const { stdout } = useStdout();
41
+ const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
42
+ const ttyRows = stdout?.rows ?? 24;
43
+
44
+ const openRows = useMemo(
45
+ () => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
46
+ [stacks]
47
+ );
48
+ const closedRows = useMemo(
49
+ () => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true),
50
+ [stacks]
51
+ );
52
+
53
+ const [pickerSection, setPickerSection] = useState(() =>
54
+ openRows.length > 0 ? "open" : "closed"
55
+ );
56
+
57
+ const activeRows = useMemo(
58
+ () => (pickerSection === "open" ? openRows : closedRows),
59
+ [pickerSection, openRows, closedRows]
60
+ );
61
+
62
+ const visible = useMemo(
63
+ () => buildPickerVisibleStacks(activeRows, { viewingTipPrNumber, viewingHeadRef }),
64
+ [activeRows, viewingTipPrNumber, viewingHeadRef]
65
+ );
66
+
67
+ const [cursor, setCursor] = useState(0);
68
+
69
+ const stacksRef = useRef(stacks);
70
+ stacksRef.current = stacks;
71
+
72
+ useLayoutEffect(() => {
73
+ const all = stacksRef.current;
74
+ const openR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true);
75
+ const closedR = all.filter((s) => s && typeof s === "object" && s.picker_merged_stack === true);
76
+ const act = pickerSection === "open" ? openR : closedR;
77
+ const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
78
+ const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
79
+ setCursor(hi >= 0 ? hi : 0);
80
+ }, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
81
+
82
+ const visibleRef = useRef(visible);
83
+ visibleRef.current = visible;
84
+
85
+ const safe = visible.length ? Math.min(cursor, visible.length - 1) : 0;
86
+ const safeRef = useRef(safe);
87
+ safeRef.current = safe;
88
+
89
+ const viewingHighlightIdx = useMemo(
90
+ () => pickViewingHighlightIndex(visible, viewingTipPrNumber, viewingHeadRef, stacks),
91
+ [visible, viewingTipPrNumber, viewingHeadRef, stacks]
92
+ );
93
+
94
+ const sectionLabel =
95
+ pickerSection === "open"
96
+ ? chalk.green("Open stacks")
97
+ : chalk.dim("Merged / closed");
98
+ const backHint =
99
+ onRequestBack && escapeToRepo
100
+ ? " · Esc/Backspace/q: repo list"
101
+ : onRequestBack
102
+ ? " · Backspace: back · Esc/q: cancel"
103
+ : " · Esc/q: cancel";
104
+ const hintLine = `${sectionLabel}${chalk.reset("")} · Tab · j/k · 1-9 · Enter${backHint}`;
105
+
106
+ useInput((input, key) => {
107
+ const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
108
+ if (backKey && onRequestBack) {
109
+ onRequestBack();
110
+ exit();
111
+ return;
112
+ }
113
+ if (input === "q" || key.escape) {
114
+ if (escapeToRepo && onRequestBack) {
115
+ onRequestBack();
116
+ } else {
117
+ onPick(null);
118
+ }
119
+ exit();
120
+ return;
121
+ }
122
+ if (key.tab) {
123
+ setPickerSection((sec) => {
124
+ if (sec === "open") return closedRows.length > 0 ? "closed" : "open";
125
+ return openRows.length > 0 ? "open" : "closed";
126
+ });
127
+ return;
128
+ }
129
+ if (input === "j" || key.downArrow) {
130
+ setCursor((c) => Math.min(c + 1, Math.max(0, visibleRef.current.length - 1)));
131
+ return;
132
+ }
133
+ if (input === "k" || key.upArrow) {
134
+ setCursor((c) => Math.max(c - 1, 0));
135
+ return;
136
+ }
137
+ if (/^[1-9]$/.test(input)) {
138
+ const n = Number.parseInt(input, 10) - 1;
139
+ if (n < visibleRef.current.length) {
140
+ onPick(visibleRef.current[n] ?? null);
141
+ exit();
142
+ }
143
+ return;
144
+ }
145
+ if (key.return || input === " ") {
146
+ const vis = visibleRef.current;
147
+ if (!vis.length) return;
148
+ onPick(vis[Math.min(Math.max(0, safeRef.current), vis.length - 1)] ?? null);
149
+ exit();
150
+ }
151
+ });
152
+
153
+ const useSplitPane = cols >= SPLIT_MIN_COLS && visible.length > 0;
154
+ const paneH = Math.max(4, ttyRows - 4);
155
+ const listW = Math.max(20, innerW - GRAPH_W - 2);
156
+
157
+ const emptyTab =
158
+ visible.length === 0
159
+ ? React.createElement(Text, { dimColor: true }, "No stacks in this tab — press Tab to switch.")
160
+ : null;
161
+
162
+ if (useSplitPane) {
163
+ const graphLines = buildSplitGraphPane(visible, safe, GRAPH_W, paneH);
164
+
165
+ return React.createElement(
166
+ Box,
167
+ { flexDirection: "column", width: cols },
168
+ React.createElement(Text, { color: "cyan", bold: true }, title),
169
+ React.createElement(Text, { dimColor: true }, hintLine),
170
+ React.createElement(
171
+ Box,
172
+ { flexDirection: "row", marginTop: 1 },
173
+ // Left: graph pane
174
+ React.createElement(
175
+ Box,
176
+ { flexDirection: "column", width: GRAPH_W, flexShrink: 0 },
177
+ ...graphLines.map((ln, i) => React.createElement(Text, { key: `gp-${i}` }, ln))
178
+ ),
179
+ // Right: stack list with bordered cards
180
+ React.createElement(
181
+ Box,
182
+ { flexDirection: "column", flexGrow: 1, width: listW },
183
+ emptyTab,
184
+ ...visible.map((s, i) => buildStackCard(s, i, {
185
+ sel: i === safe,
186
+ isViewing: viewingHighlightIdx >= 0 && i === viewingHighlightIdx,
187
+ listW,
188
+ inferTag: s && (s.inferredOnly || s.inferredFromViewerDoc)
189
+ }))
190
+ )
191
+ )
192
+ );
193
+ }
194
+
195
+ // Narrow terminal fallback: full-width stacked cards
196
+ return React.createElement(
197
+ Box,
198
+ { flexDirection: "column", padding: 1, width: cols },
199
+ React.createElement(Text, { color: "cyan", bold: true }, title),
200
+ React.createElement(Text, { dimColor: true }, hintLine),
201
+ emptyTab,
202
+ ...visible.map((s, i) => buildStackCard(s, i, {
203
+ sel: i === safe,
204
+ isViewing: viewingHighlightIdx >= 0 && i === viewingHighlightIdx,
205
+ listW: innerW - 4,
206
+ inferTag: s && (s.inferredOnly || s.inferredFromViewerDoc)
207
+ }))
208
+ );
209
+ }
210
+
211
+ // ─── Stack card builder ───────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Build a single stack entry with optional bordered boxes.
215
+ *
216
+ * Visual states:
217
+ * - Cursor (hover): round yellow border (selector box)
218
+ * - Viewing (in viewer): round cyan border (viewing indicator box)
219
+ * - Both (same item): yellow outer + cyan inner — concentric inset effect
220
+ * - Plain (neither): no border, white text
221
+ *
222
+ * @param {object} s stack row
223
+ * @param {number} i index
224
+ * @param {{ sel: boolean, isViewing: boolean, listW: number, inferTag: any }} opts
225
+ */
226
+ function buildStackCard(s, i, { sel, isViewing, listW }) {
227
+ const mark = sel ? "▶ " : " ";
228
+ const maxLabelW = Math.max(8, listW - 6);
229
+
230
+ const inferred = s && (s.inferredOnly || s.inferredFromViewerDoc);
231
+ const inferSuffix = inferred ? chalk.dim(" *") : "";
232
+
233
+ const pcLabel = `${s.pr_count ?? 1} PR${(s.pr_count ?? 1) === 1 ? "" : "s"}`;
234
+ const diffPart =
235
+ typeof s.inferDiffAdd === "number"
236
+ ? ` ${chalk.green("+" + s.inferDiffAdd)} ${chalk.red("-" + (s.inferDiffDel ?? 0))}`
237
+ : "";
238
+
239
+ // Header: marker + index + tip PR number + PR count
240
+ const headerColor = sel ? "yellow" : isViewing ? "cyan" : "white";
241
+ const header = React.createElement(
242
+ Text,
243
+ { color: headerColor, bold: sel },
244
+ `${mark}[${i + 1}] #${s.tip_pr_number}`,
245
+ inferSuffix,
246
+ ` · ${pcLabel}`,
247
+ diffPart
248
+ );
249
+
250
+ // Per-PR branch name lines (tip → base)
251
+ const prLines = Array.isArray(s.prs) && s.prs.length > 0
252
+ ? [...s.prs].reverse()
253
+ : [{ pr_number: s.tip_pr_number, head_branch: s.tip_head_branch }];
254
+
255
+ const prNameNodes = prLines.map((pr, j) => {
256
+ const name = typeof pr.head_branch === "string" && pr.head_branch
257
+ ? pr.head_branch
258
+ : `#${pr.pr_number}`;
259
+ const truncated = name.slice(0, maxLabelW);
260
+ return React.createElement(
261
+ Text,
262
+ { key: `pr-${j}`, color: sel ? "yellow" : isViewing ? "cyan" : "white" },
263
+ ` ${truncated}`
264
+ );
265
+ });
266
+
267
+ // Author line (dim / secondary)
268
+ const authorNode = s.created_by
269
+ ? React.createElement(
270
+ Text,
271
+ { key: "author", dimColor: true },
272
+ ` by ${s.created_by}`
273
+ )
274
+ : null;
275
+
276
+ // Content box (no border here — borders are added as wrappers below)
277
+ let content = React.createElement(
278
+ Box,
279
+ { flexDirection: "column", paddingX: 1 },
280
+ header,
281
+ ...prNameNodes,
282
+ authorNode
283
+ );
284
+
285
+ // Apply viewing indicator (cyan) box — inner
286
+ if (isViewing) {
287
+ content = React.createElement(
288
+ Box,
289
+ { borderStyle: "round", borderColor: "cyan", flexDirection: "column" },
290
+ content
291
+ );
292
+ }
293
+
294
+ // Apply cursor (selector) box — outer yellow; wraps the cyan box when both active
295
+ if (sel) {
296
+ content = React.createElement(
297
+ Box,
298
+ { borderStyle: "round", borderColor: "yellow", flexDirection: "column" },
299
+ content
300
+ );
301
+ }
302
+
303
+ return React.createElement(
304
+ Box,
305
+ { key: `${s.tip_pr_number}-${i}`, flexDirection: "column", marginBottom: 1 },
306
+ content
307
+ );
308
+ }
@@ -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
+ }