nugit-cli 0.1.0 → 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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/api-client.js +0 -12
  3. package/src/github-rest.js +35 -0
  4. package/src/nugit-config.js +84 -0
  5. package/src/nugit-stack.js +29 -266
  6. package/src/nugit.js +103 -661
  7. package/src/review-hub/review-hub-ink.js +6 -3
  8. package/src/review-hub/run-review-hub.js +34 -91
  9. package/src/services/repo-branches.js +151 -0
  10. package/src/services/stack-inference.js +90 -0
  11. package/src/split-view/run-split.js +14 -89
  12. package/src/stack-view/infer-chains-to-pick-stacks.js +10 -0
  13. package/src/stack-view/ink-app.js +3 -2118
  14. package/src/stack-view/loader.js +19 -93
  15. package/src/stack-view/loading-ink.js +2 -44
  16. package/src/stack-view/merge-alternate-pick-stacks.js +23 -1
  17. package/src/stack-view/remote-infer-doc.js +28 -45
  18. package/src/stack-view/run-stack-view.js +249 -526
  19. package/src/stack-view/run-view-entry.js +14 -18
  20. package/src/stack-view/stack-pick-ink.js +169 -131
  21. package/src/stack-view/stack-picker-graph-pane.js +118 -0
  22. package/src/stack-view/terminal-fullscreen.js +7 -45
  23. package/src/tui/pages/home.js +122 -0
  24. package/src/tui/pages/repo-actions.js +81 -0
  25. package/src/tui/pages/repo-branches.js +259 -0
  26. package/src/tui/pages/viewer.js +2129 -0
  27. package/src/tui/router.js +40 -0
  28. package/src/tui/run-tui.js +281 -0
  29. package/src/utilities/loading.js +37 -0
  30. package/src/utilities/terminal.js +31 -0
  31. package/src/cli-output.js +0 -228
  32. package/src/nugit-start.js +0 -211
  33. package/src/stack-discover.js +0 -292
  34. package/src/stack-discovery-config.js +0 -91
  35. package/src/stack-extra-commands.js +0 -353
  36. package/src/stack-graph.js +0 -214
  37. package/src/stack-helpers.js +0 -58
  38. package/src/stack-propagate.js +0 -422
@@ -1,5 +1,4 @@
1
- import fs from "fs";
2
- import { findGitRoot, stackJsonPath } from "../nugit-stack.js";
1
+ import { findGitRoot } from "../nugit-stack.js";
3
2
  import { runStackViewCommand } from "./run-stack-view.js";
4
3
  import { runRepoPickerFlow } from "./view-repo-picker-ink.js";
5
4
  import { RepoPickerBackError } from "./repo-picker-back.js";
@@ -25,7 +24,7 @@ async function withViewFullscreen(noTui, fn) {
25
24
  }
26
25
 
27
26
  /**
28
- * Resolve CLI args and open the stack viewer (local file, remote repo, or picker TUI).
27
+ * Resolve CLI args and open the stack viewer (remote repo by coords, repo picker, or current dir inference).
29
28
  * @param {string | undefined} repoPos
30
29
  * @param {string | undefined} refPos
31
30
  * @param {{ noTui?: boolean, repo?: string, ref?: string, file?: string, reviewAutoapply?: boolean }} opts
@@ -56,32 +55,33 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
56
55
  return;
57
56
  }
58
57
 
58
+ // Non-TTY with no explicit repo: infer from git remote
59
59
  const root = findGitRoot();
60
- if (root && fs.existsSync(stackJsonPath(root))) {
60
+ let inferredRepo = null;
61
+ if (root) {
62
+ try { inferredRepo = getRepoFullNameFromGitRoot(root); } catch { inferredRepo = null; }
63
+ }
64
+ if (inferredRepo) {
61
65
  await withViewFullscreen(!!opts.noTui, () =>
62
- runStackViewCommand({ noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
66
+ runStackViewCommand({ repo: inferredRepo, noTui: opts.noTui, reviewAutoapply: opts.reviewAutoapply })
63
67
  );
64
68
  return;
65
69
  }
66
70
 
67
71
  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. " +
72
+ "nugit view: pass owner/repo and optional ref, or run inside a git clone with a github.com remote. " +
69
73
  "Sign in with `nugit auth login` or set NUGIT_USER_TOKEN when GitHub returns 401."
70
74
  );
71
75
  }
72
76
 
73
- /** @type {string | null} */
77
+ // TTY: use the repo picker flow (or jump straight to current dir)
74
78
  let autoRepo = null;
