santree 0.2.7 → 0.2.9

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.
@@ -35,6 +35,15 @@ function slugify(title) {
35
35
  .slice(0, 40);
36
36
  }
37
37
  // ── Scroll helpers ────────────────────────────────────────────────────
38
+ function countWithChildren(di) {
39
+ let count = 1;
40
+ if (di.children) {
41
+ for (const child of di.children) {
42
+ count += countWithChildren(child);
43
+ }
44
+ }
45
+ return count;
46
+ }
38
47
  function getRowIndexForFlatIndex(groups, flatIndex) {
39
48
  let row = 1; // skip column header row
40
49
  let issuesSeen = 0;
@@ -42,11 +51,14 @@ function getRowIndexForFlatIndex(groups, flatIndex) {
42
51
  row++; // project header
43
52
  for (const sg of g.statusGroups) {
44
53
  row++; // status header
45
- for (let i = 0; i < sg.issues.length; i++) {
46
- if (issuesSeen === flatIndex)
47
- return row;
48
- row++;
49
- issuesSeen++;
54
+ for (const di of sg.issues) {
55
+ const total = countWithChildren(di);
56
+ if (flatIndex >= issuesSeen && flatIndex < issuesSeen + total) {
57
+ // The target is within this issue or its children
58
+ return row + (flatIndex - issuesSeen);
59
+ }
60
+ row += total;
61
+ issuesSeen += total;
50
62
  }
51
63
  }
52
64
  }
@@ -65,11 +77,13 @@ function getFlatIndexForListRow(groups, listRow) {
65
77
  if (row === listRow)
66
78
  return null; // status header row
67
79
  row++;
68
- for (let i = 0; i < sg.issues.length; i++) {
69
- if (row === listRow)
70
- return issuesSeen;
71
- row++;
72
- issuesSeen++;
80
+ for (const di of sg.issues) {
81
+ const total = countWithChildren(di);
82
+ if (listRow >= row && listRow < row + total) {
83
+ return issuesSeen + (listRow - row);
84
+ }
85
+ row += total;
86
+ issuesSeen += total;
73
87
  }
74
88
  }
75
89
  }
@@ -359,7 +373,7 @@ export default function Dashboard() {
359
373
  exit();
360
374
  }
361
375
  }, [exit, refresh]);
