santree 0.5.5 → 0.6.0

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 (35) hide show
  1. package/dist/commands/dashboard.js +228 -67
  2. package/dist/commands/doctor.js +2 -2
  3. package/dist/commands/helpers/squirrel.d.ts +2 -0
  4. package/dist/commands/helpers/squirrel.js +12 -0
  5. package/dist/commands/worktree/commit.d.ts +9 -1
  6. package/dist/commands/worktree/commit.js +58 -14
  7. package/dist/lib/ai.d.ts +26 -0
  8. package/dist/lib/ai.js +53 -0
  9. package/dist/lib/claude-todos.d.ts +37 -0
  10. package/dist/lib/claude-todos.js +98 -0
  11. package/dist/lib/dashboard/DetailPanel.js +99 -9
  12. package/dist/lib/dashboard/IssueList.js +2 -0
  13. package/dist/lib/dashboard/MultilineTextArea.js +24 -11
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +76 -3
  16. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
  17. package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
  18. package/dist/lib/dashboard/ReviewList.js +12 -15
  19. package/dist/lib/dashboard/data.js +158 -7
  20. package/dist/lib/dashboard/types.d.ts +45 -10
  21. package/dist/lib/dashboard/types.js +40 -7
  22. package/dist/lib/diff-parse.d.ts +25 -0
  23. package/dist/lib/diff-parse.js +60 -0
  24. package/dist/lib/git.d.ts +22 -0
  25. package/dist/lib/git.js +41 -0
  26. package/dist/lib/github.d.ts +6 -0
  27. package/dist/lib/github.js +29 -0
  28. package/dist/lib/open-url.d.ts +10 -0
  29. package/dist/lib/open-url.js +20 -0
  30. package/dist/lib/squirrel-loader.d.ts +9 -0
  31. package/dist/lib/squirrel-loader.js +322 -0
  32. package/dist/lib/trackers/index.d.ts +13 -0
  33. package/dist/lib/trackers/index.js +19 -0
  34. package/package.json +1 -1
  35. package/prompts/fill-commit.njk +79 -0
@@ -1,8 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer, useCallback, useRef, useState } from "react";
3
3
  import { Text, Box, useInput, useStdout, useApp } from "ink";
4
- import Spinner from "ink-spinner";
5
- import { exec, execSync, spawn } from "child_process";
4
+ import { exec, spawn } from "child_process";
6
5
  import { promisify } from "util";
7
6
  import { createRequire } from "module";
8
7
  import * as fs from "fs";
@@ -11,13 +10,17 @@ const require = createRequire(import.meta.url);
11
10
  const { version } = require("../../package.json");
12
11
  import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
13
12
  import { run, spawnAsync } from "../lib/exec.js";
14
- import { resolveAgentBinary } from "../lib/ai.js";
13
+ import { resolveAgentBinary, fillCommitMessage } from "../lib/ai.js";
15
14
  import { getInstalledClaudeVersion } from "../lib/version.js";
16
- import { extractTicketId } from "../lib/git.js";
15
+ import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
17
16
  import { getMultiplexer } from "../lib/multiplexer/index.js";
17
+ import { shellEscape } from "../lib/multiplexer/types.js";
18
+ import SquirrelLoader from "../lib/squirrel-loader.js";
18
19
  import { getPRTemplate } from "../lib/github.js";
19
20
  import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
20
21
  import { getIssueTracker } from "../lib/trackers/index.js";
22
+ import { openUrl } from "../lib/open-url.js";
23
+ import { parseUnifiedDiff } from "../lib/diff-parse.js";
21
24
  import * as os from "os";
22
25
  import { initialState, reducer } from "../lib/dashboard/types.js";
23
26
  import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
@@ -26,7 +29,7 @@ import { detectTerminalTheme, getThemeForMode, } from "../lib/dashboard/theme.js
26
29
  import DetailPanel, { buildIssueActions } from "../lib/dashboard/DetailPanel.js";
