santree 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +21 -599
  2. package/dist/commands/dashboard.js +267 -33
  3. package/dist/commands/doctor.js +2 -2
  4. package/dist/commands/helpers/squirrel.d.ts +2 -0
  5. package/dist/commands/helpers/squirrel.js +12 -0
  6. package/dist/commands/worktree/commit.d.ts +9 -1
  7. package/dist/commands/worktree/commit.js +58 -14
  8. package/dist/lib/ai.d.ts +26 -0
  9. package/dist/lib/ai.js +53 -0
  10. package/dist/lib/claude-todos.d.ts +37 -0
  11. package/dist/lib/claude-todos.js +98 -0
  12. package/dist/lib/dashboard/DetailPanel.js +99 -9
  13. package/dist/lib/dashboard/IssueList.js +2 -0
  14. package/dist/lib/dashboard/MultilineTextArea.js +14 -1
  15. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  16. package/dist/lib/dashboard/Overlays.js +75 -2
  17. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +7 -0
  18. package/dist/lib/dashboard/ReviewDetailPanel.js +269 -77
  19. package/dist/lib/dashboard/ReviewList.js +12 -15
  20. package/dist/lib/dashboard/data.js +158 -7
  21. package/dist/lib/dashboard/types.d.ts +45 -5
  22. package/dist/lib/dashboard/types.js +40 -0
  23. package/dist/lib/diff-parse.d.ts +25 -0
  24. package/dist/lib/diff-parse.js +60 -0
  25. package/dist/lib/git.d.ts +22 -0
  26. package/dist/lib/git.js +41 -0
  27. package/dist/lib/github.d.ts +6 -0
  28. package/dist/lib/github.js +29 -0
  29. package/dist/lib/open-url.d.ts +10 -0
  30. package/dist/lib/open-url.js +20 -0
  31. package/dist/lib/squirrel-loader.d.ts +9 -0
  32. package/dist/lib/squirrel-loader.js +322 -0
  33. package/dist/lib/trackers/index.d.ts +13 -0
  34. package/dist/lib/trackers/index.js +19 -0
  35. package/package.json +1 -1
  36. 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";
@@ -134,6 +137,45 @@ function runPipedDiff(cwd, gitArgs, tool, themeMode) {
134
137
  });
135
138
  });
136
139
  }
