mintree 0.3.2 → 0.4.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.
@@ -10,7 +10,7 @@ import { resolveClaudeBinary } from "../lib/claude.js";
10
10
  import { tryExec } from "../lib/exec.js";
11
11
  import { getLatestVersion, isNewerVersion } from "../lib/version.js";
12
12
  import { ALLOWED_TYPES } from "../lib/branch.js";
13
- import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
13
+ import { runCreate, runCreateDetached, } from "../lib/worktreeCreate.js";
14
14
  import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
15
15
  import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
16
16
  import { readMetadata } from "../lib/metadata.js";
@@ -19,6 +19,20 @@ import { loadDashboard } from "../lib/dashboard.js";
19
19
  const require = createRequire(import.meta.url);
20
20
  const { version: mintreeVersion } = require("../../package.json");
21
21
  export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
22
+ function isOrphan(d) {
23
+ return d.orphan === true;
24
+ }
25
+ function tabIssues(issues, tab) {
26
+ return issues.filter((d) => (tab === "issues" ? !isOrphan(d) : isOrphan(d)));
27
+ }
28
+ function currentSelected(s) {
29
+ const displayed = tabIssues(s.issues, s.activeTab);
30
+ const selectedIndex = s.activeTab === "issues" ? s.issuesIndex : s.worktreesIndex;
31
+ return { displayed, selectedIndex };
32
+ }
33
+ function withSelectedIndex(s, next) {
34
+ return s.activeTab === "issues" ? { ...s, issuesIndex: next } : { ...s, worktreesIndex: next };
35
+ }
22
36
  // xterm/iTerm/etc switch to the alternate screen buffer with these escape
23
37
  // codes. Using the buffer means the dashboard owns the whole window for its
24
38
  // lifetime, and the previous shell content reappears unchanged the moment
@@ -152,8 +166,10 @@ function useTerminalSize() {
152
166
  }, [stdout]);
153
167
  return size;
154
168
  }
155
- function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
156
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
169
+ function HeaderRow({ repoName, claudeVersion, issueCount, worktreeCount, activeTab, updateAvailable, }) {
170
+ const issuesLabel = ` Issues (${issueCount}) `;
171
+ const worktreesLabel = ` Worktrees (${worktreeCount}) `;
172
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsxs(Box, { children: [activeTab === "issues" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: issuesLabel })) : (_jsx(Text, { dimColor: true, children: issuesLabel })), _jsx(Text, { children: " " }), activeTab === "worktrees" ? (_jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: worktreesLabel })) : (_jsx(Text, { dimColor: true, children: worktreesLabel })), _jsx(Text, { dimColor: true, children: " ← / → switch tab" })] })] }));
157
173
  }
158
174
  function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
