santree 0.7.5 → 0.7.6

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 (109) hide show
  1. package/README.md +2 -3
  2. package/dist/commands/config.d.ts +12 -0
  3. package/dist/commands/config.js +444 -0
  4. package/dist/commands/dashboard.js +165 -61
  5. package/dist/commands/helpers/statusline.js +0 -30
  6. package/dist/commands/issue/index.d.ts +1 -1
  7. package/dist/commands/issue/index.js +1 -1
  8. package/dist/commands/pr/context.d.ts +7 -0
  9. package/dist/commands/pr/context.js +36 -0
  10. package/dist/commands/pr/fix.d.ts +11 -2
  11. package/dist/commands/pr/fix.js +64 -12
  12. package/dist/commands/worktree/create.js +6 -1
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/lib/ai.d.ts +11 -0
  15. package/dist/lib/ai.js +71 -2
  16. package/dist/lib/claude-config.d.ts +18 -4
  17. package/dist/lib/claude-config.js +58 -23
  18. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  19. package/dist/lib/config/TrackerPicker.js +113 -0
  20. package/dist/lib/config/diagnostics.d.ts +37 -0
  21. package/dist/lib/config/diagnostics.js +190 -0
  22. package/dist/lib/config-store.d.ts +23 -0
  23. package/dist/lib/config-store.js +68 -0
  24. package/dist/lib/dashboard/DetailPanel.d.ts +4 -2
  25. package/dist/lib/dashboard/DetailPanel.js +44 -37
  26. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  27. package/dist/lib/dashboard/IssueList.js +24 -5
  28. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  29. package/dist/lib/dashboard/Overlays.js +3 -4
  30. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  31. package/dist/lib/dashboard/ReviewList.js +6 -3
  32. package/dist/lib/dashboard/data.js +7 -34
  33. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  34. package/dist/lib/dashboard/external-editor.js +3 -2
  35. package/dist/lib/dashboard/types.d.ts +13 -2
  36. package/dist/lib/dashboard/types.js +33 -2
  37. package/dist/lib/exec.d.ts +13 -0
  38. package/dist/lib/exec.js +11 -1
  39. package/dist/lib/fix-loop.d.ts +45 -0
  40. package/dist/lib/fix-loop.js +83 -0
  41. package/dist/lib/git.d.ts +3 -29
  42. package/dist/lib/git.js +6 -62
  43. package/dist/lib/github.d.ts +70 -0
  44. package/dist/lib/github.js +128 -0
  45. package/dist/lib/multiplexer/cmux.js +143 -17
  46. package/dist/lib/multiplexer/none.js +3 -3
  47. package/dist/lib/multiplexer/tmux.js +5 -29
  48. package/dist/lib/multiplexer/types.d.ts +34 -1
  49. package/dist/lib/open-url.d.ts +3 -2
  50. package/dist/lib/open-url.js +5 -4
  51. package/dist/lib/prompts.d.ts +35 -1
  52. package/dist/lib/prompts.js +7 -0
  53. package/dist/lib/setup/apply.d.ts +2 -2
  54. package/dist/lib/setup/apply.js +2 -2
  55. package/dist/lib/setup/gitignore.d.ts +7 -0
  56. package/dist/lib/setup/gitignore.js +39 -1
  57. package/dist/lib/setup/steps.d.ts +6 -2
  58. package/dist/lib/setup/steps.js +65 -60
  59. package/dist/lib/setup/tools.d.ts +1 -1
  60. package/dist/lib/setup/tools.js +1 -1
  61. package/dist/lib/trackers/github/index.js +2 -2
  62. package/dist/lib/trackers/local/store.js +1 -1
  63. package/package.json +1 -1
  64. package/prompts/fix-context.njk +79 -0
  65. package/prompts/fix-loop.njk +18 -0
  66. package/prompts/pr.njk +1 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js +0 -545
  69. package/dist/commands/github/auth.d.ts +0 -2
  70. package/dist/commands/github/auth.js +0 -56
  71. package/dist/commands/github/index.d.ts +0 -1
  72. package/dist/commands/github/index.js +0 -1
  73. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  74. package/dist/commands/helpers/english-tutor/index.js +0 -1
  75. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  76. package/dist/commands/helpers/english-tutor/install.js +0 -24
  77. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  78. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  79. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  80. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  81. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  82. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  83. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  84. package/dist/commands/helpers/session-signal/end.js +0 -13
  85. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  86. package/dist/commands/helpers/session-signal/index.js +0 -1
  87. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  88. package/dist/commands/helpers/session-signal/install.js +0 -24
  89. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  90. package/dist/commands/helpers/session-signal/notification.js +0 -13
  91. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  92. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  93. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  94. package/dist/commands/helpers/session-signal/stop.js +0 -13
  95. package/dist/commands/issue/setup.d.ts +0 -2
  96. package/dist/commands/issue/setup.js +0 -108
  97. package/dist/commands/issue/switch.d.ts +0 -12
  98. package/dist/commands/issue/switch.js +0 -38
  99. package/dist/commands/linear/switch.d.ts +0 -2
  100. package/dist/commands/linear/switch.js +0 -75
  101. package/dist/commands/setup.d.ts +0 -11
  102. package/dist/commands/setup.js +0 -194
  103. package/dist/lib/english-tutor.d.ts +0 -13
  104. package/dist/lib/english-tutor.js +0 -125
  105. package/dist/lib/session-signal.d.ts +0 -16
  106. package/dist/lib/session-signal.js +0 -104
  107. package/dist/lib/setup/shell-config.d.ts +0 -38
  108. package/dist/lib/setup/shell-config.js +0 -131
  109. package/prompts/english-tutor-prompt.njk +0 -15