75
79
  if (explicitRepo) {
76
80
  autoRepo = explicitRepo;
77
81
  } else {
78
82
  const root = findGitRoot();
79
83
  if (root) {
80
- try {
81
- autoRepo = getRepoFullNameFromGitRoot(root);
82
- } catch {
83
- autoRepo = null;
84
- }
84
+ try { autoRepo = getRepoFullNameFromGitRoot(root); } catch { autoRepo = null; }
85
85
  }
86
86
  }
87
87
 
@@ -93,9 +93,7 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
93
93
  useRepo = autoRepo;
94
94
  } else {
95
95
  const picked = await runRepoPickerFlow();
96
- if (!picked) {
97
- return;
98
- }
96
+ if (!picked) return;
99
97
  useRepo = picked.repo;
100
98
  }
101
99
  firstRun = false;
@@ -109,9 +107,7 @@ export async function runNugitViewEntry(repoPos, refPos, opts) {
109
107
  });
110
108
  break;
111
109
  } catch (e) {
112
- if (e instanceof RepoPickerBackError) {
113
- continue;
114
- }
110
+ if (e instanceof RepoPickerBackError) continue;
115
111
  throw e;
116
112
  }
117
113
  }
@@ -2,21 +2,30 @@ import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { Box, Text, useApp, useInput, useStdout } from "ink";
3
3
  import chalk from "chalk";
4
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";
5
+ import { buildSplitGraphPane } from "./stack-picker-graph-pane.js";
7
6
  import { stackPickTerminalLayout } from "./stack-pick-layout.js";
8
7
 
9
- const PICKER_GRAPH_MAX = 12;
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
+ // ─────────────────────────────────────────────────────────────────────────────
10
19
 
11
20
  /**
12
21
  * @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
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
14
23
  * @param {(stack: (typeof props.stacks)[0] | null) => void} props.onPick
15
24
  * @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
25
+ * @param {() => void} [props.onRequestBack]
26
+ * @param {boolean} [props.escapeToRepo]
27
+ * @param {number | null | undefined} [props.viewingTipPrNumber]
28
+ * @param {string | null | undefined} [props.viewingHeadRef]
20
29
  */
21
30
  export function StackPickInk({
22
31
  stacks,
@@ -30,6 +39,7 @@ export function StackPickInk({
30
39
  const { exit } = useApp();
31
40
  const { stdout } = useStdout();
32
41
  const { cols, innerW } = stackPickTerminalLayout(stdout?.columns);
42
+ const ttyRows = stdout?.rows ?? 24;
33
43
 
34
44
  const openRows = useMemo(
35
45
  () => stacks.filter((s) => s && typeof s === "object" && s.picker_merged_stack !== true),
@@ -67,7 +77,6 @@ export function StackPickInk({
67
77
  const vis = buildPickerVisibleStacks(act, { viewingTipPrNumber, viewingHeadRef });
68
78
  const hi = pickViewingHighlightIndex(vis, viewingTipPrNumber, viewingHeadRef, all);
69
79
  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
80
  }, [pickerSection, viewingTipPrNumber, viewingHeadRef]);
72
81
 
73
82
  const visibleRef = useRef(visible);
@@ -85,14 +94,14 @@ export function StackPickInk({
85
94
  const sectionLabel =
86
95
  pickerSection === "open"
87
96
  ? chalk.green("Open stacks")
88
- : chalk.gray.dim("Merged / closed (tip PR not open)");
97
+ : chalk.dim("Merged / closed");
89
98
  const backHint =
90
99
  onRequestBack && escapeToRepo
91
100
  ? " · Esc/Backspace/q: repo list"
92
101
  : onRequestBack
93
- ? " · Backspace: repo list · Esc/q: cancel"
102
+ ? " · Backspace: back · Esc/q: cancel"
94
103
  : " · 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}`;
104
+ const hintLine = `${sectionLabel}${chalk.reset("")} · Tab · j/k · 1-9 · Enter${backHint}`;
96
105
 
97
106
  useInput((input, key) => {
98
107
  const backKey = key.backspace || key.delete || input === "\x7f" || input === "\b";
@@ -112,19 +121,13 @@ export function StackPickInk({
112
121
  }
113
122
  if (key.tab) {
114
123
  setPickerSection((sec) => {
115
- if (sec === "open") {
116
- return closedRows.length > 0 ? "closed" : "open";
117
- }
124
+ if (sec === "open") return closedRows.length > 0 ? "closed" : "open";
118
125
  return openRows.length > 0 ? "open" : "closed";
119
126
  });
120
127
  return;
121
128
  }
122
129
  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
- });
130
+ setCursor((c) => Math.min(c + 1, Math.max(0, visibleRef.current.length - 1)));
128
131
  return;
129
132
  }
130
133
  if (input === "k" || key.upArrow) {
@@ -133,138 +136,173 @@ export function StackPickInk({
133
136
  }
134
137
  if (/^[1-9]$/.test(input)) {
135
138
  const n = Number.parseInt(input, 10) - 1;
136
- const vis = visibleRef.current;
137
- if (n < vis.length) {
138
- onPick(vis[n] ?? null);
139
+ if (n < visibleRef.current.length) {
140
+ onPick(visibleRef.current[n] ?? null);
139
141
  exit();
140
142
  }
141
143
  return;
142
144
  }
143
145
  if (key.return || input === " ") {
144
146
  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);
147
+ if (!vis.length) return;
148
+ onPick(vis[Math.min(Math.max(0, safeRef.current), vis.length - 1)] ?? null);
150
149
  exit();
151
150
  }
152
151
  });
153
152
 
154
- const overview = buildPickStackOverviewLines(visible, safe, innerW, viewingTipPrNumber, viewingHeadRef, stacks);
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);
155
156
 
156
157
  const emptyTab =
157
158
  visible.length === 0
158
- ? React.createElement(
159
- Text,
160
- { color: "gray", dimColor: true },
161
- "No stacks in this tab — press Tab to switch."
162
- )
159
+ ? React.createElement(Text, { dimColor: true }, "No stacks in this tab — press Tab to switch.")
163
160
  : null;
164
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
165
196
  return React.createElement(
166
197
  Box,
167
198
  { flexDirection: "column", padding: 1, width: cols },
168
199
  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
- ),
200
+ React.createElement(Text, { dimColor: true }, hintLine),
175
201
  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
- }
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
+ }
242
210
 
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
- }
211
+ // ─── Stack card builder ───────────────────────────────────────────────────────
258
212
 
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
- })
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
269
307
  );