159
175
  if (phase === "error") {
@@ -186,7 +202,14 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
186
202
  const dirPreview = isNewBranch
187
203
  ? `${overlay.issue.issue.id}-${overlay.desc}`
188
204
  : `${overlay.issue.issue.id}-${detachedDesc}`;
189
- return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
205
+ return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for ${overlay.issue.issue.id}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.steps.length > 0 && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: overlay.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(CreateStepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))) })), overlay.pending && (_jsxs(Box, { marginTop: overlay.steps.length > 0 ? 0 : 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
206
+ }
207
+ function CreateStepIcon({ kind }) {
208
+ if (kind === "ok")
209
+ return _jsx(Text, { color: "green", children: "\u2713" });
210
+ if (kind === "warn")
211
+ return _jsx(Text, { color: "yellow", children: "!" });
212
+ return _jsx(Text, { color: "cyan", children: "\u25CB" });
190
213
  }
191
214
  function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
192
215
  // Display the issue id raw (e.g. "FE-123", "100"). The `#` prefix is a
@@ -639,18 +662,32 @@ export default function Dashboard() {
639
662
  return;
640
663
  }
641
664
  setState((prev) => {
642
- const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
643
- const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
644
- const previousToast = prev.phase === "ready" ? prev.toast : null;
645
- const previousScroll = prev.phase === "ready" ? prev.detailScrollOffset : 0;
646
- const clamped = Math.min(previousIndex, Math.max(0, issues.length - 1));
647
- // Preserve scroll only when the selected issue stayed put — clamping
648
- // to a different row means the user is now reading something else.
649
- const detailScrollOffset = clamped === previousIndex ? previousScroll : 0;
665
+ const prevReady = prev.phase === "ready" ? prev : null;
666
+ const activeTab = prevReady?.activeTab ?? "issues";
667
+ const previousIssuesIndex = prevReady?.issuesIndex ?? 0;
668
+ const previousWorktreesIndex = prevReady?.worktreesIndex ?? 0;
669
+ const previousOverlay = prevReady?.overlay ?? null;
670
+ const previousToast = prevReady?.toast ?? null;
671
+ const previousScroll = prevReady?.detailScrollOffset ?? 0;
672
+ const issuesList = tabIssues(issues, "issues");
673
+ const worktreesList = tabIssues(issues, "worktrees");
674
+ const issuesIndex = Math.min(previousIssuesIndex, Math.max(0, issuesList.length - 1));
675
+ const worktreesIndex = Math.min(previousWorktreesIndex, Math.max(0, worktreesList.length - 1));
676
+ // Preserve scroll only when the active tab's selected issue still
677
+ // resolves to the same row — clamping or list churn means the user
678
+ // is now reading something else.
679
+ const prevDisplayed = prevReady ? tabIssues(prevReady.issues, activeTab) : [];
680
+ const nextDisplayed = activeTab === "issues" ? issuesList : worktreesList;
681
+ const prevSelectedId = prevDisplayed[activeTab === "issues" ? previousIssuesIndex : previousWorktreesIndex]?.issue
682
+ .id ?? null;
683
+ const nextSelectedId = nextDisplayed[activeTab === "issues" ? issuesIndex : worktreesIndex]?.issue.id ?? null;
684
+ const detailScrollOffset = prevSelectedId !== null && prevSelectedId === nextSelectedId ? previousScroll : 0;
650
685
  return {
651
686
  phase: "ready",
652
687
  issues,
653
- selectedIndex: clamped,
688
+ activeTab,
689
+ issuesIndex,
690
+ worktreesIndex,
654
691
  detailScrollOffset,
655
692
  refreshing: false,
656
693
  overlay: previousOverlay,
@@ -707,10 +744,11 @@ export default function Dashboard() {
707
744
  if (prev.overlay)
708
745
  return prev; // overlay pauses scroll routing
709
746
  if (inLeftPane) {
710
- const next = Math.max(0, Math.min(prev.issues.length - 1, prev.selectedIndex + delta));
711
- if (next === prev.selectedIndex)
747
+ const { displayed, selectedIndex } = currentSelected(prev);
748
+ const next = Math.max(0, Math.min(displayed.length - 1, selectedIndex + delta));
749
+ if (next === selectedIndex)
712
750
  return prev;
713
- return { ...prev, selectedIndex: next, detailScrollOffset: 0 };
751
+ return { ...withSelectedIndex(prev, next), detailScrollOffset: 0 };
714
752
  }
715
753
  const next = Math.max(0, prev.detailScrollOffset + delta);
716
754
  if (next === prev.detailScrollOffset)
@@ -781,20 +819,23 @@ export default function Dashboard() {
781
819
  }
782
820
  if (state.phase !== "ready")
783
821
  return;
822
+ if (key.leftArrow || key.rightArrow) {
823
+ // Two tabs only — either arrow toggles. Per-tab indices are
824
+ // preserved, so the user returns to the row they left.
825
+ const next = state.activeTab === "issues" ? "worktrees" : "issues";
826
+ setState({ ...state, activeTab: next, detailScrollOffset: 0 });
827
+ return;
828
+ }
784
829
  if (key.upArrow || input === "k") {
785
- setState({
786
- ...state,
787
- selectedIndex: Math.max(0, state.selectedIndex - 1),
788
- detailScrollOffset: 0,
789
- });
830
+ const { selectedIndex } = currentSelected(state);
831
+ const nextIndex = Math.max(0, selectedIndex - 1);
832
+ setState({ ...withSelectedIndex(state, nextIndex), detailScrollOffset: 0 });
790
833
  return;
791
834
  }
792
835
  if (key.downArrow || input === "j") {
793
- setState({
794
- ...state,
795
- selectedIndex: Math.min(state.issues.length - 1, state.selectedIndex + 1),
796
- detailScrollOffset: 0,
797
- });
836
+ const { displayed, selectedIndex } = currentSelected(state);
837
+ const nextIndex = Math.min(Math.max(0, displayed.length - 1), selectedIndex + 1);
838
+ setState({ ...withSelectedIndex(state, nextIndex), detailScrollOffset: 0 });
798
839
  return;
799
840
  }
800
841
  if (key.pageUp) {
@@ -817,7 +858,8 @@ export default function Dashboard() {
817
858
  return;
818
859
  }
819
860
  if (input === "o") {
820
- const issue = state.issues[state.selectedIndex];
861
+ const { displayed, selectedIndex } = currentSelected(state);
862
+ const issue = displayed[selectedIndex];
821
863
  // Orphan rows carry an empty URL — nothing to open. Skip silently
822
864
  // rather than asking the OS to open an empty string.
823
865
  if (issue && issue.issue.url)
@@ -825,7 +867,8 @@ export default function Dashboard() {
825
867
  return;
826
868
  }
827
869
  if (input === "w") {
828
- const issue = state.issues[state.selectedIndex];
870
+ const { displayed, selectedIndex } = currentSelected(state);
871
+ const issue = displayed[selectedIndex];
829
872
  if (!issue)
830
873
  return;
831
874
  if (issue.worktree) {
@@ -836,7 +879,8 @@ export default function Dashboard() {
836
879
  return;
837
880
  }
838
881
  if (key.return) {
839
- const issue = state.issues[state.selectedIndex];
882
+ const { displayed, selectedIndex } = currentSelected(state);
883
+ const issue = displayed[selectedIndex];
840
884
  if (!issue)
841
885
  return;
842
886
  if (issue.worktree) {
@@ -852,7 +896,8 @@ export default function Dashboard() {
852
896
  return;
853
897
  }
854
898
  if (input === "d") {
855
- const issue = state.issues[state.selectedIndex];
899
+ const { displayed, selectedIndex } = currentSelected(state);
900
+ const issue = displayed[selectedIndex];
856
901
  if (!issue || !issue.worktree)
857
902
  return;
858
903
  setState({
@@ -888,6 +933,7 @@ export default function Dashboard() {
888
933
  error: null,
889
934
  conventionDoc: root ? findBranchConventionDoc(root) : null,
890
935
  pending: null,
936
+ steps: [],
891
937
  },
892
938
  toast: null,
893
939
  });
@@ -985,22 +1031,37 @@ export default function Dashboard() {
985
1031
  });
986
1032
  return;
987
1033
  }
988
- // Surface the spinner BEFORE the heavy sync work. runCreate /
989
- // runCreateDetached both block the event loop (execSync: git fetch +
990
- // worktree add + optional .mintree/init.sh — easily several seconds on
991
- // slow remotes or a repo with a real init script). Without yielding
992
- // here, Ink wouldn't get to paint the spinner before execSync blocks
993
- // the event loop, leaving the user staring at a frozen overlay.
994
- //
995
- // A single setImmediate isn't enough: React 19 commits the state on
996
- // the next microtask, then Ink schedules its stdout write on a later
997
- // macrotask. A ~32ms setTimeout (two frames at 60fps) covers both
998
- // phases reliably without being perceptible.
1034
+ // Enter the live-setup view: clear input chrome, reset the step log,
1035
+ // show a starting spinner. The actual progress updates come through
1036
+ // the runCreate/runCreateDetached callbacks below.
999
1037
  setState({
1000
1038
  ...state,
1001
- overlay: { ...overlay, error: null, pending: "Creating worktree..." },
1039
+ overlay: { ...overlay, error: null, pending: "Starting...", steps: [] },
1002
1040
  });
1003
1041
  await new Promise((resolve) => setTimeout(resolve, 32));
1042
+ // Progress callbacks invoked from inside runCreate/runCreateDetached.
1043
+ // Use functional setState so we don't clobber concurrent updates and
1044
+ // don't rely on the stale closure of `state`.
1045
+ const onStep = (step) => {
1046
+ setState((prev) => {
1047
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1048
+ return prev;
1049
+ return {
1050
+ ...prev,
1051
+ overlay: { ...prev.overlay, steps: [...prev.overlay.steps, step] },
1052
+ };
1053
+ });
1054
+ };
1055
+ const onPending = (label) => {
1056
+ setState((prev) => {
1057
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1058
+ return prev;
1059
+ return {
1060
+ ...prev,
1061
+ overlay: { ...prev.overlay, pending: label },
1062
+ };
1063
+ });
1064
+ };
1004
1065
  const prompt = overlay.prompt.trim();
1005
1066
  const issueId = overlay.issue.issue.id;
1006
1067
  let result;
@@ -1009,29 +1070,35 @@ export default function Dashboard() {
1009
1070
  // from the issue title (kebabized), not user input — keeping the
1010
1071
  // "current branch" flow as low-friction as possible.
1011
1072
  const descKebab = kebabize(overlay.issue.issue.title) || `issue-${issueId}`;
1012
- result = runCreateDetached({
1073
+ result = await runCreateDetached({
1013
1074
  issueId,
1014
1075
  descKebab,
1015
1076
  work: true,
1077
+ progress: { onStep, onPending },
1016
1078
  ...(prompt.length > 0 ? { prompt } : {}),
1017
1079
  });
1018
1080
  }
1019
1081
  else {
1020
1082
  const desc = overlay.desc.trim();
1021
1083
  const branch = `${overlay.type}/${issueId}-${desc}`;
1022
- result = runCreate(branch, {
1084
+ result = await runCreate(branch, {
1023
1085
  work: true,
1086
+ progress: { onStep, onPending },
1024
1087
  ...(prompt.length > 0 ? { prompt } : {}),
1025
1088
  });
1026
1089
  }
1027
1090
  if (!result.ok) {
1028
- setState({
1029
- ...state,
1030
- overlay: {
1031
- ...overlay,
1032
- pending: null,
1033
- error: result.message + (result.hint ? ` — ${result.hint}` : ""),
1034
- },
1091
+ setState((prev) => {
1092
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1093
+ return prev;
1094
+ return {
1095
+ ...prev,
1096
+ overlay: {
1097
+ ...prev.overlay,
1098
+ pending: null,
1099
+ error: result.ok ? null : result.message + (result.hint ? ` — ${result.hint}` : ""),
1100
+ },
1101
+ };
1035
1102
  });
1036
1103
  return;
1037
1104
  }
@@ -1039,9 +1106,15 @@ export default function Dashboard() {
1039
1106
  // to In Progress on its project. Errors from the GraphQL call don't
1040
1107
  // block the worktree hand-off; we swallow them and let `mintree doctor`
1041
1108
  // surface persistent issues (missing `project` scope, etc.).
1042
- setState({
1043
- ...state,
1044
- overlay: { ...overlay, error: null, pending: "Updating issue status..." },
1109
+ // Functional update preserves the accumulated `steps` list from the
1110
+ // progress callbacks; using the stale `overlay` closure would wipe it.
1111
+ setState((prev) => {
1112
+ if (prev.phase !== "ready" || prev.overlay?.kind !== "create")
1113
+ return prev;
1114
+ return {
1115
+ ...prev,
1116
+ overlay: { ...prev.overlay, error: null, pending: "Updating issue status..." },
1117
+ };
1045
1118
  });
1046
1119
  const repoRoot = findMainRepoRoot();
1047
1120
  if (repoRoot) {
@@ -1094,8 +1167,11 @@ export default function Dashboard() {
1094
1167
  if (state.phase === "error") {
1095
1168
  return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", state.message] }), state.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", state.hint] }) })), _jsx(Box, { marginTop: 1, children: _jsx(FooterRow, { phase: "error" }) })] }));
1096
1169
  }
1097
- const { issues, selectedIndex, refreshing, overlay, toast } = state;
1098
- const selected = issues[selectedIndex] ?? null;
1170
+ const { issues, refreshing, overlay, toast, activeTab } = state;
1171
+ const { displayed, selectedIndex } = currentSelected(state);
1172
+ const selected = displayed[selectedIndex] ?? null;
1173
+ const issuesTabCount = issues.reduce((n, d) => (isOrphan(d) ? n : n + 1), 0);
1174
+ const worktreesTabCount = issues.length - issuesTabCount;
1099
1175
  const onOverlayDescChange = (next) => {
1100
1176
  if (state.phase !== "ready" || !state.overlay)
1101
1177
  return;
@@ -1122,7 +1198,7 @@ export default function Dashboard() {
1122
1198
  const listWidthPct = 0.5;
1123
1199
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
1124
1200
  const detailWidth = columns - listWidth - 2; // border slack
1125
- const identifierWidth = Math.max(3, ...issues.map((d) => d.issue.id.length));
1201
+ const identifierWidth = Math.max(3, ...displayed.map((d) => d.issue.id.length));
1126
1202
  // Reserve rows: header (2), top borders (1), footer (3).
1127
1203
  const listVisibleRows = Math.max(3, rows - 9);
1128
1204
  // Detail pane content height inside the bordered box. Header eats 2 rows,
@@ -1136,8 +1212,14 @@ export default function Dashboard() {
1136
1212
  // Grouped list: build the project/status header rows interleaved with
1137
1213
  // issue rows, then split into a sticky header region (the selected issue's
1138
1214
  // project + Status, pinned to the top) and a windowed scrollable body.
1139
- const listRows = buildListRows(issues);
1215
+ // The Worktrees tab renders flat — the tab title already labels the group,
1216
+ // so the per-project headers would just be visual noise.
1217
+ const listRows = activeTab === "issues"
1218
+ ? buildListRows(displayed)
1219
+ : displayed.map((d, index) => ({ kind: "issue", d, index }));
1140
1220
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1141
1221
  const listContentWidth = Math.max(8, listWidth - 4);
1142
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion, listWidth: listWidth })] })] }));
1222
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issuesTabCount, worktreeCount: worktreesTabCount, activeTab: activeTab, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: displayed.length === 0 ? (_jsx(Text, { dimColor: true, children: activeTab === "issues"
1223
+ ? "No open issues assigned to you in this repo."
1224
+ : "No orphaned worktrees — anything in `.mintree/worktrees/` matches an open issue." })) : (_jsxs(_Fragment, { children: [listView.sticky.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `sticky-${i}`))), listView.issuesAbove > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", listView.issuesAbove, " more above"] })), listView.body.map((row, i) => (_jsx(ListRowView, { row: row, selectedIndex: selectedIndex, identifierWidth: identifierWidth, width: listContentWidth }, `body-${i}`))), listView.issuesBelow > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", listView.issuesBelow, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion, listWidth: listWidth })] })] }));
1143
1225
  }