27
30
  import ReviewList from "../lib/dashboard/ReviewList.js";
28
31
  import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
29
- import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
32
+ import { CommitOverlay, PrCreateOverlay, HelpOverlay } from "../lib/dashboard/Overlays.js";
30
33
  import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
31
34
  import DiffOverlay, { flattenTreeFiles, computeDiffLayout, clampDiffLeftWidth, DIFF_DIVIDER_WIDTH, } from "../lib/dashboard/DiffOverlay.js";
32
35
  import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getCachedLatestVersion, getLatestVersionFor, getCachedLatestVersionFor, isUpdateAvailable, } from "../lib/version.js";
@@ -259,7 +262,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
259
262
  if (mode === "diff") {
260
263
  return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "\u2423" }), _jsx(Text, { dimColor: true, children: " stage" }), dot, _jsx(Key, { k: "a" }), _jsx(Text, { dimColor: true, children: " all" }), dot, _jsx(Key, { k: "d" }), _jsx(Text, { dimColor: true, children: " discard" }), dot, _jsx(Key, { k: "e" }), _jsx(Text, { dimColor: true, children: " edit" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
261
264
  }
262
- return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
265
+ return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "?" }), _jsx(Text, { dimColor: true, children: " help" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
263
266
  }
264
267
  // ── Component ─────────────────────────────────────────────────────────
265
268
  export default function Dashboard() {
@@ -346,14 +349,23 @@ export default function Dashboard() {
346
349
  }
347
350
  repoRootRef.current = repoRoot;
348
351
  try {
349
- // Re-detect terminal theme alongside data fetch so light↔dark switches
350
- // propagate within one refresh cycle (≤30s).
352
+ // Re-detect terminal theme alongside data fetch so light↔dark
353
+ // switches propagate within one refresh cycle (≤30s). Skip the
354
+ // OSC 11 query when a text-input overlay is active — the
355
+ // terminal's response would otherwise leak into the user's
356
+ // commit/PR/context message via Ink's stdin handler.
357
+ const overlay = stateRef.current.overlay;
358
+ const inTextInput = overlay === "context-input" ||
359
+ (overlay === "pr-create" && stateRef.current.prCreatePhase === "review") ||
360
+ (overlay === "commit" && stateRef.current.commitPhase === "awaiting-message");
361
+ const themeP = inTextInput ? Promise.resolve(null) : detectTerminalTheme();
351
362
  const [data, reviewData, themeMode] = await Promise.all([
352
363
  loadDashboardData(repoRoot),
353
364
  loadReviewsData(repoRoot),
354
- detectTerminalTheme(),
365
+ themeP,
355
366
  ]);
356
- setTheme(getThemeForMode(themeMode));
367
+ if (themeMode !== null)
368
+ setTheme(getThemeForMode(themeMode));
357
369
  // Workspace file presence — only meaningful when the editor consumes
358
370
  // `.code-workspace` files. Cheap directory read; recomputed each cycle
359
371
  // in case the user adds/removes one.
@@ -629,21 +641,44 @@ export default function Dashboard() {
629
641
  }
630
642
  }, [state.reviewSelectedIndex, state.flatReviews, contentHeight, state.reviewListScrollOffset]);
631
643
  // ── Mouse tracking pause ─────────────────────────────────────────
632
- // The MultilineTextArea captures ESC for cancel. With SGR mouse tracking on,
633
- // every click emits `\x1b[<btn;col;rowM` Ink reads the leading ESC and fires
634
- // key.escape, dismissing the overlay. Disable tracking while any overlay
635
- // phase mounts a MultilineTextArea (context-input editing OR pr-create
636
- // review); restore when that phase ends.
644
+ // With SGR mouse tracking on, every click emits `\x1b[<btn;col;rowM` —
645
+ // these escape sequences leak into text inputs as garbage characters
646
+ // (and into MultilineTextArea, the leading ESC fires key.escape).
647
+ // Disable tracking while any text-input overlay is mounted; restore on exit.
637
648
  useEffect(() => {
638
- const needsMouseOff = (state.overlay === "context-input" && state.contextInputPhase === "editing") ||
639
- (state.overlay === "pr-create" && state.prCreatePhase === "review");
649
+ const needsMouseOff = state.overlay === "context-input" ||
650
+ (state.overlay === "pr-create" && state.prCreatePhase === "review") ||
651
+ (state.overlay === "commit" && state.commitPhase === "awaiting-message");
640
652
  if (!needsMouseOff)
641
653
  return;
642
654
  process.stdout.write("\x1b[?1002l\x1b[?1006l");
643
655
  return () => {
644
656
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
645
657
  };
646
- }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
658
+ }, [state.overlay, state.prCreatePhase, state.commitPhase]);
659
+ // ── Diff overlay: load file list when opened (gh pr diff path) ────
660
+ // Reviews-tab PRs without a local worktree shell out to `gh pr diff <n>`,
661
+ // parse the unified blob into per-file records, and stash the per-file
662
+ // content for the content-loader effect below to read synchronously.
663
+ useEffect(() => {
664
+ if (state.overlay !== "diff" || state.diffPRNumber == null)
665
+ return;
666
+ const prNumber = state.diffPRNumber;
667
+ void (async () => {
668
+ try {
669
+ const { stdout } = await execAsync(`gh pr diff ${prNumber}`, {
670
+ maxBuffer: 32 * 1024 * 1024,
671
+ });
672
+ const { files, contentByPath } = parseUnifiedDiff(stdout);
673
+ const ordered = flattenTreeFiles(files);
674
+ dispatch({ type: "DIFF_PR_LOADED", files: ordered, contentByPath });
675
+ }
676
+ catch (err) {
677
+ const msg = err instanceof Error ? err.message : String(err);
678
+ dispatch({ type: "DIFF_FILES_ERROR", error: msg });
679
+ }
680
+ })();
681
+ }, [state.overlay, state.diffPRNumber]);
647
682
  // ── Diff overlay: load file list when opened ──────────────────────