362
- const createAndLaunch = useCallback(async (mode, runSetup) => {
376
+ const createAndLaunch = useCallback(async (mode, runSetup, base) => {
363
377
  const di = stateRef.current.flatIssues[stateRef.current.selectedIndex];
364
378
  if (!di)
365
379
  return;
@@ -373,24 +387,32 @@ export default function Dashboard() {
373
387
  dispatch({ type: "CREATION_START", ticketId });
374
388
  const slug = slugify(di.issue.title);
375
389
  const branchName = `feature/${ticketId}-${slug}`;
376
- const base = getDefaultBranch();
390
+ const defaultBranch = getDefaultBranch();
391
+ const baseBranch = base ?? defaultBranch;
392
+ const isDefaultBase = baseBranch === defaultBranch;
377
393
  // 1. Pull latest (async to avoid blocking the event loop)
378
394
  dispatch({ type: "CREATION_LOG", logs: `Fetching origin...\n` });
379
395
  try {
380
396
  await execAsync("git fetch origin", { cwd: repoRoot });
381
- dispatch({ type: "CREATION_LOG", logs: `Checking out ${base}...\n` });
382
- await execAsync(`git checkout ${base}`, { cwd: repoRoot });
383
- dispatch({ type: "CREATION_LOG", logs: `Pulling ${base}...\n` });
384
- await execAsync(`git pull origin ${base}`, { cwd: repoRoot });
385
- dispatch({ type: "CREATION_LOG", logs: `Pulled latest ${base}\n` });
397
+ if (isDefaultBase) {
398
+ // Only checkout + pull for default branch (can't checkout a branch with an active worktree)
399
+ dispatch({ type: "CREATION_LOG", logs: `Checking out ${baseBranch}...\n` });
400
+ await execAsync(`git checkout ${baseBranch}`, { cwd: repoRoot });
401
+ dispatch({ type: "CREATION_LOG", logs: `Pulling ${baseBranch}...\n` });
402
+ await execAsync(`git pull origin ${baseBranch}`, { cwd: repoRoot });
403
+ }
404
+ dispatch({ type: "CREATION_LOG", logs: `Pulled latest ${baseBranch}\n` });
386
405
  }
387
406
  catch (e) {
388
407
  const msg = e instanceof Error ? e.message : "Failed to pull latest";
389
408
  dispatch({ type: "CREATION_LOG", logs: `Warning: ${msg}\n` });
390
409
  }
391
410
  // 2. Create worktree
392
- dispatch({ type: "CREATION_LOG", logs: `Creating worktree ${branchName}...\n` });
393
- const result = await createWorktree(branchName, base, repoRoot);
411
+ dispatch({
412
+ type: "CREATION_LOG",
413
+ logs: `Creating worktree ${branchName} from ${baseBranch}...\n`,
414
+ });
415
+ const result = await createWorktree(branchName, baseBranch, repoRoot);
394
416
  if (!result.success || !result.path) {
395
417
  dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
396
418
  dispatch({
@@ -446,6 +468,16 @@ export default function Dashboard() {
446
468
  dispatch({ type: "CREATION_DONE" });
447
469
  launchAfterCreation(mode, result.path, ticketId);
448
470
  }, [launchAfterCreation]);
471
+ const proceedAfterBaseSelect = useCallback((mode, base) => {
472
+ const repoRoot = repoRootRef.current;
473
+ if (!repoRoot)
474
+ return;
475
+ if (hasInitScript(repoRoot)) {
476
+ dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
477
+ return;
478
+ }
479
+ createAndLaunch(mode, false, base);
480
+ }, [createAndLaunch]);
449
481
  const doWork = useCallback((mode) => {
450
482
  const di = state.flatIssues[state.selectedIndex];
451
483
  if (!di)
@@ -467,15 +499,25 @@ export default function Dashboard() {
467
499
  }
468
500
  }
469
501
  else {
470
- // No worktree — ask about setup if init script exists
471
- if (hasInitScript(repoRoot)) {
502
+ // No worktree — collect possible base branches
503
+ const defaultBranch = getDefaultBranch();
504
+ const baseOptions = [defaultBranch];
505
+ for (const fi of state.flatIssues) {
506
+ if (fi.worktree && !baseOptions.includes(fi.worktree.branch)) {
507
+ baseOptions.push(fi.worktree.branch);
508
+ }
509
+ }
510
+ if (baseOptions.length > 1) {
511
+ // Store mode in setupMode so we can retrieve it after base selection
472
512
  dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
513
+ // Immediately replace overlay with base-select (setupMode is preserved)
514
+ dispatch({ type: "BASE_SELECT_SHOW", options: baseOptions });
473
515
  return;
474
516
  }
475
- // No init scriptcreate directly
476
- createAndLaunch(mode, false);
517
+ // Only default branch available skip base select
518
+ proceedAfterBaseSelect(mode);
477
519
  }
478
- }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, createAndLaunch]);
520
+ }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, proceedAfterBaseSelect]);
479
521
  // ── Commit flow ──────────────────────────────────────────────────
480
522
  const handleStageAll = useCallback(async () => {
481
523
  const wtPath = stateRef.current.commitWorktreePath;
@@ -769,17 +811,48 @@ export default function Dashboard() {
769
811
  }
770
812
  return;
771
813
  }
814
+ // Base select overlay
815
+ if (state.overlay === "base-select") {
816
+ const opts = state.baseSelectOptions;
817
+ if (input === "j" || key.downArrow) {
818
+ const next = Math.min(state.baseSelectIndex + 1, opts.length - 1);
819
+ dispatch({ type: "BASE_SELECT_MOVE", index: next });
820
+ return;
821
+ }
822
+ if (input === "k" || key.upArrow) {
823
+ const prev = Math.max(state.baseSelectIndex - 1, 0);
824
+ dispatch({ type: "BASE_SELECT_MOVE", index: prev });
825
+ return;
826
+ }
827
+ if (key.return) {
828
+ const chosen = opts[state.baseSelectIndex];
829
+ if (chosen) {
830
+ dispatch({ type: "BASE_SELECT_CONFIRM", chosen });
831
+ const mode = state.setupMode;
832
+ if (mode) {
833
+ proceedAfterBaseSelect(mode, chosen);
834
+ }
835
+ }
836
+ return;
837
+ }
838
+ if (key.escape) {
839
+ dispatch({ type: "BASE_SELECT_DONE" });
840
+ return;
841
+ }
842
+ return;
843
+ }
772
844
  // Confirm setup overlay
773
845
  if (state.overlay === "confirm-setup") {
774
846
  const mode = state.setupMode;
847
+ const base = state.baseSelectChosen ?? undefined;
775
848
  if (input === "y" && mode) {
776
849
  dispatch({ type: "SETUP_CONFIRM_DONE" });
777
- createAndLaunch(mode, true);
850
+ createAndLaunch(mode, true, base);
778
851
  return;
779
852
  }
780
853
  if (input === "n" && mode) {
781
854
  dispatch({ type: "SETUP_CONFIRM_DONE" });
782
- createAndLaunch(mode, false);
855
+ createAndLaunch(mode, false, base);
783
856
  return;
784
857
  }
785
858
  if (key.escape) {
@@ -1073,5 +1146,10 @@ export default function Dashboard() {
1073
1146
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "No active issues assigned to you" }), _jsx(Text, { dimColor: true, children: "Press R to refresh or q to quit" })] }) }));
1074
1147
  }