140
+ /**
141
+ * Pipe an in-memory unified diff string through the configured pager. Used by
142
+ * the reviews-tab PR diff path, which already has per-file content from
143
+ * `gh pr diff` parsed into a map — no `git diff` to spawn here.
144
+ */
145
+ function runPagerOnString(input, tool, themeMode) {
146
+ return new Promise((resolve, reject) => {
147
+ const pagerArgs = tool === "delta" ? [themeMode === "light" ? "--light" : "--dark"] : [];
148
+ const pagerEnv = tool === "delta"
149
+ ? {
150
+ ...process.env,
151
+ GIT_CONFIG_PARAMETERS: "'delta.hyperlinks=false' 'delta.line-numbers=false'",
152
+ }
153
+ : process.env;
154
+ const pager = spawn(tool, pagerArgs, {
155
+ stdio: ["pipe", "pipe", "pipe"],
156
+ env: pagerEnv,
157
+ });
158
+ let out = "";
159
+ let err = "";
160
+ pager.stdout.on("data", (d) => {
161
+ out += d.toString();
162
+ });
163
+ pager.stderr.on("data", (d) => {
164
+ err += d.toString();
165
+ });
166
+ pager.on("error", reject);
167
+ pager.on("close", (code) => {
168
+ if (code !== 0 && !out) {
169
+ reject(new Error(err || `${tool} exited with code ${code}`));
170
+ }
171
+ else {
172
+ resolve(splitCombinedSgr(out));
173
+ }
174
+ });
175
+ pager.stdin.write(input);
176
+ pager.stdin.end();
177
+ });
178
+ }
137
179
  function parseNameStatus(raw) {
138
180
  const files = [];
139
181
  for (const line of raw.split("\n")) {
@@ -259,7 +301,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
259
301
  if (mode === "diff") {
260
302
  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
303
  }
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" })] }));
304
+ 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
305
  }
264
306
  // ── Component ─────────────────────────────────────────────────────────
265
307
  export default function Dashboard() {
@@ -346,14 +388,23 @@ export default function Dashboard() {
346
388
  }
347
389
  repoRootRef.current = repoRoot;
348
390
  try {
349
- // Re-detect terminal theme alongside data fetch so light↔dark switches
350
- // propagate within one refresh cycle (≤30s).
391
+ // Re-detect terminal theme alongside data fetch so light↔dark
392
+ // switches propagate within one refresh cycle (≤30s). Skip the
393
+ // OSC 11 query when a text-input overlay is active — the
394
+ // terminal's response would otherwise leak into the user's
395
+ // commit/PR/context message via Ink's stdin handler.
396
+ const overlay = stateRef.current.overlay;
397
+ const inTextInput = overlay === "context-input" ||
398
+ (overlay === "pr-create" && stateRef.current.prCreatePhase === "review") ||
399
+ (overlay === "commit" && stateRef.current.commitPhase === "awaiting-message");
400
+ const themeP = inTextInput ? Promise.resolve(null) : detectTerminalTheme();
351
401
  const [data, reviewData, themeMode] = await Promise.all([
352
402
  loadDashboardData(repoRoot),
353
403
  loadReviewsData(repoRoot),
354
- detectTerminalTheme(),
404
+ themeP,
355
405
  ]);
356
- setTheme(getThemeForMode(themeMode));
406
+ if (themeMode !== null)
407
+ setTheme(getThemeForMode(themeMode));
357
408
  // Workspace file presence — only meaningful when the editor consumes
358
409
  // `.code-workspace` files. Cheap directory read; recomputed each cycle
359
410
  // in case the user adds/removes one.
@@ -644,6 +695,29 @@ export default function Dashboard() {
644
695
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
645
696
  };
646
697
  }, [state.overlay, state.prCreatePhase, state.commitPhase]);
698
+ // ── Diff overlay: load file list when opened (gh pr diff path) ────
699
+ // Reviews-tab PRs without a local worktree shell out to `gh pr diff <n>`,
700
+ // parse the unified blob into per-file records, and stash the per-file
701
+ // content for the content-loader effect below to read synchronously.
702
+ useEffect(() => {
703
+ if (state.overlay !== "diff" || state.diffPRNumber == null)
704
+ return;
705
+ const prNumber = state.diffPRNumber;
706
+ void (async () => {
707
+ try {
708
+ const { stdout } = await execAsync(`gh pr diff ${prNumber}`, {
709
+ maxBuffer: 32 * 1024 * 1024,
710
+ });
711
+ const { files, contentByPath } = parseUnifiedDiff(stdout);
712
+ const ordered = flattenTreeFiles(files);
713
+ dispatch({ type: "DIFF_PR_LOADED", files: ordered, contentByPath });
714
+ }
715
+ catch (err) {
716
+ const msg = err instanceof Error ? err.message : String(err);
717
+ dispatch({ type: "DIFF_FILES_ERROR", error: msg });
718
+ }
719
+ })();
720
+ }, [state.overlay, state.diffPRNumber]);
647
721
  // ── Diff overlay: load file list when opened ──────────────────────
648
722
  // Resolves merge-base against the configured base branch so upstream-only
649
723
  // changes (commits on master we haven't pulled) are excluded — same semantics
@@ -702,6 +776,44 @@ export default function Dashboard() {
702
776
  }
703
777
  })();
704
778
  }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