@@ -9,12 +9,15 @@ import * as path from "path";
9
9
  const require = createRequire(import.meta.url);
10
10
  const { version } = require("../../package.json");
11
11
  import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
12
- import { run, spawnAsync } from "../lib/exec.js";
12
+ import { run, spawnAsync, execFileAsync } from "../lib/exec.js";
13
+ import { getConfiguredEditor } from "../lib/config-store.js";
13
14
  import { printCdHint } from "../lib/cd-hint.js";
14
15
  import { resolveAgentBinary, resolveClaudeBinary, fillCommitMessage, askTicketQuestion, } from "../lib/ai.js";
15
16
  import { readTriageInvestigateConfig, isTriageInvestigateConfigured, buildInvestigatePrompt, buildInvestigateCommand, } from "../lib/triage-config.js";
16
17
  import { getInstalledClaudeVersion } from "../lib/version.js";
17
18
  import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
19
+ import { santreeSelfArgv } from "../lib/setup/apply.js";
20
+ import { startFixLoop } from "../lib/fix-loop.js";
18
21
  import { getMultiplexer } from "../lib/multiplexer/index.js";
19
22
  import { shellEscape } from "../lib/multiplexer/types.js";
20
23
  import Spinner from "ink-spinner";
@@ -294,6 +297,32 @@ function Tab({ active, label, mode }) {
294
297
  }
295
298
  return _jsx(Text, { children: ` ${label} ` });
296
299
  }
