santree 0.2.8 → 0.2.10

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.
@@ -17,12 +17,25 @@ import { getPRTemplate } from "../lib/github.js";
17
17
  import { renderPrompt, renderDiff } from "../lib/prompts.js";
18
18
  import * as os from "os";
19
19
  import { initialState, reducer } from "../lib/dashboard/types.js";
20
- import { loadDashboardData } from "../lib/dashboard/data.js";
20
+ import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
21
21
  import IssueList from "../lib/dashboard/IssueList.js";
22
22
  import DetailPanel from "../lib/dashboard/DetailPanel.js";
23
+ import ReviewList from "../lib/dashboard/ReviewList.js";
24
+ import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
23
25
  import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
24
26
  export const description = "Interactive dashboard of your Linear issues";
25
27
  const execAsync = promisify(exec);
28
+ const CLAUDE_VERSION = (() => {
29
+ const bin = path.join(os.homedir(), ".claude", "local", "claude");
30
+ try {
31
+ return (execSync(`${bin} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
32
+ .trim()
33
+ .split(" ")[0] ?? "");
34
+ }
35
+ catch {
36
+ return "";
37
+ }
38
+ })();
26
39
  // ── Helpers ───────────────────────────────────────────────────────────
27
40
  function isInTmux() {
28
41
  return !!process.env.TMUX;
@@ -35,6 +48,15 @@ function slugify(title) {
35
48
  .slice(0, 40);
36
49
  }
37
50
  // ── Scroll helpers ────────────────────────────────────────────────────
51
+ function countWithChildren(di) {
52
+ let count = 1;
53
+ if (di.children) {
54
+ for (const child of di.children) {
55
+ count += countWithChildren(child);
56
+ }
57
+ }
58
+ return count;
59
+ }
38
60
  function getRowIndexForFlatIndex(groups, flatIndex) {
39
61
  let row = 1; // skip column header row
40
62
  let issuesSeen = 0;
@@ -42,11 +64,14 @@ function getRowIndexForFlatIndex(groups, flatIndex) {
42
64
  row++; // project header
43
65
  for (const sg of g.statusGroups) {
44
66
  row++; // status header
45
- for (let i = 0; i < sg.issues.length; i++) {
46
- if (issuesSeen === flatIndex)
47
- return row;
48
- row++;
49
- issuesSeen++;
67
+ for (const di of sg.issues) {
68
+ const total = countWithChildren(di);
69
+ if (flatIndex >= issuesSeen && flatIndex < issuesSeen + total) {
70
+ // The target is within this issue or its children
71
+ return row + (flatIndex - issuesSeen);
72
+ }
73
+ row += total;
74
+ issuesSeen += total;
50
75
  }
51
76
  }
52
77
  }
@@ -65,11 +90,13 @@ function getFlatIndexForListRow(groups, listRow) {
65
90
  if (row === listRow)
66
91
  return null; // status header row
67
92
  row++;
68
- for (let i = 0; i < sg.issues.length; i++) {
69
- if (row === listRow)
70
- return issuesSeen;
71
- row++;
72
- issuesSeen++;
93
+ for (const di of sg.issues) {
94
+ const total = countWithChildren(di);
95
+ if (listRow >= row && listRow < row + total) {
96
+ return issuesSeen + (listRow - row);
97
+ }
98
+ row += total;
99
+ issuesSeen += total;
73
100
  }
74
101
  }
75
102
  }
@@ -155,7 +182,7 @@ export default function Dashboard() {
155
182
  const leftWidthRef = useRef(leftWidth);
156
183
  leftWidthRef.current = leftWidth;
157
184
  const rightWidth = columns - leftWidth - separatorWidth;
158
- const contentHeight = rows - 1; // 1 header
185
+ const contentHeight = rows - 2; // 2 header rows (tabs + version)
159
186
  const LIST_FOOTER_HEIGHT = 2;
160
187
  // ── Data loading ──────────────────────────────────────────────────
161
188
  const refresh = useCallback(async (isInitial = false) => {
@@ -168,8 +195,12 @@ export default function Dashboard() {
168
195
  }
169
196
  repoRootRef.current = repoRoot;
170
197
  try {
171
- const data = await loadDashboardData(repoRoot);
198
+ const [data, reviewData] = await Promise.all([
199
+ loadDashboardData(repoRoot),
200
+ loadReviewsData(repoRoot),
201
+ ]);
172
202
  dispatch({ type: "SET_DATA", ...data });
203
+ dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
173
204
  }
174
205
  catch (e) {
175
206
  dispatch({
@@ -215,6 +246,20 @@ export default function Dashboard() {
215
246
  const s = stateRef.current;
216
247
  const lw = leftWidthRef.current;
217
248
  const delta = button === 65 ? 3 : -3;
249
+ if (s.activeTab === "reviews") {
250
+ if (col <= lw) {
251
+ const maxIdx = s.flatReviews.length - 1;
252
+ if (maxIdx < 0)
253
+ return;
254
+ const next = Math.max(0, Math.min(s.reviewSelectedIndex + delta, maxIdx));
255
+ dispatch({ type: "REVIEW_SELECT", index: next });
256
+ }
257
+ else {
258
+ const next = Math.max(0, s.reviewDetailScrollOffset + delta);
259
+ dispatch({ type: "REVIEW_SCROLL_DETAIL", offset: next });
260
+ }
261
+ return;
262
+ }
218
263
  if (col <= lw) {
219
264
  // Scroll left pane (issue list)
220
265
  const maxIdx = s.flatIssues.length - 1;
@@ -240,9 +285,9 @@ export default function Dashboard() {
240
285
  draggingRef.current = true;
241
286
  return;
242
287
  }
243
- // Left-click press: select issue in left pane
288
+ // Left-click press: select item in left pane
244
289
  const s = stateRef.current;
245
- if (s.loading || s.error || s.flatIssues.length === 0)
290
+ if (s.loading || s.error)
246
291
  return;
247
292
  if (col > lw)
248
293
  return;
@@ -250,6 +295,19 @@ export default function Dashboard() {
250
295
  const contentRow = row - 2; // 0-based row within content area
251
296
  if (contentRow < 0)
252
297
  return;
298
+ if (s.activeTab === "reviews") {
299
+ if (s.flatReviews.length === 0)
300
+ return;
301
+ // Row 0 is column header, PRs start at row 1
302
+ const listRow = s.reviewListScrollOffset + contentRow;
303
+ const flatIdx = listRow - 1; // subtract column header
304
+ if (flatIdx >= 0 && flatIdx < s.flatReviews.length) {
305
+ dispatch({ type: "REVIEW_SELECT", index: flatIdx });
306
+ }
307
+ return;
308
+ }
309
+ if (s.flatIssues.length === 0)
310
+ return;
253
311
  const listRow = s.listScrollOffset + contentRow;
254
312
  const flatIdx = getFlatIndexForListRow(s.groups, listRow);
255
313
  if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
@@ -292,6 +350,22 @@ export default function Dashboard() {
292
350
  dispatch({ type: "SCROLL_LIST", offset });
293
351
  }
294
352
  }, [state.selectedIndex, state.groups, contentHeight, state.listScrollOffset]);
353
+ // ── Review list scroll tracking ──────────────────────────────────
354
+ useEffect(() => {
355
+ // Row index = 1 (column header) + flatIndex
356
+ const rowIdx = 1 + state.reviewSelectedIndex;
357
+ const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
358
+ let offset = state.reviewListScrollOffset;
359
+ if (rowIdx < offset) {
360
+ offset = Math.max(0, rowIdx - 1);
361
+ }
362
+ else if (rowIdx >= offset + maxVisible) {
363
+ offset = rowIdx - maxVisible + 2;
364
+ }
365
+ if (offset !== state.reviewListScrollOffset) {
366
+ dispatch({ type: "REVIEW_SCROLL_LIST", offset });
367
+ }
368
+ }, [state.reviewSelectedIndex, state.flatReviews, contentHeight, state.reviewListScrollOffset]);
295
369
  // ── Actions ───────────────────────────────────────────────────────
296
370
  const launchWorkInTmux = useCallback((di, mode, worktreePath) => {
297
371
  const windowName = di.issue.identifier;
@@ -359,7 +433,7 @@ export default function Dashboard() {
359
433
  exit();
360
434
  }
361
435
  }, [exit, refresh]);
362
- const createAndLaunch = useCallback(async (mode, runSetup) => {
436
+ const createAndLaunch = useCallback(async (mode, runSetup, base) => {
363
437
  const di = stateRef.current.flatIssues[stateRef.current.selectedIndex];
364
438
  if (!di)
365
439
  return;
@@ -373,24 +447,32 @@ export default function Dashboard() {
373
447
  dispatch({ type: "CREATION_START", ticketId });
374
448
  const slug = slugify(di.issue.title);
375
449
  const branchName = `feature/${ticketId}-${slug}`;
376
- const base = getDefaultBranch();
450
+ const defaultBranch = getDefaultBranch();
451
+ const baseBranch = base ?? defaultBranch;
452
+ const isDefaultBase = baseBranch === defaultBranch;
377
453
  // 1. Pull latest (async to avoid blocking the event loop)
378
454
  dispatch({ type: "CREATION_LOG", logs: `Fetching origin...\n` });
379
455
  try {
380
456
  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` });
457
+ if (isDefaultBase) {
458
+ // Only checkout + pull for default branch (can't checkout a branch with an active worktree)
459
+ dispatch({ type: "CREATION_LOG", logs: `Checking out ${baseBranch}...\n` });
460
+ await execAsync(`git checkout ${baseBranch}`, { cwd: repoRoot });
461
+ dispatch({ type: "CREATION_LOG", logs: `Pulling ${baseBranch}...\n` });
462
+ await execAsync(`git pull origin ${baseBranch}`, { cwd: repoRoot });
463
+ }
464
+ dispatch({ type: "CREATION_LOG", logs: `Pulled latest ${baseBranch}\n` });
386
465
  }
387
466
  catch (e) {
388
467
  const msg = e instanceof Error ? e.message : "Failed to pull latest";
389
468
  dispatch({ type: "CREATION_LOG", logs: `Warning: ${msg}\n` });
390
469
  }
391
470
  // 2. Create worktree
392
- dispatch({ type: "CREATION_LOG", logs: `Creating worktree ${branchName}...\n` });
393
- const result = await createWorktree(branchName, base, repoRoot);
471
+ dispatch({
472
+ type: "CREATION_LOG",
473
+ logs: `Creating worktree ${branchName} from ${baseBranch}...\n`,
474
+ });
475
+ const result = await createWorktree(branchName, baseBranch, repoRoot);
394
476
  if (!result.success || !result.path) {
395
477
  dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
396
478
  dispatch({
@@ -446,6 +528,16 @@ export default function Dashboard() {
446
528
  dispatch({ type: "CREATION_DONE" });
447
529
  launchAfterCreation(mode, result.path, ticketId);
448
530
  }, [launchAfterCreation]);
531
+ const proceedAfterBaseSelect = useCallback((mode, base) => {
532
+ const repoRoot = repoRootRef.current;
533
+ if (!repoRoot)
534
+ return;
535
+ if (hasInitScript(repoRoot)) {
536
+ dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
537
+ return;
538
+ }
539
+ createAndLaunch(mode, false, base);
540
+ }, [createAndLaunch]);
449
541
  const doWork = useCallback((mode) => {
450
542
  const di = state.flatIssues[state.selectedIndex];
451
543
  if (!di)
@@ -467,15 +559,25 @@ export default function Dashboard() {
467
559
  }
468
560
  }
469
561
  else {
470
- // No worktree — ask about setup if init script exists
471
- if (hasInitScript(repoRoot)) {
562
+ // No worktree — collect possible base branches
563
+ const defaultBranch = getDefaultBranch();
564
+ const baseOptions = [defaultBranch];
565
+ for (const fi of state.flatIssues) {
566
+ if (fi.worktree && !baseOptions.includes(fi.worktree.branch)) {
567
+ baseOptions.push(fi.worktree.branch);
568
+ }
569
+ }
570
+ if (baseOptions.length > 1) {
571
+ // Store mode in setupMode so we can retrieve it after base selection
472
572
  dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
573
+ // Immediately replace overlay with base-select (setupMode is preserved)
574
+ dispatch({ type: "BASE_SELECT_SHOW", options: baseOptions });
473
575
  return;
474
576
  }
475
- // No init scriptcreate directly
476
- createAndLaunch(mode, false);
577
+ // Only default branch available skip base select
578
+ proceedAfterBaseSelect(mode);
477
579
  }
478
- }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, createAndLaunch]);
580
+ }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, proceedAfterBaseSelect]);
479
581
  // ── Commit flow ──────────────────────────────────────────────────