779
+ // ── Diff overlay: select content for current file (gh pr diff path) ─
780
+ // Per-file slices were parsed up front by the file-list effect above —
781
+ // just pull the right entry out of the map. When SANTREE_DIFF_TOOL is set,
782
+ // pipe the slice through the pager so reviews-tab diffs get the same
783
+ // syntax highlighting as worktree diffs.
784
+ useEffect(() => {
785
+ if (state.overlay !== "diff" || state.diffPRNumber == null)
786
+ return;
787
+ const file = state.diffFiles[state.diffFileIndex];
788
+ if (!file) {
789
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: "" });
790
+ return;
791
+ }
792
+ const raw = state.diffPRContentByPath[file.path] ?? "";
793
+ const tool = getDiffTool();
794
+ if (!tool || !raw) {
795
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: raw });
796
+ return;
797
+ }
798
+ dispatch({ type: "DIFF_CONTENT_LOADING" });
799
+ void (async () => {
800
+ try {
801
+ const content = await runPagerOnString(raw, tool, theme.mode);
802
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
803
+ }
804
+ catch (err) {
805
+ const msg = err instanceof Error ? err.message : String(err);
806
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: `Error rendering diff: ${msg}` });
807
+ }
808
+ })();
809
+ }, [
810
+ state.overlay,
811
+ state.diffPRNumber,
812
+ state.diffFileIndex,
813
+ state.diffFiles,
814
+ state.diffPRContentByPath,
815
+ theme.mode,
816
+ ]);
705
817
  // ── Diff overlay: load content for selected file ──────────────────
706
818
  // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
707
819
  // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