648
683
  // Resolves merge-base against the configured base branch so upstream-only
649
684
  // changes (commits on master we haven't pulled) are excluded — same semantics
@@ -702,6 +737,26 @@ export default function Dashboard() {
702
737
  }
703
738
  })();
704
739
  }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
740
+ // ── Diff overlay: select content for current file (gh pr diff path) ─
741
+ // Per-file slices were parsed up front by the file-list effect above —
742
+ // just pull the right entry out of the map. No subprocess per file.
743
+ useEffect(() => {
744
+ if (state.overlay !== "diff" || state.diffPRNumber == null)
745
+ return;
746
+ const file = state.diffFiles[state.diffFileIndex];
747
+ if (!file) {
748
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: "" });
749
+ return;
750
+ }
751
+ const content = state.diffPRContentByPath[file.path] ?? "";
752
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
753
+ }, [
754
+ state.overlay,
755
+ state.diffPRNumber,
756
+ state.diffFileIndex,
757
+ state.diffFiles,
758
+ state.diffPRContentByPath,
759
+ ]);
705
760
  // ── Diff overlay: load content for selected file ──────────────────
706
761
  // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
707
762
  // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
@@ -765,7 +820,15 @@ export default function Dashboard() {
765
820
  const windowName = di.issue.identifier;
766
821
  const sessionId = di.worktree?.sessionId;
767
822
  const bin = resolveAgentBinary();
768
- const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
823
+ // `claude --resume` is cwd-scoped it only finds the session under
824
+ // the encoded path of the current cwd. Project conventions (direnv,
825
+ // shell init) sometimes leave the tmux window in a subdir, so we
826
+ // prepend `cd <sessionCwd>` (resolved by `findClaudeSessionCwd`)
827
+ // to guarantee the resume runs from where the session was created.
828
+ const sessionCwd = di.worktree?.sessionCwd ?? di.worktree?.path;
829
+ const resumeCmd = sessionId && bin && sessionCwd
830
+ ? `cd ${shellEscape(sessionCwd)} && ${bin} --resume ${sessionId}`
831
+ : null;
769
832
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
770
833
  const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
771
834
  const cmd = resumeCmd ?? workCmd;
@@ -1024,13 +1087,12 @@ export default function Dashboard() {
1024
1087
  // ── Commit flow ──────────────────────────────────────────────────
1025
1088
  const handleStageAll = useCallback(async () => {
1026
1089
  const wtPath = stateRef.current.commitWorktreePath;
1027
- const ticketId = stateRef.current.commitTicketId;
1028
1090
  if (!wtPath)
1029
1091
  return;
1030
1092
  try {
1031
1093
  await execAsync("git add -A", { cwd: wtPath });
1032
- dispatch({ type: "COMMIT_MESSAGE", message: `[${ticketId}] ` });
1033
- dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1094
+ // After staging, ask whether to draft with AI or write manually.
1095
+ dispatch({ type: "COMMIT_PHASE", phase: "choose-mode" });
1034
1096
  }
1035
1097
  catch (e) {
1036
1098
  dispatch({
@@ -1039,6 +1101,46 @@ export default function Dashboard() {
1039
1101
  });
1040
1102
  }
1041
1103
  }, []);
1104
+ const handleFillCommit = useCallback(async () => {
1105
+ const s = stateRef.current;
1106
+ const wtPath = s.commitWorktreePath;
1107
+ const branch = s.commitBranch;
1108
+ const ticketId = s.commitTicketId;
1109
+ if (!wtPath || !branch)
1110
+ return;
1111
+ dispatch({ type: "COMMIT_PHASE", phase: "filling" });
1112
+ const diffContent = getStagedDiffContent(wtPath);
1113
+ const fallbackPrefix = ticketId ? `[${ticketId}] ` : "";
1114
+ if (!diffContent.trim()) {
1115
+ dispatch({ type: "COMMIT_MESSAGE", message: fallbackPrefix });
1116
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1117
+ return;
1118
+ }
1119
+ // Pull ticket context so the AI message is grounded in the requested
1120
+ // change rather than just the literal diff.
1121
+ let ticketContent;
1122
+ const mainRoot = repoRootRef.current;
1123
+ if (ticketId && mainRoot) {
1124
+ try {
1125
+ const tracker = getIssueTracker(mainRoot);
1126
+ const result = await tracker.getIssue(ticketId, mainRoot);
1127
+ if (result.ok) {
1128
+ ticketContent = renderTicket(result.value, tracker.displayName);
1129
+ }
1130
+ }
1131
+ catch {
1132
+ // non-fatal — the prompt works with diff alone
1133
+ }
1134
+ }
1135
+ const drafted = await fillCommitMessage({
1136
+ branch,
1137
+ ticketId,
1138
+ ticketContent,
1139
+ diffContent,
1140
+ });
1141
+ dispatch({ type: "COMMIT_MESSAGE", message: drafted ?? fallbackPrefix });
1142
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1143
+ }, []);
1042
1144
  const handleCommitSubmit = useCallback(async (value) => {
1043
1145
  const s = stateRef.current;
1044
1146
  if (!s.commitWorktreePath || !s.commitBranch)
@@ -1048,9 +1150,10 @@ export default function Dashboard() {
1048
1150
  dispatch({ type: "COMMIT_ERROR", error: "Empty commit message" });
1049
1151
  return;
1050
1152
  }
1051
- const msg = trimmed.includes(`[${s.commitTicketId}]`)
1052
- ? trimmed
1053
- : `[${s.commitTicketId}] ${trimmed}`;
1153
+ // Auto-prefix with `[TICKET]` only when there's a real ticket
1154
+ // AND the user hasn't already typed it.
1155
+ const tid = s.commitTicketId;
1156
+ const msg = tid && !trimmed.includes(`[${tid}]`) ? `[${tid}] ${trimmed}` : trimmed;
1054
1157
  dispatch({ type: "COMMIT_PHASE", phase: "committing" });
1055
1158
  try {
1056
1159
  await execAsync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
@@ -1263,8 +1366,25 @@ export default function Dashboard() {
1263
1366
  if (state.actionMessage && input !== "q") {
1264
1367
  dispatch({ type: "SET_ACTION_MESSAGE", message: null });
1265
1368
  }
1369
+ // Help overlay — toggleable from anywhere except text-input
1370
+ // overlays. ? opens, ? again or Esc closes.
1371
+ if (state.overlay === "help") {
1372
+ if (input === "?" || key.escape) {
1373
+ dispatch({ type: "SET_OVERLAY", overlay: null });
1374
+ }
1375
+ return;
1376
+ }
1377
+ if (input === "?" && state.overlay === null) {
1378
+ dispatch({ type: "SET_OVERLAY", overlay: "help" });
1379
+ return;
1380
+ }
1266
1381
  // Commit overlay
1267
1382
  if (state.overlay === "commit") {
1383
+ // awaiting-message is owned by MultilineTextArea (Ctrl+D submit,
1384
+ // Ctrl+G cancel) — escape there is handled inside the component,
1385
+ // so we don't intercept any keys at this phase.
1386
+ if (state.commitPhase === "awaiting-message")
1387
+ return;
1268
1388
  if (key.escape) {
1269
1389
  dispatch({ type: "COMMIT_CANCEL" });
1270
1390
  return;
@@ -1280,8 +1400,20 @@ export default function Dashboard() {
1280
1400
  }
1281
1401
  return;
1282
1402
  }
1283
- // awaiting-message is handled by TextInput, not useInput
1284
- // All other phases: swallow input
1403
+ if (state.commitPhase === "choose-mode") {
1404
+ if (input === "f") {
1405
+ handleFillCommit();
1406
+ return;
1407
+ }
1408
+ if (input === "m") {
1409
+ const tid = state.commitTicketId;
1410
+ dispatch({ type: "COMMIT_MESSAGE", message: tid ? `[${tid}] ` : "" });
1411
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1412
+ return;
1413
+ }
1414
+ return;
1415
+ }
1416
+ // committing / pushing / done / error: swallow
1285
1417
  return;
1286
1418
  }
1287
1419
  // PR create overlay
@@ -1396,29 +1528,10 @@ export default function Dashboard() {
1396
1528
  }
1397
1529
  return;
1398
1530
  }
1399
- // Context-input overlay.
1400
- // Editing phase: MultilineTextArea owns useInput (outer is disabled
1401
- // via isActive below).
1402
- // Review phase: outer handles y/n/e/ESC.
1531
+ // Context-input overlay: MultilineTextArea owns useInput (outer is
1532
+ // disabled via isActive below). Submit launches directly; cancel
1533
+ // closes the overlay.
1403
1534
  if (state.overlay === "context-input") {
1404
- if (state.contextInputPhase === "review") {
1405
- if (input === "y" || key.return) {
1406
- const mode = state.contextInputMode;
1407
- const ctx = state.contextInputValue;
1408
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1409
- if (mode)
1410
- doWork(mode, ctx);
1411
- return;
1412
- }
1413
- if (input === "n" || input === "e") {
1414
- dispatch({ type: "CONTEXT_INPUT_EDIT" });
1415
- return;
1416
- }
1417
- if (key.escape) {
1418
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1419
- return;
1420
- }
1421
- }
1422
1535
  return;
1423
1536
  }
1424
1537
  // Diff overlay
@@ -1700,15 +1813,55 @@ export default function Dashboard() {
1700
1813
  const ri = state.flatReviews[state.reviewSelectedIndex];
1701
1814
  if (!ri)
1702
1815
  return;
1703
- // Open PR in browser
1816
+ // Open linked ticket in browser (only when one is associated).
1817
+ // Aligns with the issues tab: `[o]` always opens the ticket; `[p]`
1818
+ // opens the PR. The previous behavior — `[o]` opens the PR — is
1819
+ // the only intentional muscle-memory break in this redesign.
1704
1820
  if (input === "o") {
1705
- if (ri.pr.url) {
1706
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1707
- execSync(`${openCmd} "${ri.pr.url}"`, { stdio: "ignore" });
1821
+ if (!ri.ticket?.url) {
1822
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No linked ticket" });
1823
+ return;
1824
+ }
1825
+ if (openUrl(ri.ticket.url)) {
1826
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened ticket in browser" });
1827
+ }
1828
+ return;
1829
+ }
1830
+ // Open PR in browser
1831
+ if (input === "p") {
1832
+ if (!ri.pr.url)
1833
+ return;
1834
+ if (openUrl(ri.pr.url)) {
1708
1835
  dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
1709
1836
  }
1710
1837
  return;
1711
1838
  }
1839
+ // View diff (inline overlay).
1840
+ // - With a local worktree: reuse the issues-tab path (git diff
1841
+ // against merge-base, full XY/staging support).
1842
+ // - Without a worktree: parse `gh pr diff <n>` once, render the
1843
+ // same DiffOverlay in read-only mode.
1844
+ if (input === "v") {
1845
+ const ticketLabel = ri.ticket?.identifier ?? `#${ri.pr.number}`;
1846
+ if (ri.worktree) {
1847
+ const baseBranch = getBaseBranch(ri.worktree.branch);
1848
+ dispatch({
1849
+ type: "DIFF_OPEN",
1850
+ ticketId: ticketLabel,
1851
+ worktreePath: ri.worktree.path,
1852
+ baseBranch,
1853
+ });
1854
+ }
1855
+ else {
1856
+ dispatch({
1857
+ type: "DIFF_OPEN_PR",
1858
+ label: ticketLabel,
1859
+ prNumber: ri.pr.number,
1860
+ baseBranch: ri.baseBranch ?? "main",
1861
+ });
1862
+ }
1863
+ return;
1864
+ }
1712
1865
  // Create worktree from PR branch (checkout for local testing)
1713
1866
  if (input === "w") {
1714
1867
  if (ri.worktree) {
@@ -1917,7 +2070,10 @@ export default function Dashboard() {
1917
2070
  const windowName = di.issue.identifier;
1918
2071
  const sessionId = di.worktree.sessionId;
1919
2072
  const bin = resolveAgentBinary();
1920
- const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
2073
+ const sessionCwd = di.worktree.sessionCwd ?? di.worktree.path;
2074
+ const resumeCmd = sessionId && bin
2075
+ ? `cd ${shellEscape(sessionCwd)} && ${bin} --resume ${sessionId}`
2076
+ : null;
1921
2077
  const worktreePath = di.worktree.path;
1922
2078
  void (async () => {
1923
2079
  const selected = await mux.selectWindow(windowName);
@@ -1950,9 +2106,9 @@ export default function Dashboard() {
1950
2106
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
1951
2107
  return;
1952
2108
  }
1953
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1954
- execSync(`${openCmd} "${di.issue.url}"`, { stdio: "ignore" });
1955
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2109
+ if (openUrl(di.issue.url)) {
2110
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2111
+ }
1956
2112
  return;
1957
2113
  }
1958
2114
  // Open PR
@@ -1961,9 +2117,9 @@ export default function Dashboard() {
1961
2117
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to open" });
1962
2118
  return;
1963
2119
  }
1964
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1965
- execSync(`${openCmd} "${di.pr.url}"`, { stdio: "ignore" });
1966
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
2120
+ if (openUrl(di.pr.url)) {
2121
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
2122
+ }
1967
2123
  return;
1968
2124
  }
1969
2125
  // Create PR
@@ -2044,7 +2200,9 @@ export default function Dashboard() {
2044
2200
  }
2045
2201
  dispatch({
2046
2202
  type: "COMMIT_START",
2047
- ticketId: di.issue.identifier,
2203
+ // Main-row commits don't carry a ticket prefix — only real
2204
+ // tracker tickets do.
2205
+ ticketId: di.issue.state.type === "main" ? null : di.issue.identifier,
2048
2206
  worktreePath: di.worktree.path,
2049
2207
  branch: di.worktree.branch,
2050
2208
  gitStatus: di.worktree.gitStatus,
@@ -2107,23 +2265,26 @@ export default function Dashboard() {
2107
2265
  return;
2108
2266
  }
2109
2267
  }, {
2110
- isActive: (state.overlay !== "context-input" || state.contextInputPhase === "review") &&
2268
+ isActive: state.overlay !== "context-input" &&
2111
2269
  (state.overlay !== "pr-create" || state.prCreatePhase !== "review") &&
2112
2270
  (state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
2113
2271
  });
2114
2272
  // ── Render ─────────────────────────────────────────────────────────
2115
2273
  if (state.loading) {
2116
- return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading dashboard..." })] }) }));
2274
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: _jsx(SquirrelLoader, { text: "Loading dashboard..." }) }) }));
2117
2275
  }
2118
2276
  if (state.error) {
2119
2277
  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" })] }) }));
2120
2278
  }
2121
2279
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
2122
2280
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
2123
- return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [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 === "context-input" ? (_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: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
2124
- .split("\n")
2125
- .slice(0, 12)
2126
- .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " 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) => {
2281
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [state.overlay === "help" ? (_jsx(HelpOverlay, { width: innerWidth, height: contentHeight })) : 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 === "context-input" ? (_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: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
2282
+ const mode = state.contextInputMode;
2283
+ const ctx = state.contextInputValue;
2284
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
2285
+ if (mode)
2286
+ doWork(mode, ctx);
2287
+ }, onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " launch · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " 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) => {
2127
2288
  const selected = idx === state.baseSelectIndex;
2128
2289
  const defaultBranch = getDefaultBranch();
2129
2290
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import Spinner from "ink-spinner";
4
3
  import { useEffect, useState } from "react";
4
+ import SquirrelLoader from "../lib/squirrel-loader.js";
5
5
  import { exec, execSync } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import { createRequire } from "module";
@@ -608,7 +608,7 @@ export default function Doctor() {
608
608
  runChecks();
609
609
  }, []);
610
610
  if (loading) {
611
- return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking system requirements..." })] }));
611
+ return (_jsx(Box, { paddingY: 1, children: _jsx(SquirrelLoader, { text: "Checking system requirements..." }) }));
612
612
  }
613
613
  const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
614
614
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
@@ -0,0 +1,2 @@
1
+ export declare const description = "Render the spinning squirrel until you Ctrl+C (debug helper)";
2
+ export default function Squirrel(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, useApp, useInput } from "ink";
3
+ import SquirrelLoader from "../../lib/squirrel-loader.js";
4
+ export const description = "Render the spinning squirrel until you Ctrl+C (debug helper)";
5
+ export default function Squirrel() {
6
+ const { exit } = useApp();
7
+ useInput((_, key) => {
8
+ if (key.escape)
9
+ exit();
10
+ });
11
+ return (_jsx(Box, { flexDirection: "column", alignItems: "center", paddingY: 1, children: _jsx(SquirrelLoader, { text: "Press Esc or Ctrl+C to exit" }) }));
12
+ }
@@ -1,2 +1,10 @@
1
+ import { z } from "zod";
1
2
  export declare const description = "Stage and commit changes";
2
- export default function Commit(): import("react/jsx-runtime").JSX.Element;
3
+ export declare const options: z.ZodObject<{
4
+ fill: z.ZodOptional<z.ZodBoolean>;
5
+ }, z.core.$strip>;
6
+ type Props = {
7
+ options: z.infer<typeof options>;
8
+ };
9
+ export default function Commit({ options }: Props): import("react/jsx-runtime").JSX.Element;
10
+ export {};