300
+ const TAB_NAMES = {
301
+ triage: "Triage",
302
+ issues: "Issues",
303
+ trees: "Trees",
304
+ reviews: "Reviews",
305
+ };
306
+ /**
307
+ * Column ranges (1-based, absolute) of each tab pill in the tab strip, so a
308
+ * row-2 click maps to the right tab. Must mirror the renderer exactly: the strip
309
+ * Box has `paddingX={1}` (first pill at col 2), each `<Tab>` pill is `" label "`
310
+ * (label + 2), and a 1-space separator sits between pills. Labels embed the live
311
+ * counts, so widths shift as data refreshes — always pass current counts.
312
+ */
313
+ function computeTabHitboxes(order, counts) {
314
+ const boxes = [];
315
+ let col = 2; // paddingX={1} on the row-2 Box → first pill starts at col 2
316
+ order.forEach((tab, i) => {
317
+ if (i > 0)
318
+ col += 1; // 1-space separator between pills
319
+ const label = `${i + 1} ${TAB_NAMES[tab]} (${counts[tab]})`;
320
+ const width = label.length + 2; // pill renders ` label `
321
+ boxes.push({ tab, start: col, end: col + width - 1 });
322
+ col += width;
323
+ });
324
+ return boxes;
325
+ }
297
326
  /**
298
327
  * Single-line global keymap shown at the bottom-left of the dashboard. The
299
328
  * `E workspace` hint only appears when the action is meaningful
@@ -336,6 +365,10 @@ export default function Dashboard() {
336
365
  const repoRootRef = useRef(null);
337
366
  const stateRef = useRef(state);
338
367
  stateRef.current = state;
368
+ // Kept in sync so the long-lived mouse handler can rebuild the tab order
369
+ // (which depends on whether Triage is present) for click-to-switch.
370
+ const supportsTriageRef = useRef(supportsTriage);
371
+ supportsTriageRef.current = supportsTriage;
339
372
  const draggingRef = useRef(null);
340
373
  const [termSize, setTermSize] = useState({
341
374
  columns: stdout?.columns ?? 80,
@@ -411,10 +444,11 @@ export default function Dashboard() {
411
444
  return;
412
445
  }
413
446
  try {
414
- // Re-detect terminal theme alongside data fetch so light↔dark
415
- // switches propagate within one refresh cycle (≤5min, or sooner
416
- // on a manual `R`). Skip the
417
- // OSC 11 query when a text-input overlay is active — the
447
+ // Refresh the colorscheme FIRST it's the most visible change, so on a
448
+ // manual `R` (or a light↔dark switch) the theme should flip immediately
449
+ // rather than waiting on the slower gh-backed data fetch below. Detect +
450
+ // apply it, then load everything else.
451
+ // Skip the OSC 11 query when a text-input overlay is active — the
418
452
  // terminal's response would otherwise leak into the user's
419
453
  // commit/PR/context message via Ink's stdin handler.
420
454
  const overlay = stateRef.current.overlay;
@@ -422,18 +456,17 @@ export default function Dashboard() {
422
456
  (overlay === "triage-ask" && stateRef.current.triageAskPhase === "input") ||
423
457
  (overlay === "pr-create" && stateRef.current.prCreatePhase === "review") ||
424
458
  (overlay === "commit" && stateRef.current.commitPhase === "awaiting-message");
425
- const themeP = inTextInput ? Promise.resolve(null) : detectTerminalTheme();
426
- const [data, reviewData, themeMode] = await Promise.all([
459
+ const themeMode = inTextInput ? null : await detectTerminalTheme();
460
+ if (themeMode !== null)
461
+ setTheme(getThemeForMode(themeMode));
462
+ const [data, reviewData] = await Promise.all([
427
463
  loadDashboardData(repoRoot),
428
464
  loadReviewsData(repoRoot),
429
- themeP,
430
465
  ]);
431
- if (themeMode !== null)
432
- setTheme(getThemeForMode(themeMode));
433
466
  // Workspace file presence — only meaningful when the editor consumes
434
467
  // `.code-workspace` files. Cheap directory read; recomputed each cycle
435
468
  // in case the user adds/removes one.
436
- const editor = (process.env.SANTREE_EDITOR ?? "code").toLowerCase();
469
+ const editor = (getConfiguredEditor() ?? "code").toLowerCase();
437
470
  const editorAcceptsWorkspace = editor === "code" || editor === "cursor";
438
471
  let hasWs = false;
439
472
  if (editorAcceptsWorkspace) {
@@ -599,6 +632,28 @@ export default function Dashboard() {
599
632
  }
600
633
  if (!isPress)
601
634
  return;
635
+ // Tab strip click (row 2) — switch tabs by clicking, like the 1–N keys.
636
+ // Ignored while any overlay is up or data isn't ready.
637
+ {
638
+ const s = stateRef.current;
639
+ if (row === 2 && s.overlay === null && !s.loading && !s.error) {
640
+ const order = supportsTriageRef.current
641
+ ? ["triage", "issues", "trees", "reviews"]
642
+ : ["issues", "trees", "reviews"];
643
+ const counts = {
644
+ triage: s.flatTriage.length,
645
+ issues: s.flatIssues.length,
646
+ trees: s.flatTrees.length,
647
+ reviews: s.flatReviews.length,
648
+ };
649
+ const hit = computeTabHitboxes(order, counts).find((b) => col >= b.start && col <= b.end);
650
+ if (hit) {
651
+ if (hit.tab !== s.activeTab)
652
+ dispatch({ type: "SET_TAB", tab: hit.tab });
653
+ return;
654
+ }
655
+ }
656
+ }
602
657
  // Diff overlay click: drag divider, or select file row in left pane
603
658
  {
604
659
  const s = stateRef.current;
@@ -1078,6 +1133,8 @@ export default function Dashboard() {
1078
1133
  name: windowName,
1079
1134
  cwd: worktreePath,
1080
1135
  command: cmd,
1136
+ group: di.issue.projectName ?? undefined,
1137
+ tabName: "work",
1081
1138
  });
1082
1139
  if (created.ok) {
1083
1140
  dispatch({
@@ -1097,7 +1154,7 @@ export default function Dashboard() {
1097
1154
  // Delayed refresh to pick up session ID created by `santree worktree work`
1098
1155
  setTimeout(() => refresh(), 3000);
1099
1156
  }, [refresh]);
1100
- const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
1157
+ const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile, group) => {
1101
1158
  const mux = getMultiplexer();
1102
1159
  if (mux.isActive()) {
1103
1160
  const windowName = ticketId;
@@ -1109,6 +1166,8 @@ export default function Dashboard() {
1109
1166
  name: windowName,
1110
1167
  cwd: worktreePath,
1111
1168
  command: workCmd,
1169
+ group,
1170
+ tabName: "work",
1112
1171
  });
1113
1172
  if (created.ok) {
1114
1173
  dispatch({
@@ -1244,7 +1303,7 @@ export default function Dashboard() {
1244
1303
  }
1245
1304
  // 4. Done — launch work
1246
1305
  dispatch({ type: "CREATION_DONE" });
1247
- launchAfterCreation(mode, result.path, ticketId, contextFile);
1306
+ launchAfterCreation(mode, result.path, ticketId, contextFile, di.issue.projectName ?? undefined);
1248
1307
  }, [launchAfterCreation]);
1249
1308
  // Holds the context file path through multi-step flows (mode-select → base-select → confirm-setup → create)
1250
1309
  const pendingContextFileRef = useRef(null);
@@ -1414,7 +1473,7 @@ export default function Dashboard() {
1414
1473
  if (!user) {
1415
1474
  dispatch({
1416
1475
  type: "TRACKER_SELECT_MESSAGE",
1417
- message: "GitHub CLI not authenticated. Run: santree github auth",
1476
+ message: "GitHub CLI not authenticated. Run: gh auth login",
1418
1477
  });
1419
1478
  return;
1420
1479
  }
@@ -1581,7 +1640,9 @@ export default function Dashboard() {
1581
1640
  const msg = tid && !trimmed.includes(`[${tid}]`) ? `[${tid}] ${trimmed}` : trimmed;
1582
1641
  dispatch({ type: "COMMIT_PHASE", phase: "committing" });
1583
1642
  try {
1584
- await execAsync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
1643
+ // Array args (no shell) — the message is user-typed and may contain
1644
+ // backticks, `$(…)`, quotes, or backslashes.
1645
+ await execFileAsync("git", ["commit", "-m", msg], {
1585
1646
  cwd: s.commitWorktreePath,
1586
1647
  });
1587
1648
  }
@@ -1594,7 +1655,9 @@ export default function Dashboard() {
1594
1655
  }
1595
1656
  dispatch({ type: "COMMIT_PHASE", phase: "pushing" });
1596
1657
  try {
1597
- await execAsync(`git push -u origin "${s.commitBranch}"`, { cwd: s.commitWorktreePath });
1658
+ await execFileAsync("git", ["push", "-u", "origin", s.commitBranch], {
1659
+ cwd: s.commitWorktreePath,
1660
+ });
1598
1661
  }
1599
1662
  catch (e) {
1600
1663
  dispatch({ type: "COMMIT_ERROR", error: e?.stderr?.trim() || e?.message || "Push failed" });
@@ -1608,7 +1671,7 @@ export default function Dashboard() {
1608
1671
  }, [refresh]);
1609
1672
  // ── Editor actions ───────────────────────────────────────────────
1610
1673
  const openInEditor = useCallback((wtPath) => {
1611
- const editor = process.env.SANTREE_EDITOR || "code";
1674
+ const editor = getConfiguredEditor() || "code";
1612
1675
  spawn(editor, [wtPath], { detached: true, stdio: "ignore" }).unref();
1613
1676
  dispatch({
1614
1677
  type: "SET_ACTION_MESSAGE",
@@ -1619,7 +1682,7 @@ export default function Dashboard() {
1619
1682
  const repoRoot = repoRootRef.current;
1620
1683
  if (!repoRoot)
1621
1684
  return;
1622
- const editor = process.env.SANTREE_EDITOR || "code";
1685
+ const editor = getConfiguredEditor() || "code";
1623
1686
  try {
1624
1687
  const entries = fs.readdirSync(repoRoot);
1625
1688
  const wsFile = entries.find((f) => f.endsWith(".code-workspace"));
@@ -2486,7 +2549,9 @@ export default function Dashboard() {
2486
2549
  (async () => {
2487
2550
  try {
2488
2551
  dispatch({ type: "CREATION_LOG", logs: `Fetching ${ri.branch}...\n` });
2489
- await execAsync(`git fetch origin ${ri.branch}`, { cwd: repoRoot });
2552
+ // Array args (no shell) foreign PR branch names are not
2553
+ // locally slugified and could otherwise inject.
2554
+ await execFileAsync("git", ["fetch", "origin", ri.branch], { cwd: repoRoot });
2490
2555
  dispatch({ type: "CREATION_LOG", logs: `Creating worktree...\n` });
2491
2556
  const result = await createWorktree(ri.branch, ri.baseBranch ?? getDefaultBranch(), repoRoot);
2492
2557
  if (!result.success || !result.path) {
@@ -2537,7 +2602,7 @@ export default function Dashboard() {
2537
2602
  dispatch({ type: "CREATION_DONE" });
2538
2603
  dispatch({ type: "SET_ACTION_MESSAGE", message: `Worktree created for ${ticketId}` });
2539
2604
  // Open in editor automatically
2540
- const editor = process.env.SANTREE_EDITOR || "code";
2605
+ const editor = getConfiguredEditor() || "code";
2541
2606
  spawn(editor, [result.path], { detached: true, stdio: "ignore" }).unref();
2542
2607
  refresh();
2543
2608
  }
@@ -2553,7 +2618,7 @@ export default function Dashboard() {
2553
2618
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree (press w to checkout)" });
2554
2619
  return;
2555
2620
  }
2556
- const editor = process.env.SANTREE_EDITOR || "code";
2621
+ const editor = getConfiguredEditor() || "code";
2557
2622
  spawn(editor, [ri.worktree.path], { detached: true, stdio: "ignore" }).unref();
2558
2623
  dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
2559
2624
  return;
@@ -2569,18 +2634,22 @@ export default function Dashboard() {
2569
2634
  }
2570
2635
  const mux = getMultiplexer();
2571
2636
  if (mux.isActive()) {
2572
- const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
2637
+ const ticketId = extractTicketId(ri.branch ?? "") ?? `pr-${ri.pr.number}`;
2573
2638
  const cwd = ri.worktree.path;
2574
2639
  void (async () => {
2575
- const created = await mux.createWindow({
2576
- name: windowName,
2640
+ // Open review as a `review` tab inside the ticket's workspace
2641
+ // when one exists; addTab falls back to a new window otherwise.
2642
+ const created = await mux.addTab({
2643
+ windowName: ticketId,
2644
+ tabName: "review",
2577
2645
  cwd,
2578
2646
  command: "santree pr review",
2647
+ group: ri.ticket?.projectName ?? undefined,
2579
2648
  });
2580
2649
  dispatch({
2581
2650
  type: "SET_ACTION_MESSAGE",
2582
2651
  message: created.ok
2583
- ? "Launched AI review in new window"
2652
+ ? "Launched AI review in review tab"
2584
2653
  : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
2585
2654
  });
2586
2655
  })();
@@ -2656,7 +2725,7 @@ export default function Dashboard() {
2656
2725
  if (di.worktree?.sessionId) {
2657
2726
  dispatch({
2658
2727
  type: "SET_ACTION_MESSAGE",
2659
- message: "Session active — switch to the Trees tab to resume.",
2728
+ message: "A Claude session exists — switch to the Trees tab to resume.",
2660
2729
  });
2661
2730
  return;
2662
2731
  }
@@ -2695,7 +2764,12 @@ export default function Dashboard() {
2695
2764
  const command = buildInvestigateCommand(resolveClaudeBinary() ?? "claude", prompt);
2696
2765
  const windowName = `investigate-${di.issue.identifier}`;
2697
2766
  void (async () => {
2698
- const created = await mux.createWindow({ name: windowName, cwd: root, command });
2767
+ const created = await mux.createWindow({
2768
+ name: windowName,
2769
+ cwd: root,
2770
+ command,
2771
+ group: di.issue.projectName ?? undefined,
2772
+ });
2699
2773
  dispatch({
2700
2774
  type: "SET_ACTION_MESSAGE",
2701
2775
  message: created.ok
@@ -2758,7 +2832,7 @@ export default function Dashboard() {
2758
2832
  if (di.worktree?.sessionId) {
2759
2833
  dispatch({
2760
2834
  type: "SET_ACTION_MESSAGE",
2761
- message: "Session active — switch to the Trees tab to resume.",
2835
+ message: "A Claude session exists — switch to the Trees tab to resume.",
2762
2836
  });
2763
2837
  return;
2764
2838
  }
@@ -2825,7 +2899,7 @@ export default function Dashboard() {
2825
2899
  if (di.worktree?.sessionId) {
2826
2900
  dispatch({
2827
2901
  type: "SET_ACTION_MESSAGE",
2828
- message: "Session active. Press Enter to resume.",
2902
+ message: "A Claude session exists. Press Enter to resume.",
2829
2903
  });
2830
2904
  return;
2831
2905
  }
@@ -2857,6 +2931,8 @@ export default function Dashboard() {
2857
2931
  name: windowName,
2858
2932
  cwd: worktreePath,
2859
2933
  command: cmd,
2934
+ group: di.issue.projectName ?? undefined,
2935
+ tabName: "work",
2860
2936
  });
2861
2937
  if (!created.ok) {
2862
2938
  dispatch({
@@ -2921,18 +2997,21 @@ export default function Dashboard() {
2921
2997
  }
2922
2998
  const mux = getMultiplexer();
2923
2999
  if (mux.isActive()) {
2924
- const windowName = `review-${di.issue.identifier}`;
2925
3000
  const cwd = di.worktree.path;
2926
3001
  void (async () => {
2927
- const created = await mux.createWindow({
2928
- name: windowName,
3002
+ // Open review as a `review` tab inside the ticket's workspace
3003
+ // (next to `work`), not as a separate window.
3004
+ const created = await mux.addTab({
3005
+ windowName: di.issue.identifier,
3006
+ tabName: "review",
2929
3007
  cwd,
2930
3008
  command: "santree pr review",
3009
+ group: di.issue.projectName ?? undefined,
2931
3010
  });
2932
3011
  dispatch({
2933
3012
  type: "SET_ACTION_MESSAGE",
2934
3013
  message: created.ok
2935
- ? "Launched review in new window"
3014
+ ? "Launched review in review tab"
2936
3015
  : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
2937
3016
  });
2938
3017
  })();
@@ -2982,35 +3061,50 @@ export default function Dashboard() {
2982
3061
  });
2983
3062
  return;
2984
3063
  }
2985
- // Fix PR
3064
+ // Fix PR — launch a self-driving fix loop (re-checks CI + conflicts every 5 min)
2986
3065
  if (input === "f") {
2987
3066
  if (!di.pr || !di.worktree) {
2988
3067
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
2989
3068
  return;
2990
3069
  }
2991
3070
  const mux = getMultiplexer();
2992
- if (mux.isActive()) {
2993
- const windowName = `fix-${di.issue.identifier}`;
2994
- const cwd = di.worktree.path;
2995
- void (async () => {
2996
- const created = await mux.createWindow({
2997
- name: windowName,
2998
- cwd,
2999
- command: "santree pr fix",
3000
- });
3001
- dispatch({
3002
- type: "SET_ACTION_MESSAGE",
3003
- message: created.ok
3004
- ? "Launched PR fix in new window"
3005
- : `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
3006
- });
3007
- })();
3008
- }
3009
- else {
3010
- leaveAltScreen();
3011
- printCdHint({ path: di.worktree.path });
3012
- exit();
3071
+ if (!mux.isActive()) {
3072
+ dispatch({
3073
+ type: "SET_ACTION_MESSAGE",
3074
+ message: "Fix loop needs tmux or cmux — run `santree pr fix --loop` in the worktree",
3075
+ });
3076
+ return;
3013
3077
  }
3078
+ const ticketId = di.issue.identifier;
3079
+ const cwd = di.worktree.path;
3080
+ // Absolute santree invocation: the new window's shell may not have
3081
+ // `santree` on PATH (cmux workspaces start a fresh login shell).
3082
+ const self = santreeSelfArgv(["pr", "fix", "--loop"]);
3083
+ const command = [self.cmd, ...self.args].join(" ");
3084
+ // Seed the marker + optimistic badge so the loop shows immediately,
3085
+ // before the launched process writes its own marker.
3086
+ const root = repoRootRef.current ?? findMainRepoRoot();
3087
+ if (root)
3088
+ startFixLoop(root, ticketId, 5);
3089
+ dispatch({ type: "FIXLOOP_START", ticketId });
3090
+ void (async () => {
3091
+ // Add the loop as a `fix-loop` tab inside the ticket's workspace
3092
+ // (next to its `work` tab). On tmux this falls back to a separate
3093
+ // `fix-loop-<ticket>` window.
3094
+ const created = await mux.addTab({
3095
+ windowName: ticketId,
3096
+ tabName: "fix-loop",
3097
+ cwd,
3098
+ command,
3099
+ group: di.issue.projectName ?? undefined,
3100
+ });
3101
+ dispatch({
3102
+ type: "SET_ACTION_MESSAGE",
3103
+ message: created.ok
3104
+ ? "Started fix loop (re-checks every 5 min) in fix-loop tab"
3105
+ : `Failed to start fix loop${created.message ? `: ${created.message}` : ""}`,
3106
+ });
3107
+ })();
3014
3108
  return;
3015
3109
  }
3016
3110
  // View diff (inline overlay)
@@ -3068,6 +3162,11 @@ export default function Dashboard() {
3068
3162
  : state.flatIssues[state.selectedIndex]) ?? null;
3069
3163
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
3070
3164
  const activeTracker = getIssueTracker(repoRootRef.current);
3165
+ // The tracker actually in use right now — null until the repo is configured,
3166
+ // so the `t` overlay doesn't mark a tracker the user never picked.
3167
+ const configuredTrackerKind = isRepoTrackerConfigured(repoRootRef.current)
3168
+ ? activeTracker.kind
3169
+ : null;
3071
3170
  // Comments for the selected triage issue (lazily loaded; undefined = still
3072
3171
  // fetching, which the detail panel renders as "loading…").
3073
3172
  const triageComments = state.activeTab === "triage" && selectedIssue
@@ -3107,12 +3206,17 @@ export default function Dashboard() {
3107
3206
  const sel = i === state.trackerSelectIndex;
3108
3207
  return (_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", org.name, " (", org.slug, ")"] }, org.slug));
3109
3208
  }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to link, ESC to go back" })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Select an issue tracker for this repo:" }), _jsx(Text, { children: " " }), [
3110
- { label: "Local", hint: "built-in, file-based — no account needed" },
3111
- { label: "Linear", hint: "OAuth workspace" },
3112
- { label: "GitHub", hint: "GitHub Issues via gh CLI" },
3209
+ {
3210
+ kind: "local",
3211
+ label: "Local",
3212
+ hint: "built-in, file-based — no account needed",
3213
+ },
3214
+ { kind: "linear", label: "Linear", hint: "OAuth workspace" },
3215
+ { kind: "github", label: "GitHub", hint: "GitHub Issues via gh CLI" },
3113
3216
  ].map((t, i) => {
3114
3217
  const sel = i === state.trackerSelectIndex;
3115
- return (_jsxs(Text, { children: [_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", t.label] }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", t.hint] })] }, t.label));
3218
+ const active = t.kind === configuredTrackerKind;
3219
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", t.label] }), active ? _jsx(Text, { color: "green", children: " \u25CF in use" }) : null, _jsxs(Text, { dimColor: true, children: [" \u2014 ", t.hint] })] }, t.label));
3116
3220
  }), _jsx(Text, { children: " " }), state.trackerSelectMessage ? (_jsx(Text, { color: "yellow", children: state.trackerSelectMessage })) : null, _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, q to quit" })] })) }) })) : state.overlay === "confirm-delete-issue" ? (_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: "Delete issue?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [selectedIssue?.issue.identifier, " ", selectedIssue?.issue.title] }), _jsx(Text, { dimColor: true, children: "This removes the issue file from .santree/issues/" }), _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 === "issue-form" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.issueFormMode === "edit" ? `Edit ${state.issueFormId}` : "New issue", " · ", state.issueFormPhase === "title"
3117
3221
  ? "title"
3118
3222
  : state.issueFormPhase === "description"
@@ -3144,7 +3248,7 @@ export default function Dashboard() {
3144
3248
  })(), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u21E7\u2191\u2193/j/k" }), " scroll · ", _jsx(Text, { color: "cyan", bold: true, children: "a" }), " ask again · ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), " close"] })] }))] })) : (_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, selectionBg: theme.selectionBg })) : state.activeTab === "triage" ? (state.flatTriage.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "Triage inbox empty" }) })) : (_jsx(IssueList, { groups: state.triageGroups, flatIssues: state.flatTriage, selectedIndex: state.triageSelectedIndex, scrollOffset: state.triageListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg, variant: "triage" }))) : (state.activeTab === "trees" ? state.flatTrees : state.flatIssues).length ===
3145
3249
  0 ? (_jsxs(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: state.activeTab === "trees" ? "No worktrees yet" : "No active issues" }), state.activeTab === "issues" && activeTracker.canMutate ? (_jsx(Text, { dimColor: true, children: "Press n to create one" })) : null] })) : (_jsx(IssueList, { groups: state.activeTab === "trees" ? state.treeGroups : state.groups, flatIssues: state.activeTab === "trees" ? state.flatTrees : state.flatIssues, selectedIndex: state.activeTab === "trees" ? state.treeSelectedIndex : state.selectedIndex, scrollOffset: state.activeTab === "trees"
3146
3250
  ? state.treeListScrollOffset
3147
- : state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg, deletingIds: state.activeTab === "trees" ? deletingIds : undefined, variant: state.activeTab === "issues" ? "issues" : "default" })) }), _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
3251
+ : state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg, deletingIds: state.activeTab === "trees" ? deletingIds : undefined, fixLoops: state.activeTab === "trees" ? state.fixLoopTickets : undefined, variant: state.activeTab === "issues" ? "issues" : "default" })) }), _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
3148
3252
  .split("\n")
3149
3253
  .slice(-(contentHeight - 1))
3150
3254
  .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, draft: state.prCreateDraft, dispatch: dispatch })) : state.activeTab === "triage" && !selectedIssue ? (
@@ -3155,7 +3259,7 @@ export default function Dashboard() {
3155
3259
  ? state.treeDetailScrollOffset
3156
3260
  : state.activeTab === "triage"
3157
3261
  ? state.triageDetailScrollOffset
3158
- : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, prSyncing: !!selectedIssue && !!state.pendingPrs[selectedIssue.issue.identifier], triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true, triageInvestigateConfigured: triageInvestigateConfigured }) })] })) })] })] }));
3262
+ : state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, prSyncing: !!selectedIssue && !!state.pendingPrs[selectedIssue.issue.identifier], fixLoop: selectedIssue ? state.fixLoopTickets[selectedIssue.issue.identifier] : undefined, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true, triageInvestigateConfigured: triageInvestigateConfigured }) })] })) })] })] }));
3159
3263
  }
3160
3264
  /**
3161
3265
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
@@ -83,28 +83,6 @@ function isWorktree(cwd) {
83
83
  function isSantreeWorktree(cwd) {
84
84
  return cwd.includes("/.santree/worktrees/");
85
85
  }
86
- // Read session state from per-ticket state file
87
- function readSessionStateFile(cwd) {
88
- const marker = "/.santree/worktrees/";
89
- const idx = cwd.indexOf(marker);
90
- if (idx === -1)
91
- return null;
92
- const repoRoot = cwd.slice(0, idx);
93
- const rest = cwd.slice(idx + marker.length);
94
- const ticketId = rest.split("/")[0];
95
- if (!ticketId)
96
- return null;
97
- const filePath = path.join(repoRoot, ".santree", "session-states", `${ticketId}.json`);
98
- try {
99
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
100
- if (data.state === "exited")
101
- return null;
102
- return data.state;
103
- }
104
- catch {
105
- return null;
106
- }
107
- }
108
86
  // Extract ticket ID from branch name (e.g., feature/TEAM-123-desc -> TEAM-123)
109
87
  function extractTicketId(branch) {
110
88
  const match = branch.match(/([a-zA-Z]+)-(\d+)/);
@@ -181,14 +159,6 @@ function buildSantreeStatusline(cwd, model, usedPercentage) {
181
159
  // Git changes
182
160
  const changes = getGitChanges(cwd);
183
161
  parts.push(formatChanges(changes));
184
- // Session state
185
- const sessState = readSessionStateFile(cwd);
186
- if (sessState === "waiting") {
187
- parts.push(`${c.red}WAITING${c.reset}`);
188
- }
189
- else if (sessState === "idle") {
190
- parts.push(`${c.yellow}idle${c.reset}`);
191
- }
192
162
  // Model
193
163
  if (model) {
194
164
  parts.push(`${c.blue}${model}${c.reset}`);
@@ -1 +1 @@
1
- export declare const description = "Issue tracker commands (Linear / GitHub)";
1
+ export declare const description = "Issue actions (open the current branch's issue)";
@@ -1 +1 @@
1
- export const description = "Issue tracker commands (Linear / GitHub)";
1
+ export const description = "Issue actions (open the current branch's issue)";
@@ -0,0 +1,7 @@
1
+ export declare const description = "Print the fix-loop iteration brief (state + the exact actions to take)";
2
+ /**
3
+ * Read-only command the auto-fix `/loop` runs each iteration to refresh state.
4
+ * No Ink UI — it writes the rendered brief straight to stdout and exits (same
5
+ * pattern as the statusline helper) so the agent can read it cleanly.
6
+ */
7
+ export default function PRContext(): null;
@@ -0,0 +1,36 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { resolveAIContext, fetchAndRenderFixContext } from "../../lib/ai.js";
3
+ import { santreeSelfArgv } from "../../lib/setup/apply.js";
4
+ export const description = "Print the fix-loop iteration brief (state + the exact actions to take)";
5
+ /**
6
+ * Read-only command the auto-fix `/loop` runs each iteration to refresh state.
7
+ * No Ink UI — it writes the rendered brief straight to stdout and exits (same
8
+ * pattern as the statusline helper) so the agent can read it cleanly.
9
+ */
10
+ export default function PRContext() {
11
+ const hasRun = useRef(false);
12
+ useEffect(() => {
13
+ if (hasRun.current)
14
+ return;
15
+ hasRun.current = true;
16
+ (async () => {
17
+ const result = await resolveAIContext();
18
+ if (!result.ok) {
19
+ process.stderr.write(result.error + "\n");
20
+ process.exit(1);
21
+ }
22
+ // Embed the absolute santree invocation so the brief's own `--signal` /
23
+ // resolve commands work in a shell that may not have santree on PATH.
24
+ const self = santreeSelfArgv([]);
25
+ const santreeCmd = [self.cmd, ...self.args].join(" ");
26
+ const md = await fetchAndRenderFixContext(result.context.branch, santreeCmd);
27
+ if (!md) {
28
+ process.stderr.write(`No pull request found for branch '${result.context.branch}'\n`);
29
+ process.exit(1);
30
+ }
31
+ process.stdout.write(md.trimEnd() + "\n");
32
+ process.exit(0);
33
+ })();
34
+ }, []);
35
+ return null;
36
+ }
@@ -1,2 +1,11 @@
1
- export declare const description = "Fix PR review comments";
2
- export default function Fix(): import("react/jsx-runtime").JSX.Element;
1
+ import { z } from "zod";
2
+ export declare const description = "Run the self-driving fix loop: merge conflicts, fixable CI, and \uD83D\uDC4D-approved review comments";
3
+ export declare const options: z.ZodObject<{
4
+ loop: z.ZodDefault<z.ZodBoolean>;
5
+ signal: z.ZodOptional<z.ZodString>;
6
+ }, z.core.$strip>;
7
+ type Props = {
8
+ options: z.infer<typeof options>;
9
+ };
10
+ export default function Fix({ options: opts }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};