@@ -55,19 +55,20 @@ export default function Create({ args, options }) {
55
55
  const [result, setResult] = useState(null);
56
56
  const [transition, setTransition] = useState("idle");
57
57
  useEffect(() => {
58
- setTimeout(() => {
58
+ (async () => {
59
59
  try {
60
- setResult(runCreate(branch, {
60
+ const r = await runCreate(branch, {
61
61
  base: options.base,
62
62
  work: options.work,
63
63
  prompt: options.prompt,
64
64
  permissionMode: options.permissionMode,
65
- }));
65
+ });
66
+ setResult(r);
66
67
  }
67
68
  catch (err) {
68
69
  setResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
69
70
  }
70
- }, 0);
71
+ })();
71
72
  }, [branch, options.base, options.work, options.prompt, options.permissionMode]);
72
73
  // Kick the Project v2 transition once the worktree is in place. Only when
73
74
  // --work was on — non-work creates leave status untouched. Errors from the
@@ -5,11 +5,24 @@ export type CreateStep = {
5
5
  label: string;
6
6
  detail?: string;
7
7
  };
8
+ /**
9
+ * Optional progress callbacks used by the dashboard overlay to render a
10
+ * live setup log (santree-style). `onPending(label)` highlights the
11
+ * currently running blocking operation (rendered with a spinner); call
12
+ * `onPending(null)` when it ends. `onStep(step)` appends a completed step
13
+ * to the log. Between every emission the implementation yields the event
14
+ * loop for one frame so Ink can paint before the next blocking section.
15
+ */
16
+ export type ProgressCallbacks = {
17
+ onStep?: (step: CreateStep) => void;
18
+ onPending?: (label: string | null) => void;
19
+ };
8
20
  export type CreateOpts = {
9
21
  base?: string;
10
22
  work: boolean;
11
23
  prompt?: string;
12
24
  permissionMode?: PermissionMode;
25
+ progress?: ProgressCallbacks;
13
26
  };
14
27
  export type CreateResult = {
15
28
  ok: true;
@@ -32,14 +45,18 @@ export type CreateResult = {
32
45
  * resolves a base branch, runs `git worktree add`, persists metadata, runs
33
46
  * the optional `.mintree/init.sh`, and stages the --prompt to a temp file
34
47
  * for the work hand-off when relevant.
48
+ *
49
+ * Async only because progress callbacks need event-loop yields between
50
+ * blocking sections; without them the dashboard overlay would freeze.
35
51
  */
36
- export declare function runCreate(branchArg: string, opts: CreateOpts): CreateResult;
52
+ export declare function runCreate(branchArg: string, opts: CreateOpts): Promise<CreateResult>;
37
53
  export type CreateDetachedOpts = {
38
54
  issueId: string;
39
55
  descKebab: string;
40
56
  work: boolean;
41
57
  prompt?: string;
42
58
  permissionMode?: PermissionMode;
59
+ progress?: ProgressCallbacks;
43
60
  };
44
61
  /**
45
62
  * Variant of `runCreate` that doesn't create a new branch — the worktree is
@@ -53,4 +70,4 @@ export type CreateDetachedOpts = {
53
70
  * branch-based flow so `worktree work` can still recover the issueId from
54
71
  * the dir name (where it can't read it from the branch).
55
72
  */
56
- export declare function runCreateDetached(opts: CreateDetachedOpts): CreateResult;
73
+ export declare function runCreateDetached(opts: CreateDetachedOpts): Promise<CreateResult>;
@@ -37,14 +37,26 @@ function writePromptFile(prompt) {
37
37
  fs.writeFileSync(filePath, prompt);
38
38
  return filePath;
39
39
  }
40
+ // Wait one frame (~16ms) so Ink has time to commit + paint the latest
41
+ // state before the next blocking execSync. No-op when no progress
42
+ // callbacks are set — CLI invocations skip the cost entirely.
43
+ function nextFrame(progress) {
44
+ if (!progress || (!progress.onStep && !progress.onPending))
45
+ return Promise.resolve();
46
+ return new Promise((resolve) => setTimeout(resolve, 16));
47
+ }
40
48
  /**
41
49
  * The whole `worktree create` flow as a pure function — same code path used
42
50
  * by the CLI command and by the dashboard's `w` overlay. Validates input,
43
51
  * resolves a base branch, runs `git worktree add`, persists metadata, runs
44
52
  * the optional `.mintree/init.sh`, and stages the --prompt to a temp file
45
53
  * for the work hand-off when relevant.
54
+ *
55
+ * Async only because progress callbacks need event-loop yields between
56
+ * blocking sections; without them the dashboard overlay would freeze.
46
57
  */
47
- export function runCreate(branchArg, opts) {
58
+ export async function runCreate(branchArg, opts) {
59
+ const progress = opts.progress;
48
60
  const root = findMainRepoRoot();
49
61
  if (!root) {
50
62
  return {
@@ -81,18 +93,27 @@ export function runCreate(branchArg, opts) {
81
93
  };
82
94
  }
83
95
  const steps = [];
84
- steps.push({
96
+ const pushStep = (step) => {
97
+ steps.push(step);
98
+ progress?.onStep?.(step);
99
+ };
100
+ pushStep({
85
101
  kind: "ok",
86
102
  label: "parsed branch",
87
103
  detail: `type=${parsed.type}, issue=${parsed.issueId}, desc=${parsed.desc}`,
88
104
  });
105
+ await nextFrame(progress);
89
106
  // Fetch before resolving refs so the worktree forks from fresh code, not a
90
107
  // stale local checkout. Best-effort: offline / no-remote just warns and we
91
108
  // fall back to whatever is already local.
109
+ progress?.onPending?.("Fetching origin...");
110
+ await nextFrame(progress);
92
111
  const fetch = fetchRemote(root);
93
- steps.push(fetch.ok
112
+ progress?.onPending?.(null);
113
+ pushStep(fetch.ok
94
114
  ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
95
115
  : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
116
+ await nextFrame(progress);
96
117
  const existence = branchExists(root, parsed.branch);
97
118
  let base;
98
119
  if (existence === null) {
@@ -119,10 +140,13 @@ export function runCreate(branchArg, opts) {
119
140
  if (existence === null && base && fetch.ok && remoteBranchExists(root, base)) {
120
141
  baseRef = `origin/${base}`;
121
142
  }
143
+ progress?.onPending?.("Creating worktree...");
144
+ await nextFrame(progress);
122
145
  try {
123
146
  addWorktree({ repoRoot: root, branch: parsed.branch, worktreePath, base: baseRef });
124
147
  }
125
148
  catch (err) {
149
+ progress?.onPending?.(null);
126
150
  const stderr = err && typeof err === "object" && "stderr" in err
127
151
  ? String(err.stderr).trim()
128
152
  : err instanceof Error
@@ -130,48 +154,58 @@ export function runCreate(branchArg, opts) {
130
154
  : String(err);
131
155
  return { ok: false, message: `git worktree add failed: ${stderr}` };
132
156
  }
157
+ progress?.onPending?.(null);
133
158
  if (existence === "remote") {
134
- steps.push({
159
+ pushStep({
135
160
  kind: "ok",
136
161
  label: "checked out tracking branch",
137
162
  detail: `from origin/${parsed.branch}`,
138
163
  });
139
164
  }
140
165
  else if (existence === "local") {
141
- steps.push({
166
+ pushStep({
142
167
  kind: "ok",
143
168
  label: "checked out existing local branch",
144
169
  detail: parsed.branch,
145
170
  });
146
171
  }
147
172
  else {
148
- steps.push({
173
+ pushStep({
149
174
  kind: "ok",
150
175
  label: "created new branch",
151
176
  detail: `${parsed.branch} (from ${baseRef})`,
152
177
  });
153
178
  }
154
- steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
179
+ await nextFrame(progress);
180
+ pushStep({ kind: "ok", label: "worktree created", detail: worktreePath });
181
+ await nextFrame(progress);
155
182
  upsertIssue(root, parsed.issueId, base ? { base_branch: base } : {});
156
- steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
183
+ pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${parsed.issueId}` });
184
+ await nextFrame(progress);
157
185
  const initShPath = getInitScriptPath(root);
186
+ if (pathExists(initShPath)) {
187
+ progress?.onPending?.("Running .mintree/init.sh...");
188
+ await nextFrame(progress);
189
+ }
158
190
  const initResult = tryRunInitScript(initShPath, worktreePath, root);
191
+ progress?.onPending?.(null);
159
192
  if (initResult.ran) {
160
- steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
193
+ pushStep({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
161
194
  }
162
195
  else if (initResult.error) {
163
- steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
196
+ pushStep({ kind: "warn", label: "init.sh failed", detail: initResult.error });
164
197
  }
165
198
  else if (!pathExists(initShPath)) {
166
- steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
199
+ pushStep({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
167
200
  }
201
+ await nextFrame(progress);
168
202
  let promptFile;
169
203
  if (opts.work && opts.prompt && opts.prompt.length > 0) {
170
204
  try {
171
205
  promptFile = writePromptFile(opts.prompt);
172
206
  }
173
207
  catch (err) {
174
- steps.push({
208
+ pushStep({
175
209
  kind: "warn",
176
210
  label: "failed to stage --prompt for hand-off",
177
211
  detail: err instanceof Error ? err.message : String(err),
@@ -179,7 +213,7 @@ export function runCreate(branchArg, opts) {
179
213
  }
180
214
  }
181
215
  if (!opts.work && (opts.prompt || opts.permissionMode)) {
182
- steps.push({
216
+ pushStep({
183
217
  kind: "warn",
184
218
  label: "ignoring --prompt / --permission-mode (only meaningful with --work)",
185
219
  });
@@ -208,7 +242,8 @@ export function runCreate(branchArg, opts) {
208
242
  * branch-based flow so `worktree work` can still recover the issueId from
209
243
  * the dir name (where it can't read it from the branch).
210
244
  */
211
- export function runCreateDetached(opts) {
245
+ export async function runCreateDetached(opts) {
246
+ const progress = opts.progress;
212
247
  const root = findMainRepoRoot();
213
248
  if (!root) {
214
249
  return {
@@ -255,22 +290,34 @@ export function runCreateDetached(opts) {
255
290
  };
256
291
  }
257
292
  const steps = [];
258
- steps.push({
293
+ const pushStep = (step) => {
294
+ steps.push(step);
295
+ progress?.onStep?.(step);
296
+ };
297
+ pushStep({
259
298
  kind: "ok",
260
299
  label: "detached worktree",
261
300
  detail: `issue=${opts.issueId}, base=${currentBranch}`,
262
301
  });
302
+ await nextFrame(progress);
263
303
  // Fetch so the detached worktree forks from the fresh remote tip of the
264
304
  // current branch instead of a stale local checkout. Best-effort.
305
+ progress?.onPending?.("Fetching origin...");
306
+ await nextFrame(progress);
265
307
  const fetch = fetchRemote(root);
266
- steps.push(fetch.ok
308
+ progress?.onPending?.(null);
309
+ pushStep(fetch.ok
267
310
  ? { kind: "ok", label: "fetched origin", detail: "refs up to date" }
268
311
  : { kind: "warn", label: "skipped git fetch", detail: fetch.reason });
312
+ await nextFrame(progress);
269
313
  const baseRef = fetch.ok && remoteBranchExists(root, currentBranch) ? `origin/${currentBranch}` : currentBranch;
314
+ progress?.onPending?.("Creating worktree...");
315
+ await nextFrame(progress);
270
316
  try {
271
317
  execSync(`git worktree add --detach '${worktreePath.replace(/'/g, `'\\''`)}' '${baseRef.replace(/'/g, `'\\''`)}'`, { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
272
318
  }
273
319
  catch (err) {
320
+ progress?.onPending?.(null);
274
321
  const stderr = err && typeof err === "object" && "stderr" in err
275
322
  ? String(err.stderr).trim()
276
323
  : err instanceof Error
@@ -278,32 +325,42 @@ export function runCreateDetached(opts) {
278
325
  : String(err);
279
326
  return { ok: false, message: `git worktree add --detach failed: ${stderr}` };
280
327
  }
281
- steps.push({
328
+ progress?.onPending?.(null);
329
+ pushStep({
282
330
  kind: "ok",
283
331
  label: "checked out detached HEAD",
284
332
  detail: `at tip of ${baseRef}`,
285
333
  });
286
- steps.push({ kind: "ok", label: "worktree created", detail: worktreePath });
334
+ await nextFrame(progress);
335
+ pushStep({ kind: "ok", label: "worktree created", detail: worktreePath });
336
+ await nextFrame(progress);
287
337
  upsertIssue(root, opts.issueId, { base_branch: currentBranch });
288
- steps.push({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
338
+ pushStep({ kind: "ok", label: "metadata updated", detail: `issue ${opts.issueId}` });
339
+ await nextFrame(progress);
289
340
  const initShPath = getInitScriptPath(root);
341
+ if (pathExists(initShPath)) {
342
+ progress?.onPending?.("Running .mintree/init.sh...");
343
+ await nextFrame(progress);
344
+ }
290
345
  const initResult = tryRunInitScript(initShPath, worktreePath, root);
346
+ progress?.onPending?.(null);
291
347
  if (initResult.ran) {
292
- steps.push({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
348
+ pushStep({ kind: "ok", label: "ran .mintree/init.sh", detail: worktreePath });
293
349
  }
294
350
  else if (initResult.error) {
295
- steps.push({ kind: "warn", label: "init.sh failed", detail: initResult.error });
351
+ pushStep({ kind: "warn", label: "init.sh failed", detail: initResult.error });
296
352
  }
297
353
  else if (!pathExists(initShPath)) {
298
- steps.push({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
354
+ pushStep({ kind: "skip", label: "no init.sh (skipping post-create hook)" });
299
355
  }
356
+ await nextFrame(progress);
300
357
  let promptFile;
301
358
  if (opts.work && opts.prompt && opts.prompt.length > 0) {
302
359
  try {
303
360
  promptFile = writePromptFile(opts.prompt);
304
361
  }
305
362
  catch (err) {
306
- steps.push({
363
+ pushStep({
307
364
  kind: "warn",
308
365
  label: "failed to stage --prompt for hand-off",
309
366
  detail: err instanceof Error ? err.message : String(err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Issue-driven git worktrees + Claude Code sessions for repos with an opinionated SDD+TDD flow.",
5
5
  "license": "MIT",
6
6
  "author": "Martin Mineo <mmineo@canarytechnologies.com>",