480
582
  const handleStageAll = useCallback(async () => {
481
583
  const wtPath = stateRef.current.commitWorktreePath;
@@ -769,17 +871,48 @@ export default function Dashboard() {
769
871
  }
770
872
  return;
771
873
  }
874
+ // Base select overlay
875
+ if (state.overlay === "base-select") {
876
+ const opts = state.baseSelectOptions;
877
+ if (input === "j" || key.downArrow) {
878
+ const next = Math.min(state.baseSelectIndex + 1, opts.length - 1);
879
+ dispatch({ type: "BASE_SELECT_MOVE", index: next });
880
+ return;
881
+ }
882
+ if (input === "k" || key.upArrow) {
883
+ const prev = Math.max(state.baseSelectIndex - 1, 0);
884
+ dispatch({ type: "BASE_SELECT_MOVE", index: prev });
885
+ return;
886
+ }
887
+ if (key.return) {
888
+ const chosen = opts[state.baseSelectIndex];
889
+ if (chosen) {
890
+ dispatch({ type: "BASE_SELECT_CONFIRM", chosen });
891
+ const mode = state.setupMode;
892
+ if (mode) {
893
+ proceedAfterBaseSelect(mode, chosen);
894
+ }
895
+ }
896
+ return;
897
+ }
898
+ if (key.escape) {
899
+ dispatch({ type: "BASE_SELECT_DONE" });
900
+ return;
901
+ }
902
+ return;
903
+ }
772
904
  // Confirm setup overlay
773
905
  if (state.overlay === "confirm-setup") {
774
906
  const mode = state.setupMode;
907
+ const base = state.baseSelectChosen ?? undefined;
775
908
  if (input === "y" && mode) {
776
909
  dispatch({ type: "SETUP_CONFIRM_DONE" });
777
- createAndLaunch(mode, true);
910
+ createAndLaunch(mode, true, base);
778
911
  return;
779
912
  }
780
913
  if (input === "n" && mode) {
781
914
  dispatch({ type: "SETUP_CONFIRM_DONE" });
782
- createAndLaunch(mode, false);
915
+ createAndLaunch(mode, false, base);
783
916
  return;
784
917
  }
785
918
  if (key.escape) {
@@ -840,11 +973,227 @@ export default function Dashboard() {
840
973
  }
841
974
  return;
842
975
  }
976
+ // Tab switching (only when no overlay is active)
977
+ if (key.tab) {
978
+ dispatch({
979
+ type: "SET_TAB",
980
+ tab: state.activeTab === "issues" ? "reviews" : "issues",
981
+ });
982
+ return;
983
+ }
984
+ if (input === "1") {
985
+ dispatch({ type: "SET_TAB", tab: "issues" });
986
+ return;
987
+ }
988
+ if (input === "2") {
989
+ dispatch({ type: "SET_TAB", tab: "reviews" });
990
+ return;
991
+ }
843
992
  // Quit
844
993
  if (input === "q") {
845
994
  exit();
846
995
  return;
847
996
  }
997
+ // Refresh (shared across tabs)
998
+ if (input === "R") {
999
+ refresh();
1000
+ return;
1001
+ }
1002
+ // ── Reviews tab keyboard ─────────────────────────────────
1003
+ if (state.activeTab === "reviews") {
1004
+ const maxReviewIdx = state.flatReviews.length - 1;
1005
+ if (input === "j" || (key.downArrow && !key.shift)) {
1006
+ const next = Math.min(state.reviewSelectedIndex + 1, maxReviewIdx);
1007
+ dispatch({ type: "REVIEW_SELECT", index: next });
1008
+ return;
1009
+ }
1010
+ if (input === "k" || (key.upArrow && !key.shift)) {
1011
+ const prev = Math.max(state.reviewSelectedIndex - 1, 0);
1012
+ dispatch({ type: "REVIEW_SELECT", index: prev });
1013
+ return;
1014
+ }
1015
+ if (key.shift && key.downArrow) {
1016
+ dispatch({
1017
+ type: "REVIEW_SCROLL_DETAIL",
1018
+ offset: state.reviewDetailScrollOffset + 3,
1019
+ });
1020
+ return;
1021
+ }
1022
+ if (key.shift && key.upArrow) {
1023
+ dispatch({
1024
+ type: "REVIEW_SCROLL_DETAIL",
1025
+ offset: Math.max(0, state.reviewDetailScrollOffset - 3),
1026
+ });
1027
+ return;
1028
+ }
1029
+ const ri = state.flatReviews[state.reviewSelectedIndex];
1030
+ if (!ri)
1031
+ return;
1032
+ // Open PR in browser
1033
+ if (input === "o") {
1034
+ if (ri.pr.url) {
1035
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1036
+ execSync(`${openCmd} "${ri.pr.url}"`, { stdio: "ignore" });
1037
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
1038
+ }
1039
+ return;
1040
+ }
1041
+ // Create worktree from PR branch (checkout for local testing)
1042
+ if (input === "w") {
1043
+ if (ri.worktree) {
1044
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree already exists" });
1045
+ return;
1046
+ }
1047
+ if (!ri.branch) {
1048
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No branch info" });
1049
+ return;
1050
+ }
1051
+ const repoRoot = repoRootRef.current;
1052
+ if (!repoRoot)
1053
+ return;
1054
+ if (state.creatingForTicket)
1055
+ return;
1056
+ const ticketId = extractTicketId(ri.branch);
1057
+ if (!ticketId) {
1058
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No ticket ID in branch" });
1059
+ return;
1060
+ }
1061
+ dispatch({ type: "CREATION_START", ticketId });
1062
+ (async () => {
1063
+ try {
1064
+ dispatch({ type: "CREATION_LOG", logs: `Fetching ${ri.branch}...\n` });
1065
+ await execAsync(`git fetch origin ${ri.branch}`, { cwd: repoRoot });
1066
+ dispatch({ type: "CREATION_LOG", logs: `Creating worktree...\n` });
1067
+ const result = await createWorktree(ri.branch, ri.baseBranch ?? getDefaultBranch(), repoRoot);
1068
+ if (!result.success || !result.path) {
1069
+ dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
1070
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${result.error}` });
1071
+ return;
1072
+ }
1073
+ dispatch({ type: "CREATION_LOG", logs: `Worktree at ${result.path}\n` });
1074
+ // Run init script if available
1075
+ if (hasInitScript(repoRoot)) {
1076
+ const initScript = getInitScriptPath(repoRoot);
1077
+ let canExecute = true;
1078
+ try {
1079
+ fs.accessSync(initScript, fs.constants.X_OK);
1080
+ }
1081
+ catch {
1082
+ dispatch({ type: "CREATION_LOG", logs: "init.sh not executable, skipping\n" });
1083
+ canExecute = false;
1084
+ }
1085
+ if (canExecute) {
1086
+ dispatch({ type: "CREATION_LOG", logs: "Running init.sh...\n" });
1087
+ let lastLen = 0;
1088
+ const initResult = await spawnAsync(initScript, [], {
1089
+ cwd: result.path,
1090
+ env: {
1091
+ ...process.env,
1092
+ SANTREE_WORKTREE_PATH: result.path,
1093
+ SANTREE_REPO_ROOT: repoRoot,
1094
+ },
1095
+ onOutput: (output) => {
1096
+ const delta = output.slice(lastLen);
1097
+ if (delta)
1098
+ dispatch({ type: "CREATION_LOG", logs: delta });
1099
+ lastLen = output.length;
1100
+ },
1101
+ });
1102
+ if (initResult.code !== 0) {
1103
+ dispatch({
1104
+ type: "CREATION_LOG",
1105
+ logs: `\ninit.sh exited with code ${initResult.code}\n`,
1106
+ });
1107
+ }
1108
+ else {
1109
+ dispatch({ type: "CREATION_LOG", logs: "\nSetup complete!\n" });
1110
+ }
1111
+ }
1112
+ }
1113
+ dispatch({ type: "CREATION_DONE" });
1114
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Worktree created for ${ticketId}` });
1115
+ // Open in editor automatically
1116
+ const editor = process.env.SANTREE_EDITOR || "code";
1117
+ spawn(editor, [result.path], { detached: true, stdio: "ignore" }).unref();
1118
+ refresh();
1119
+ }
1120
+ catch (e) {
1121
+ dispatch({ type: "CREATION_ERROR", error: e?.message ?? "Failed" });
1122
+ }
1123
+ })();
1124
+ return;
1125
+ }
1126
+ // Open in editor
1127
+ if (input === "e") {
1128
+ if (!ri.worktree) {
1129
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree (press w to checkout)" });
1130
+ return;
1131
+ }
1132
+ const editor = process.env.SANTREE_EDITOR || "code";
1133
+ spawn(editor, [ri.worktree.path], { detached: true, stdio: "ignore" }).unref();
1134
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
1135
+ return;
1136
+ }
1137
+ // AI Review in tmux
1138
+ if (input === "r") {
1139
+ if (!ri.worktree) {
1140
+ dispatch({
1141
+ type: "SET_ACTION_MESSAGE",
1142
+ message: "No worktree (press w to checkout first)",
1143
+ });
1144
+ return;
1145
+ }
1146
+ if (isInTmux()) {
1147
+ const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
1148
+ try {
1149
+ execSync(`tmux new-window -n "${windowName}" -c "${ri.worktree.path}"`, {
1150
+ stdio: "ignore",
1151
+ });
1152
+ execSync("sleep 0.1", { stdio: "ignore" });
1153
+ execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, {
1154
+ stdio: "ignore",
1155
+ });
1156
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched AI review in tmux" });
1157
+ }
1158
+ catch {
1159
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1160
+ }
1161
+ }
1162
+ else {
1163
+ leaveAltScreen();
1164
+ console.log(`SANTREE_CD:${ri.worktree.path}`);
1165
+ exit();
1166
+ }
1167
+ return;
1168
+ }
1169
+ // Delete worktree
1170
+ if (input === "d") {
1171
+ if (!ri.worktree || !ri.branch) {
1172
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to remove" });
1173
+ return;
1174
+ }
1175
+ const repoRoot = repoRootRef.current;
1176
+ if (!repoRoot)
1177
+ return;
1178
+ const ticketId = extractTicketId(ri.branch);
1179
+ if (!ticketId)
1180
+ return;
1181
+ dispatch({ type: "DELETE_START", ticketId });
1182
+ const force = ri.worktree.dirty;
1183
+ removeWorktree(ri.branch, repoRoot, force).then((result) => {
1184
+ dispatch({ type: "DELETE_DONE" });
1185
+ if (result.success) {
1186
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Removed worktree` });
1187
+ refresh();
1188
+ }
1189
+ else {
1190
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${result.error}` });
1191
+ }
1192
+ });
1193
+ return;
1194
+ }
1195
+ return; // prevent fallthrough to issues actions
1196
+ }
848
1197
  const maxIndex = state.flatIssues.length - 1;