1075
1148
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1076
- return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" v", version] }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: _jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket }) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, scrollOffset: state.detailScrollOffset })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1149
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" v", version] }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
1150
+ const selected = idx === state.baseSelectIndex;
1151
+ const defaultBranch = getDefaultBranch();
1152
+ const label = branch === defaultBranch ? `${branch} (default)` : branch;
1153
+ return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1154
+ }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: _jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket }) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, scrollOffset: state.detailScrollOffset })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1077
1155
  }
@@ -175,11 +175,11 @@ function buildSantreeStatusline(cwd, model, usedPercentage) {
175
175
  if (model) {
176
176
  parts.push(`${c.blue}${model}${c.reset}`);
177
177
  }
178
- // Usable context % (accounting for 80% auto-compact threshold)
178
+ // Context usage %
179
179
  if (usedPercentage !== null) {
180
- const usable = Math.round(usedPercentage * 1.25);
181
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
182
- parts.push(`${color}${usable}%${c.reset}`);
180
+ const used = Math.round(usedPercentage);
181
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
182
+ parts.push(`${color}${used}%${c.reset}`);
183
183
  }
184
184
  return parts.join(" | ");
185
185
  }
@@ -200,11 +200,11 @@ function buildGitStatusline(cwd, model, usedPercentage) {
200
200
  if (model) {
201
201
  parts.push(`${c.blue}${model}${c.reset}`);
202
202
  }
203
- // Usable context %
203
+ // Context usage %
204
204
  if (usedPercentage !== null) {
205
- const usable = Math.round(usedPercentage * 1.25);
206
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
207
- parts.push(`${color}${usable}%${c.reset}`);
205
+ const used = Math.round(usedPercentage);
206
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
207
+ parts.push(`${color}${used}%${c.reset}`);
208
208
  }
209
209
  return parts.join(" | ");
210
210
  }
@@ -218,11 +218,11 @@ function buildPlainStatusline(cwd, model, usedPercentage) {
218
218
  if (model) {
219
219
  parts.push(`${c.blue}${model}${c.reset}`);
220
220
  }
221
- // Usable context %
221
+ // Context usage %
222
222
  if (usedPercentage !== null) {
223
- const usable = Math.round(usedPercentage * 1.25);
224
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
225
- parts.push(`${color}${usable}%${c.reset}`);
223
+ const used = Math.round(usedPercentage);
224
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
225
+ parts.push(`${color}${used}%${c.reset}`);
226
226
  }
227
227
  return parts.join(" | ");
228
228
  }
