santree 0.5.6 → 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 +210 -33
  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 +14 -1
  14. package/dist/lib/dashboard/Overlays.d.ts +5 -0
  15. package/dist/lib/dashboard/Overlays.js +75 -2
  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 -5
  21. package/dist/lib/dashboard/types.js +40 -0
  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.
@@ -644,6 +656,29 @@ export default function Dashboard() {
644
656
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
645
657
  };
646
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
@@ -1681,15 +1813,55 @@ export default function Dashboard() {
1681
1813
  const ri = state.flatReviews[state.reviewSelectedIndex];
1682
1814
  if (!ri)
1683
1815
  return;
1684
- // 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.
1685
1820
  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" });
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)) {
1689
1835
  dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
1690
1836
  }
1691
1837
  return;
1692
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
+ }
1693
1865
  // Create worktree from PR branch (checkout for local testing)
1694
1866
  if (input === "w") {
1695
1867
  if (ri.worktree) {
@@ -1898,7 +2070,10 @@ export default function Dashboard() {
1898
2070
  const windowName = di.issue.identifier;
1899
2071
  const sessionId = di.worktree.sessionId;
1900
2072
  const bin = resolveAgentBinary();
1901
- 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;
1902
2077
  const worktreePath = di.worktree.path;
1903
2078
  void (async () => {
1904
2079
  const selected = await mux.selectWindow(windowName);
@@ -1931,9 +2106,9 @@ export default function Dashboard() {
1931
2106
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
1932
2107
  return;
1933
2108
  }
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" });
2109
+ if (openUrl(di.issue.url)) {
2110
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2111
+ }
1937
2112
  return;
1938
2113
  }
1939
2114
  // Open PR
@@ -1942,9 +2117,9 @@ export default function Dashboard() {
1942
2117
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to open" });
1943
2118
  return;
1944
2119
  }
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" });
2120
+ if (openUrl(di.pr.url)) {
2121
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
2122
+ }
1948
2123
  return;
1949
2124
  }
1950
2125
  // Create PR
@@ -2025,7 +2200,9 @@ export default function Dashboard() {
2025
2200
  }
2026
2201
  dispatch({
2027
2202
  type: "COMMIT_START",
2028
- 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,
2029
2206
  worktreePath: di.worktree.path,
2030
2207
  branch: di.worktree.branch,
2031
2208
  gitStatus: di.worktree.gitStatus,
@@ -2094,14 +2271,14 @@ export default function Dashboard() {
2094
2271
  });
2095
2272
  // ── Render ─────────────────────────────────────────────────────────
2096
2273
  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..." })] }) }));
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..." }) }) }));
2098
2275
  }
2099
2276
  if (state.error) {
2100
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" })] }) }));
2101
2278
  }
2102
2279
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
2103
2280
  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: () => {
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: () => {
2105
2282
  const mode = state.contextInputMode;
2106
2283
  const ctx = state.contextInputValue;
2107
2284
  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)
package/dist/lib/ai.d.ts CHANGED
@@ -75,7 +75,33 @@ export interface RunAgentResult {
75
75
  export declare function runAgent(prompt: string, opts?: {
76
76
  allowedTools?: string[];
77
77
  }): RunAgentResult;
78
+ /**
79
+ * Async version of runAgent. Use this from Ink renderers — spawnSync
80
+ * blocks Node's event loop, freezing the UI (no spinner animation, no
81
+ * keystroke processing) for the entire duration of Claude's generation.
82
+ * spawn() lets the loop run during the call.
83
+ */
84
+ export declare function runAgentAsync(prompt: string, opts?: {
85
+ allowedTools?: string[];
86
+ }): Promise<RunAgentResult>;
78
87
  /**
79
88
  * Clean up cached image downloads for an issue identifier on the active tracker.
80
89
  */
81
90
  export declare function cleanupImages(ticketId: string): void;
91
+ export interface FillCommitOpts {
92
+ branch: string;
93
+ ticketId: string | null;
94
+ ticketContent?: string;
95
+ diffContent: string;
96
+ }
97
+ /**
98
+ * Generate a short imperative commit message from a staged diff.
99
+ * Async so callers (the Ink dashboard, the CLI commit flow) keep the
100
+ * event loop turning during Claude's ~5–30s generation — using the sync
101
+ * runAgent here freezes the renderer.
102
+ *
103
+ * Returns the trimmed message string (no quotes, no preamble) on success,
104
+ * or null if Claude failed. Caller is responsible for ensuring the diff
105
+ * is non-empty.
106
+ */
107
+ export declare function fillCommitMessage(opts: FillCommitOpts): Promise<string | null>;