270
308
  }
@@ -0,0 +1,118 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Build ASCII railroad graph lines for the split-pane stack picker left column.
5
+ *
6
+ * Structure (tip-first per stack, dots connected by explicit vertical lines):
7
+ *
8
+ * main ← base_ref label
9
+ * │
10
+ * ├─● feat/tip ← fork row: tip PR (selected: yellow, others: dim)
11
+ * │ │ ← connecting line between consecutive PR nodes
12
+ * │ ● feat/base ← cont row: bottom PR
13
+ * │
14
+ * ├─◯ feat/b-tip ← another stack (dim)
15
+ * └─◯ feat/c ← last stack (dim)
16
+ *
17
+ * The branch `│` connector at col 3 runs through all PR nodes of the same
18
+ * stack so dots appear visually linked. The trunk `│` at col 1 connects
19
+ * stacks back to the base branch.
20
+ *
21
+ * @param {Array<{
22
+ * tip_pr_number: number,
23
+ * tip_head_branch?: string,
24
+ * pr_count?: number,
25
+ * base_ref?: string,
26
+ * prs?: Array<{ pr_number: number, head_branch?: string, title?: string }>
27
+ * }>} stacks
28
+ * @param {number} selectedIndex cursor (0-based into stacks)
29
+ * @param {number} paneW total character width of the left column
30
+ * @param {number} paneH total character height (rows) available
31
+ * @returns {string[]} exactly paneH lines
32
+ */
33
+ export function buildSplitGraphPane(stacks, selectedIndex, paneW, paneH) {
34
+ if (!stacks.length) {
35
+ return Array.from({ length: paneH }, () => "");
36
+ }
37
+
38
+ const n = stacks.length;
39
+ const baseRef = (stacks[0]?.base_ref || "main").slice(0, paneW - 2);
40
+
41
+ /** @type {string[]} */
42
+ const rows = [];
43
+
44
+ // Header: base branch label + initial trunk
45
+ rows.push(chalk.dim.gray(` ${baseRef}`));
46
+ rows.push(chalk.dim.gray(" │"));
47
+
48
+ // Available rows for all stacks (min 3 per stack for fork+conn+node)
49
+ const available = Math.max(n * 3, paneH - 2);
50
+ const rowsPerStack = Math.max(3, Math.floor(available / n));
51
+
52
+ const labelW = Math.max(4, paneW - 6);
53
+
54
+ for (let si = 0; si < n; si++) {
55
+ const stack = stacks[si];
56
+ const sel = si === selectedIndex;
57
+ const isLast = si === n - 1;
58
+
59
+ // PRs ordered tip-first (tip = highest = top of branch in graph)
60
+ const rawPrs = Array.isArray(stack.prs) && stack.prs.length > 0
61
+ ? [...stack.prs].reverse()
62
+ : [{ pr_number: stack.tip_pr_number, head_branch: stack.tip_head_branch }];
63
+
64
+ const forkPrefix = isLast ? " └─" : " ├─"; // 3 chars; node lands at col 3
65
+ const trunkCont = isLast ? " " : " │ "; // 3 chars; keeps trunk + branch lane aligned
66
+
67
+ // Color helpers
68
+ const nodeChar = sel ? chalk.yellow("●") : chalk.dim.gray("○");
69
+ const connChar = sel ? chalk.yellow("│") : chalk.dim.gray("│");
70
+ const forkCol = sel ? chalk.yellow : chalk.dim.gray;
71
+ const labelCol = sel ? chalk.yellowBright : chalk.dim.gray;
72
+
73
+ /** @param {number} idx index into reversed prs array */
74
+ const getLabel = (idx) => {
75
+ const pr = rawPrs[idx];
76
+ if (!pr) return "";
77
+ const name = typeof pr.head_branch === "string" && pr.head_branch
78
+ ? pr.head_branch
79
+ : `#${pr.pr_number}`;
80
+ return name.slice(0, labelW);
81
+ };
82
+
83
+ let pushed = 0; // rows pushed for this stack
84
+
85
+ // Fork row (tip PR)
86
+ if (rows.length < paneH) {
87
+ rows.push(forkCol(forkPrefix) + nodeChar + " " + labelCol(getLabel(0)));
88
+ pushed++;
89
+ }
90
+
91
+ // Additional PR nodes, each preceded by an explicit connecting line
92
+ let prIdx = 1;
93
+ while (prIdx < rawPrs.length && pushed < rowsPerStack - 1 && rows.length < paneH) {
94
+ // Connecting vertical line between previous node and this one
95
+ if (pushed < rowsPerStack - 1 && rows.length < paneH) {
96
+ rows.push(chalk.dim.gray(trunkCont) + connChar);
97
+ pushed++;
98
+ }
99
+ // PR node
100
+ if (pushed < rowsPerStack && rows.length < paneH) {
101
+ const nd = sel ? chalk.yellow("●") : chalk.dim.gray("○");
102
+ rows.push(chalk.dim.gray(trunkCont) + nd + " " + labelCol(getLabel(prIdx)));
103
+ pushed++;
104
+ prIdx++;
105
+ }
106
+ }
107
+
108
+ // Padding rows: trunk continues between stacks; last stack pads with empty
109
+ while (pushed < rowsPerStack && rows.length < paneH) {
110
+ rows.push(isLast ? "" : chalk.dim.gray(" │"));
111
+ pushed++;
112
+ }
113
+ }
114
+
115
+ // Fill remaining rows to exactly paneH
116
+ while (rows.length < paneH) rows.push("");
117
+ return rows.slice(0, paneH);
118
+ }
@@ -1,45 +1,7 @@
1
- /**
2
- * Alternate screen buffer for a cleaner fullscreen TUI.
3
- * Disable with NUGIT_NO_FULLSCREEN=1 if your terminal misbehaves.
4
- */
5
-
6
- /**
7
- * @returns {boolean}
8
- */
9
- export function isAlternateScreenDisabled() {
10
- const v = process.env.NUGIT_NO_FULLSCREEN;
11
- return v === "1" || v === "true";
12
- }
13
-
14
- /**
15
- * @param {import('node:stream').Writable | undefined} stdout
16
- */
17
- export function enterAlternateScreen(stdout) {
18
- if (!stdout?.isTTY || isAlternateScreenDisabled()) {
19
- return;
20
- }
21
- stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
22
- }
23
-
24
- /**
25
- * @param {import('node:stream').Writable | undefined} stdout
26
- */
27
- export function leaveAlternateScreen(stdout) {
28
- if (!stdout?.isTTY || isAlternateScreenDisabled()) {
29
- return;
30
- }
31
- stdout.write("\x1b[?1049l");
32
- }
33
-
34
- /**
35
- * Erase and home the cursor on the TTY Ink uses (stdout). Use before mounting a new Ink tree
36
- * so a previous fullscreen app does not leave ghost lines (e.g. shell footer over stack picker).
37
- *
38
- * @param {import('node:stream').Writable} [out] defaults to process.stdout
39
- */
40
- export function clearInkScreen(out = process.stdout) {
41
- if (!out?.isTTY || isAlternateScreenDisabled()) {
42
- return;
43
- }
44
- out.write("\x1b[2J\x1b[H");
45
- }
1
+ // Re-exported from cli/src/utilities/terminal.js — import from there for new code.
2
+ export {
3
+ isAlternateScreenDisabled,
4
+ enterAlternateScreen,
5
+ leaveAlternateScreen,
6
+ clearInkScreen
7
+ } from "../utilities/terminal.js";