@@ -79,13 +79,26 @@ function buildRows(groups, flatIssues) {
79
79
  // Build a map from issue identifier to flat index
80
80
  const indexMap = new Map();
81
81
  flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
82
+ function pushIssueWithChildren(di, depth) {
83
+ rows.push({
84
+ kind: "issue",
85
+ issue: di,
86
+ flatIndex: indexMap.get(di.issue.identifier) ?? -1,
87
+ depth,
88
+ });
89
+ if (di.children) {
90
+ for (const child of di.children) {
91
+ pushIssueWithChildren(child, depth + 1);
92
+ }
93
+ }
94
+ }
82
95
  for (const group of groups) {
83
96
  const totalIssues = group.statusGroups.reduce((sum, sg) => sum + sg.issues.length, 0);
84
97
  rows.push({ kind: "header", name: group.name, count: totalIssues });
85
98
  for (const sg of group.statusGroups) {
86
99
  rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
87
100
  for (const di of sg.issues) {
88
- rows.push({ kind: "issue", issue: di, flatIndex: indexMap.get(di.issue.identifier) ?? -1 });
101
+ pushIssueWithChildren(di, 0);
89
102
  }
90
103
  }
91
104
  }
@@ -115,7 +128,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
115
128
  if (row.kind === "status-header") {
116
129
  return (_jsx(Box, { children: _jsxs(Text, { color: stateColor(row.type, row.name), dimColor: true, children: [" ", row.name, " (", row.count, ")"] }) }, `sh-${i}`));
117
130
  }
118
- const { issue, flatIndex } = row;
131
+ const { issue, flatIndex, depth } = row;
119
132
  const selected = flatIndex === selectedIndex;
120
133
  const di = issue;
121
134
  const sc = stateColor(di.issue.state.type, di.issue.state.name);
@@ -126,10 +139,12 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
126
139
  const pr = prIndicator(di.pr);
127
140
  const prio = priorityIndicator(di.issue.priority);
128
141
  const cursor = selected ? ">" : " ";
129
- const title = di.issue.title.length > titleMaxWidth
130
- ? di.issue.title.slice(0, titleMaxWidth - 1) + "…"
142
+ const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
143
+ const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
144
+ const title = di.issue.title.length > adjustedTitleWidth
145
+ ? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
131
146
  : di.issue.title;
132
147
  const bg = selected ? "#1e3a5f" : undefined;
133
- return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsx(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: di.issue.identifier.padEnd(10) }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
148
+ return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(adjustedTitleWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
134
149
  }) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Shift + \u2191\u2193" }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "E" }), _jsx(Text, { color: "white", children: " Workspace" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), _jsx(Text, { color: "white", children: " Refresh" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
135
150
  }
@@ -129,9 +129,37 @@ export async function loadDashboardData(repoRoot) {
129
129
  reviews: reviewsInfo,
130
130
  };
131
131
  }));
132
- // Group by project
132
+ // ── Compute parent-child relationships ──────────────────────────
133
+ // Build a map from worktree branch → DashboardIssue
134
+ const allIssues = [...enriched, ...orphans];
135
+ const branchToIssue = new Map();
136
+ for (const di of allIssues) {
137
+ if (di.worktree)
138
+ branchToIssue.set(di.worktree.branch, di);
139
+ }
140
+ // For each issue with a worktree, check if its base_branch matches another issue's branch
141
+ const childTicketIds = new Set();
142
+ for (const di of allIssues) {
143
+ if (!di.worktree)
144
+ continue;
145
+ const ticketId = di.issue.identifier;
146
+ const baseBranch = metadata[ticketId]?.base_branch;
147
+ if (!baseBranch)
148
+ continue; // no custom base = branched from default, not a child
149
+ const parent = branchToIssue.get(baseBranch);
150
+ if (!parent || parent === di)
151
+ continue;
152
+ di.parentTicketId = parent.issue.identifier;
153
+ if (!parent.children)
154
+ parent.children = [];
155
+ parent.children.push(di);
156
+ childTicketIds.add(ticketId);
157
+ }
158
+ // Group by project (excluding children — they'll appear nested under parents)
133
159
  const groupMap = new Map();
134
160
  for (const di of enriched) {
161
+ if (childTicketIds.has(di.issue.identifier))
162
+ continue;
135
163
  const key = di.issue.projectName ?? "No Project";
136
164
  const list = groupMap.get(key) ?? [];
137
165
  list.push(di);
@@ -169,8 +197,9 @@ export async function loadDashboardData(repoRoot) {
169
197
  statusGroups,
170
198
  };
171
199
  });
172
- // Append orphaned worktrees as a separate group at the bottom
173
- if (orphans.length > 0) {
200
+ // Append orphaned worktrees as a separate group at the bottom (excluding children)
201
+ const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
202
+ if (topLevelOrphans.length > 0) {
174
203
  groups.push({
175
204
  name: "Orphaned Worktrees",
176
205
  id: null,
@@ -178,11 +207,21 @@ export async function loadDashboardData(repoRoot) {
178
207
  {
179
208
  name: "Orphaned",
180
209
  type: "orphaned",
181
- issues: orphans,
210
+ issues: topLevelOrphans,
182
211
  },
183
212
  ],
184
213
  });
185
214
  }
186
- const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues));
215
+ // Flatten with children inserted right after their parent
216
+ function flattenWithChildren(di) {
217
+ const result = [di];
218
+ if (di.children) {
219
+ for (const child of di.children) {
220
+ result.push(...flattenWithChildren(child));
221
+ }
222
+ }
223
+ return result;
224
+ }
225
+ const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
187
226
  return { groups, flatIssues };