@@ -765,7 +877,15 @@ export default function Dashboard() {
765
877
  const windowName = di.issue.identifier;
766
878
  const sessionId = di.worktree?.sessionId;
767
879
  const bin = resolveAgentBinary();
768
- const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
880
+ // `claude --resume` is cwd-scoped it only finds the session under
881
+ // the encoded path of the current cwd. Project conventions (direnv,
882
+ // shell init) sometimes leave the tmux window in a subdir, so we
883
+ // prepend `cd <sessionCwd>` (resolved by `findClaudeSessionCwd`)
884
+ // to guarantee the resume runs from where the session was created.
885
+ const sessionCwd = di.worktree?.sessionCwd ?? di.worktree?.path;
886
+ const resumeCmd = sessionId && bin && sessionCwd
887
+ ? `cd ${shellEscape(sessionCwd)} && ${bin} --resume ${sessionId}`
888
+ : null;
769
889
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
770
890
  const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
771
891
  const cmd = resumeCmd ?? workCmd;
@@ -1024,13 +1144,12 @@ export default function Dashboard() {
1024
1144
  // ── Commit flow ──────────────────────────────────────────────────
1025
1145
  const handleStageAll = useCallback(async () => {
1026
1146
  const wtPath = stateRef.current.commitWorktreePath;
1027
- const ticketId = stateRef.current.commitTicketId;
1028
1147
  if (!wtPath)
1029
1148
  return;
1030
1149
  try {
1031
1150
  await execAsync("git add -A", { cwd: wtPath });
1032
- dispatch({ type: "COMMIT_MESSAGE", message: `[${ticketId}] ` });
1033
- dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1151
+ // After staging, ask whether to draft with AI or write manually.
1152
+ dispatch({ type: "COMMIT_PHASE", phase: "choose-mode" });
1034
1153
  }
1035
1154
  catch (e) {
1036
1155
  dispatch({
@@ -1039,6 +1158,46 @@ export default function Dashboard() {
1039
1158
  });
1040
1159
  }
1041
1160
  }, []);
1161
+ const handleFillCommit = useCallback(async () => {
1162
+ const s = stateRef.current;
1163
+ const wtPath = s.commitWorktreePath;
1164
+ const branch = s.commitBranch;
1165
+ const ticketId = s.commitTicketId;
1166
+ if (!wtPath || !branch)
1167
+ return;
1168
+ dispatch({ type: "COMMIT_PHASE", phase: "filling" });
1169
+ const diffContent = getStagedDiffContent(wtPath);
1170
+ const fallbackPrefix = ticketId ? `[${ticketId}] ` : "";
1171
+ if (!diffContent.trim()) {
1172
+ dispatch({ type: "COMMIT_MESSAGE", message: fallbackPrefix });
1173
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1174
+ return;
1175
+ }
1176
+ // Pull ticket context so the AI message is grounded in the requested
1177
+ // change rather than just the literal diff.
1178
+ let ticketContent;
1179
+ const mainRoot = repoRootRef.current;
1180
+ if (ticketId && mainRoot) {
1181
+ try {
1182
+ const tracker = getIssueTracker(mainRoot);
1183
+ const result = await tracker.getIssue(ticketId, mainRoot);
1184
+ if (result.ok) {
1185
+ ticketContent = renderTicket(result.value, tracker.displayName);
1186
+ }
1187
+ }
1188
+ catch {
1189
+ // non-fatal — the prompt works with diff alone
1190
+ }
1191
+ }
1192
+ const drafted = await fillCommitMessage({
1193
+ branch,
1194
+ ticketId,
1195
+ ticketContent,
1196
+ diffContent,
1197
+ });
1198
+ dispatch({ type: "COMMIT_MESSAGE", message: drafted ?? fallbackPrefix });
1199
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1200
+ }, []);
1042
1201
  const handleCommitSubmit = useCallback(async (value) => {
1043
1202
  const s = stateRef.current;
1044
1203
  if (!s.commitWorktreePath || !s.commitBranch)
@@ -1048,9 +1207,10 @@ export default function Dashboard() {
1048
1207
  dispatch({ type: "COMMIT_ERROR", error: "Empty commit message" });
1049
1208
  return;
1050
1209
  }
1051
- const msg = trimmed.includes(`[${s.commitTicketId}]`)
1052
- ? trimmed
1053
- : `[${s.commitTicketId}] ${trimmed}`;
1210
+ // Auto-prefix with `[TICKET]` only when there's a real ticket
1211
+ // AND the user hasn't already typed it.
1212
+ const tid = s.commitTicketId;
1213
+ const msg = tid && !trimmed.includes(`[${tid}]`) ? `[${tid}] ${trimmed}` : trimmed;
1054
1214
  dispatch({ type: "COMMIT_PHASE", phase: "committing" });
1055
1215
  try {
1056
1216
  await execAsync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
@@ -1263,8 +1423,25 @@ export default function Dashboard() {
1263
1423
  if (state.actionMessage && input !== "q") {
1264
1424
  dispatch({ type: "SET_ACTION_MESSAGE", message: null });
1265
1425
  }
1426
+ // Help overlay — toggleable from anywhere except text-input
1427
+ // overlays. ? opens, ? again or Esc closes.
1428
+ if (state.overlay === "help") {
1429
+ if (input === "?" || key.escape) {
1430
+ dispatch({ type: "SET_OVERLAY", overlay: null });
1431
+ }
1432
+ return;
1433
+ }
1434
+ if (input === "?" && state.overlay === null) {
1435
+ dispatch({ type: "SET_OVERLAY", overlay: "help" });
1436
+ return;
1437
+ }
1266
1438
  // Commit overlay
1267
1439
  if (state.overlay === "commit") {
1440
+ // awaiting-message is owned by MultilineTextArea (Ctrl+D submit,
1441
+ // Ctrl+G cancel) — escape there is handled inside the component,
1442
+ // so we don't intercept any keys at this phase.
1443
+ if (state.commitPhase === "awaiting-message")
1444
+ return;
1268
1445
  if (key.escape) {
1269
1446
  dispatch({ type: "COMMIT_CANCEL" });
1270
1447
  return;
@@ -1280,8 +1457,20 @@ export default function Dashboard() {
1280
1457
  }
1281
1458
  return;
1282
1459
  }
1283
- // awaiting-message is handled by TextInput, not useInput
1284
- // All other phases: swallow input
1460
+ if (state.commitPhase === "choose-mode") {
1461
+ if (input === "f") {
1462
+ handleFillCommit();
1463
+ return;
1464
+ }
1465
+ if (input === "m") {
1466
+ const tid = state.commitTicketId;
1467
+ dispatch({ type: "COMMIT_MESSAGE", message: tid ? `[${tid}] ` : "" });
1468
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
1469
+ return;
1470
+ }
1471
+ return;
1472
+ }
1473
+ // committing / pushing / done / error: swallow
1285
1474
  return;
1286
1475
  }
1287
1476
  // PR create overlay
@@ -1681,15 +1870,55 @@ export default function Dashboard() {
1681
1870
  const ri = state.flatReviews[state.reviewSelectedIndex];
1682
1871
  if (!ri)
1683
1872
  return;
1684
- // Open PR in browser
1873
+ // Open linked ticket in browser (only when one is associated).
1874
+ // Aligns with the issues tab: `[o]` always opens the ticket; `[p]`
1875
+ // opens the PR. The previous behavior — `[o]` opens the PR — is
1876
+ // the only intentional muscle-memory break in this redesign.
1685
1877
  if (input === "o") {
1686
- if (ri.pr.url) {
1687
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1688
- execSync(`${openCmd} "${ri.pr.url}"`, { stdio: "ignore" });
1878
+ if (!ri.ticket?.url) {
1879
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No linked ticket" });
1880
+ return;
1881
+ }
1882
+ if (openUrl(ri.ticket.url)) {
1883
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened ticket in browser" });
1884
+ }
1885
+ return;
1886
+ }
1887
+ // Open PR in browser
1888
+ if (input === "p") {
1889
+ if (!ri.pr.url)
1890
+ return;
1891
+ if (openUrl(ri.pr.url)) {
1689
1892
  dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
1690
1893
  }
1691
1894
  return;
1692
1895
  }
1896
+ // View diff (inline overlay).
1897
+ // - With a local worktree: reuse the issues-tab path (git diff
1898
+ // against merge-base, full XY/staging support).
1899
+ // - Without a worktree: parse `gh pr diff <n>` once, render the
1900
+ // same DiffOverlay in read-only mode.
1901
+ if (input === "v") {
1902
+ const ticketLabel = ri.ticket?.identifier ?? `#${ri.pr.number}`;
1903
+ if (ri.worktree) {
1904
+ const baseBranch = getBaseBranch(ri.worktree.branch);
1905
+ dispatch({
1906
+ type: "DIFF_OPEN",
1907
+ ticketId: ticketLabel,
1908
+ worktreePath: ri.worktree.path,
1909
+ baseBranch,
1910
+ });
1911
+ }
1912
+ else {
1913
+ dispatch({
1914
+ type: "DIFF_OPEN_PR",
1915
+ label: ticketLabel,
1916
+ prNumber: ri.pr.number,
1917
+ baseBranch: ri.baseBranch ?? "main",
1918
+ });
1919
+ }
1920
+ return;
1921
+ }
1693
1922
  // Create worktree from PR branch (checkout for local testing)
1694
1923
  if (input === "w") {
1695
1924
  if (ri.worktree) {
@@ -1898,7 +2127,10 @@ export default function Dashboard() {
1898
2127
  const windowName = di.issue.identifier;
1899
2128
  const sessionId = di.worktree.sessionId;
1900
2129
  const bin = resolveAgentBinary();
1901
- const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
2130
+ const sessionCwd = di.worktree.sessionCwd ?? di.worktree.path;
2131
+ const resumeCmd = sessionId && bin
2132
+ ? `cd ${shellEscape(sessionCwd)} && ${bin} --resume ${sessionId}`
2133
+ : null;
1902
2134
  const worktreePath = di.worktree.path;
1903
2135
  void (async () => {
1904
2136
  const selected = await mux.selectWindow(windowName);
@@ -1931,9 +2163,9 @@ export default function Dashboard() {
1931
2163
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
1932
2164
  return;
1933
2165
  }
1934
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1935
- execSync(`${openCmd} "${di.issue.url}"`, { stdio: "ignore" });
1936
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2166
+ if (openUrl(di.issue.url)) {
2167
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2168
+ }
1937
2169
  return;
1938
2170
  }
1939
2171
  // Open PR
@@ -1942,9 +2174,9 @@ export default function Dashboard() {
1942
2174
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to open" });
1943
2175
  return;
1944
2176
  }
1945
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
1946
- execSync(`${openCmd} "${di.pr.url}"`, { stdio: "ignore" });
1947
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
2177
+ if (openUrl(di.pr.url)) {
2178
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
2179
+ }
1948
2180
  return;
1949
2181
  }
1950
2182
  // Create PR
@@ -2025,7 +2257,9 @@ export default function Dashboard() {
2025
2257
  }
2026
2258
  dispatch({
2027
2259
  type: "COMMIT_START",
2028
- ticketId: di.issue.identifier,
2260
+ // Main-row commits don't carry a ticket prefix — only real
2261
+ // tracker tickets do.
2262
+ ticketId: di.issue.state.type === "main" ? null : di.issue.identifier,
2029
2263
  worktreePath: di.worktree.path,
2030
2264
  branch: di.worktree.branch,
2031
2265
  gitStatus: di.worktree.gitStatus,
@@ -2094,14 +2328,14 @@ export default function Dashboard() {
2094
2328
  });
2095
2329
  // ── Render ─────────────────────────────────────────────────────────
2096
2330
  if (state.loading) {
2097
- 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..." })] }) }));
2331
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: _jsx(SquirrelLoader, { text: "Loading dashboard..." }) }) }));
2098
2332
  }
2099
2333
  if (state.error) {
2100
2334
  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" })] }) }));
2101
2335
  }
2102
2336
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
2103
2337
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
2104
- 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: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
2338
+ 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: () => {
2105
2339
  const mode = state.contextInputMode;
2106
2340
  const ctx = state.contextInputValue;
2107
2341
  dispatch({ type: "CONTEXT_INPUT_DONE" });
@@ -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 {};
@@ -3,12 +3,19 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box, useInput, useApp } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import Spinner from "ink-spinner";
6
+ import { z } from "zod";
6
7
  import { exec } from "child_process";
7
8
  import { promisify } from "util";
8
- import { findRepoRoot, getCurrentBranch, extractTicketId, getGitStatus, getStagedDiffStat, hasStagedChanges, hasUnstagedChanges, } from "../../lib/git.js";
9
+ import { findRepoRoot, findMainRepoRoot, getCurrentBranch, extractTicketId, getGitStatus, getStagedDiffStat, getStagedDiffContent, hasStagedChanges, hasUnstagedChanges, } from "../../lib/git.js";
10
+ import { fillCommitMessage } from "../../lib/ai.js";
11
+ import { getIssueTracker } from "../../lib/trackers/index.js";
12
+ import { renderTicket } from "../../lib/prompts.js";
9
13
  export const description = "Stage and commit changes";
14
+ export const options = z.object({
15
+ fill: z.boolean().optional().describe("Use AI to draft a short commit message"),
16
+ });
10
17
  const execAsync = promisify(exec);
11
- export default function Commit() {
18
+ export default function Commit({ options }) {
12
19
  const { exit } = useApp();
13
20
  const [status, setStatus] = useState("loading");
14
21
  const [message, setMessage] = useState("");
@@ -25,10 +32,9 @@ export default function Commit() {
25
32
  stageAndContinue();
26
33
  }
27
34
  else if (input === "n" || input === "N" || key.escape) {
28
- if (hasStagedChanges()) {
29
- setStatus("awaiting-message");
30
- const prefix = ticketId ? `[${ticketId}] ` : "";
31
- setCommitInput(prefix);
35
+ if (hasStagedChanges() && repoRoot && branch) {
36
+ // Respect --fill even when the user declines to stage more.
37
+ void openMessagePhase({ repoRoot, branch, ticketId });
32
38
  }
33
39
  else {
34
40
  setStatus("no-changes");
@@ -39,19 +45,59 @@ export default function Commit() {
39
45
  }
40
46
  });
41
47
  async function stageAndContinue() {
48
+ if (!repoRoot || !branch)
49
+ return;
42
50
  try {
43
- await execAsync("git add -A", { cwd: repoRoot ?? undefined });
51
+ await execAsync("git add -A", { cwd: repoRoot });
44
52
  setGitStatus(getGitStatus());
45
53
  setDiffStat(getStagedDiffStat());
46
- setStatus("awaiting-message");
47
- const prefix = ticketId ? `[${ticketId}] ` : "";
48
- setCommitInput(prefix);
54
+ await openMessagePhase({ repoRoot, branch, ticketId });
49
55
  }
50
56
  catch (e) {
51
57
  setStatus("error");
52
58
  setMessage(`Failed to stage changes: ${e}`);
53
59
  }
54
60
  }
61
+ // Routes to either the AI-fill phase or straight to the bare input.
62
+ // Takes context as args so callers in init() (where state isn't yet
63
+ // propagated from the just-fired setStates) can hand in fresh values.
64
+ async function openMessagePhase(ctx) {
65
+ const prefix = ctx.ticketId ? `[${ctx.ticketId}] ` : "";
66
+ if (options.fill) {
67
+ setStatus("filling");
68
+ setMessage("Drafting commit message with Claude...");
69
+ const drafted = await draftWithAI(ctx);
70
+ // Whether Claude succeeds or not, fall through to the input —
71
+ // the user can edit or type from scratch.
72
+ setCommitInput(drafted ?? prefix);
73
+ setStatus("awaiting-message");
74
+ return;
75
+ }
76
+ setCommitInput(prefix);
77
+ setStatus("awaiting-message");
78
+ }
79
+ async function draftWithAI(ctx) {
80
+ const diffContent = getStagedDiffContent(ctx.repoRoot);
81
+ if (!diffContent.trim())
82
+ return null;
83
+ // Pull ticket context if we can — the prompt uses it to ground the
84
+ // summary in the requested change rather than the literal diff.
85
+ let ticketContent;
86
+ const mainRoot = findMainRepoRoot();
87
+ if (ctx.ticketId && mainRoot) {
88
+ const tracker = getIssueTracker(mainRoot);
89
+ const result = await tracker.getIssue(ctx.ticketId, mainRoot);
90
+ if (result.ok) {
91
+ ticketContent = renderTicket(result.value, tracker.displayName);
92
+ }
93
+ }
94
+ return fillCommitMessage({
95
+ branch: ctx.branch,
96
+ ticketId: ctx.ticketId,
97
+ ticketContent,
98
+ diffContent,
99
+ });
100
+ }
55
101
  async function handleCommitSubmit(value) {
56
102
  const trimmed = value.trim();
57
103
  if (!trimmed) {
@@ -128,9 +174,7 @@ export default function Commit() {
128
174
  }
129
175
  else if (staged) {
130
176
  setDiffStat(getStagedDiffStat());
131
- setStatus("awaiting-message");
132
- const prefix = ticket ? `[${ticket}] ` : "";
133
- setCommitInput(prefix);
177
+ await openMessagePhase({ repoRoot: root, branch: currentBranch, ticketId: ticket });
134
178
  }
135
179
  else {
136
180
  setStatus("no-changes");
@@ -140,7 +184,7 @@ export default function Commit() {
140
184
  }
141
185
  init();
142
186
  }, []);
143
- const isLoading = status === "loading" || status === "committing" || status === "pushing";
187
+ const isLoading = status === "loading" || status === "filling" || status === "committing" || status === "pushing";
144
188
  return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDCBE Commit" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] }))] }), gitStatus && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: "Changes:" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: "100%", children: [gitStatus
145
189
  .split("\n")
146
190
  .slice(0, 10)