849
1198
  // Navigation
850
1199
  if (input === "j" || (key.downArrow && !key.shift)) {
@@ -1056,11 +1405,6 @@ export default function Dashboard() {
1056
1405
  dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
1057
1406
  return;
1058
1407
  }
1059
- // Refresh
1060
- if (input === "R") {
1061
- refresh();
1062
- return;
1063
- }
1064
1408
  }, { isActive: state.overlay !== "commit" || state.commitPhase !== "awaiting-message" });
1065
1409
  // ── Render ─────────────────────────────────────────────────────────
1066
1410
  if (state.loading) {
@@ -1069,9 +1413,15 @@ export default function Dashboard() {
1069
1413
  if (state.error) {
1070
1414
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
1071
1415
  }
1072
- if (state.flatIssues.length === 0) {
1073
- 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
- }
1075
1416
  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 })) })] }))] }));
1417
+ const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1418
+ 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] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), 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) => {
1419
+ const selected = idx === state.baseSelectIndex;
1420
+ const defaultBranch = getDefaultBranch();
1421
+ const label = branch === defaultBranch ? `${branch} (default)` : branch;
1422
+ return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1423
+ }), _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: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_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.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
1424
+ .split("\n")
1425
+ .slice(-(contentHeight - 1))
1426
+ .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : 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
1427
  }
package/dist/lib/ai.js CHANGED
@@ -143,6 +143,9 @@ export function launchAgent(prompt, opts) {
143
143
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
144
144
  }
145
145
  const args = [];
146
+ if (process.env.SANTREE_SKIP_PERMISSIONS) {
147
+ args.push("--dangerously-skip-permissions");
148
+ }
146
149
  if (opts?.planMode) {
147
150
  args.push("--permission-mode", "plan");
148
151
  }
@@ -167,7 +170,8 @@ export function runAgent(prompt) {
167
170
  if (!bin) {
168
171
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
169
172
  }
170
- const result = spawnSync(bin, ["-p", "--output-format", "text", "--", promptArg(prompt)], {
173
+ const skipPerms = process.env.SANTREE_SKIP_PERMISSIONS ? ["--dangerously-skip-permissions"] : [];
174
+ const result = spawnSync(bin, [...skipPerms, "-p", "--output-format", "text", "--", promptArg(prompt)], {
171
175
  encoding: "utf-8",
172
176
  maxBuffer: 10 * 1024 * 1024,
173
177
  });