188
227
  }
@@ -30,6 +30,8 @@ export interface DashboardIssue {
30
30
  pr: PRInfo | null;
31
31
  checks: PRCheck[] | null;
32
32
  reviews: PRReview[] | null;
33
+ parentTicketId?: string;
34
+ children?: DashboardIssue[];
33
35
  }
34
36
  export interface StatusGroup {
35
37
  name: string;
@@ -41,7 +43,7 @@ export interface ProjectGroup {
41
43
  id: string | null;
42
44
  statusGroups: StatusGroup[];
43
45
  }
44
- export type ActionOverlay = "mode-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
46
+ export type ActionOverlay = "mode-select" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
45
47
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
46
48
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
47
49
  export interface DashboardState {
@@ -75,6 +77,9 @@ export interface DashboardState {
75
77
  prCreateBody: string | null;
76
78
  prCreateTitle: string | null;
77
79
  setupMode: "plan" | "implement" | null;
80
+ baseSelectOptions: string[];
81
+ baseSelectIndex: number;
82
+ baseSelectChosen: string | null;
78
83
  }
79
84
  export type DashboardAction = {
80
85
  type: "SET_DATA";
@@ -164,6 +169,17 @@ export type DashboardAction = {
164
169
  mode: "plan" | "implement";
165
170
  } | {
166
171
  type: "SETUP_CONFIRM_DONE";
172
+ } | {
173
+ type: "BASE_SELECT_SHOW";
174
+ options: string[];
175
+ } | {
176
+ type: "BASE_SELECT_MOVE";
177
+ index: number;
178
+ } | {
179
+ type: "BASE_SELECT_CONFIRM";
180
+ chosen: string;
181
+ } | {
182
+ type: "BASE_SELECT_DONE";
167
183
  };
168
184
  export declare const initialState: DashboardState;
169
185
  export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
@@ -30,6 +30,9 @@ export const initialState = {
30
30
  prCreateBody: null,
31
31
  prCreateTitle: null,
32
32
  setupMode: null,
33
+ baseSelectOptions: [],
34
+ baseSelectIndex: 0,
35
+ baseSelectChosen: null,
33
36
  };
34
37
  export function reducer(state, action) {
35
38
  switch (action.type) {
@@ -81,9 +84,21 @@ export function reducer(state, action) {
81
84
  case "CREATION_LOG":
82
85
  return { ...state, creationLogs: state.creationLogs + action.logs };
83
86
  case "CREATION_DONE":
84
- return { ...state, creatingForTicket: null, creationLogs: "", creationError: null };
87
+ return {
88
+ ...state,
89
+ creatingForTicket: null,
90
+ creationLogs: "",
91
+ creationError: null,
92
+ baseSelectChosen: null,
93
+ };
85
94
  case "CREATION_ERROR":
86
- return { ...state, creationError: action.error, creatingForTicket: null, creationLogs: "" };
95
+ return {
96
+ ...state,
97
+ creationError: action.error,
98
+ creatingForTicket: null,
99
+ creationLogs: "",
100
+ baseSelectChosen: null,
101
+ };
87
102
  case "DELETE_START":
88
103
  return { ...state, deletingForTicket: action.ticketId };
89
104
  case "DELETE_DONE":
@@ -176,6 +191,29 @@ export function reducer(state, action) {
176
191
  overlay: null,
177
192
  setupMode: null,
178
193
  };
194
+ case "BASE_SELECT_SHOW":
195
+ return {
196
+ ...state,
197
+ overlay: "base-select",
198
+ baseSelectOptions: action.options,
199
+ baseSelectIndex: 0,
200
+ baseSelectChosen: null,
201
+ };
202
+ case "BASE_SELECT_MOVE":
203
+ return { ...state, baseSelectIndex: action.index };
204
+ case "BASE_SELECT_CONFIRM":
205
+ return {
206
+ ...state,
207
+ overlay: null,
208
+ baseSelectChosen: action.chosen,
209
+ };
210
+ case "BASE_SELECT_DONE":
211
+ return {
212
+ ...state,
213
+ overlay: null,
214
+ baseSelectOptions: [],
215
+ baseSelectIndex: 0,
216
+ };
179
217
  default:
180
218
  return state;
181
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",