mintree 0.2.1 → 0.2.2

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.
@@ -35,20 +35,6 @@ const MOUSE_ON = "\x1b[?1002h\x1b[?1006h";
35
35
  const MOUSE_OFF = "\x1b[?1006l\x1b[?1002l";
36
36
  const MOUSE_SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
37
37
  const SCROLL_STEP = 3;
38
- function StateIcon({ state }) {
39
- if (!state)
40
- return _jsx(Text, { dimColor: true, children: "\u00B7" });
41
- switch (state) {
42
- case "active":
43
- return _jsx(Text, { color: "green", children: "\u25CF" });
44
- case "waiting":
45
- return _jsx(Text, { color: "yellow", children: "!" });
46
- case "idle":
47
- return _jsx(Text, { dimColor: true, children: "\u25CB" });
48
- case "exited":
49
- return _jsx(Text, { dimColor: true, children: "\u2014" });
50
- }
51
- }
52
38
  function truncate(s, max) {
53
39
  if (max <= 1)
54
40
  return s.slice(0, max);
@@ -165,7 +151,7 @@ function useTerminalSize() {
165
151
  function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
166
152
  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}) ` }) })] }));
167
153
  }
168
- function FooterRow({ phase, overlayKind, latestVersion, }) {
154
+ function FooterRow({ phase, overlayKind, latestVersion, listWidth, }) {
169
155
  if (phase === "error") {
170
156
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
171
157
  }
@@ -175,7 +161,13 @@ function FooterRow({ phase, overlayKind, latestVersion, }) {
175
161
  if (overlayKind === "remove") {
176
162
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
177
163
  }
178
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " delete" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
164
+ // Two-column footer like santree: common navigation/dashboard commands
165
+ // align under the left (list) pane; ticket-specific actions align under
166
+ // the right (detail) pane. Falls back to a single inline row when no
167
+ // width hint is available (e.g. the error path).
168
+ const common = (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: " scroll " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
169
+ const ticket = (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " Switch " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " w" }), _jsx(Text, { dimColor: true, children: " Work " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " o" }), _jsx(Text, { dimColor: true, children: " Open " }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { bold: true, children: " d" }), _jsx(Text, { dimColor: true, children: " Remove" })] }));
170
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: listWidth, children: common }), _jsx(Box, { flexGrow: 1, children: ticket })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
179
171
  }
180
172
  function RemoveOverlayView({ overlay }) {
181
173
  return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove 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(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
@@ -192,41 +184,33 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
192
184
  : `${overlay.issue.issue.id}-${detachedDesc}`;
193
185
  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] })] }))] }));
194
186
  }
195
- function stateChar(state) {
196
- if (!state)
197
- return "·";
198
- if (state === "active")
199
- return "●";
200
- if (state === "waiting")
201
- return "!";
202
- if (state === "idle")
203
- return "○";
204
- return "—";
205
- }
206
- function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
187
+ function IssueListRow({ d, selected, identifierWidth, rowWidth, }) {
207
188
  // Display the issue id raw (e.g. "AUTH-6", "100"). The `#` prefix is a
208
189
  // GitHub convention that reads as noise for Plane's already-prefixed
209
190
  // ids, and dropping it across the board keeps the dashboard provider-
210
191
  // agnostic.
211
192
  const idText = d.issue.id.padEnd(identifierWidth, " ");
212
- const icon = stateChar(d.session?.state ?? null);
213
- const title = truncate(d.issue.title, maxTitleWidth);
214
- // One single Text with a single string so the background highlight is
215
- // continuous across the whole row. Coloured per-state icons live in the
216
- // detail pane instead keeps the list selection visually solid. The two
217
- // leading spaces nest the row under its Status sub-header.
218
- const line = ` ${idText} ${icon} ${title}`;
219
- return (_jsx(Box, { children: _jsx(Text, { backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: line }) }));
193
+ // Status-coloured leading dot — same convention as santree. Falls back to
194
+ // gray when the issue has no project board membership.
195
+ const dotColor = d.project?.statusColor ?? "gray";
196
+ const title = d.issue.title;
197
+ // The leading-dot Text and the rest are nested under a single Text so the
198
+ // selection background paints the whole row in one contiguous block.
199
+ // `wrap="truncate"` clamps the row to a single line and Ink renders an
200
+ // ellipsis at the cut. The outer Box has a fixed width so the wrap
201
+ // behaviour knows where to truncate.
202
+ return (_jsx(Box, { width: rowWidth, children: _jsxs(Text, { wrap: "truncate", backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: [" ", _jsx(Text, { color: selected ? "white" : dotColor, children: "\u25CF" }), ` ${idText} ${title}`] }) }));
220
203
  }
221
204
  // A project board header — the top level of the grouped issue list. Mirrors
222
205
  // the bold project name + dim count seen in the santree dashboard.
223
206
  function ProjectHeaderRow({ title, count, width, }) {
224
207
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: truncate(title, Math.max(4, width - 6)) }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
225
208
  }
226
- // A Status sub-header within a project group. The bullet and name take the
227
- // colour the board itself assigned to that Status option.
209
+ // A Status sub-header within a project group. Matches santree's look: just
210
+ // the status name in its board colour, no leading bullet the bullets live
211
+ // on the individual issue rows below it.
228
212
  function StatusHeaderRow({ name, color, count, width, }) {
229
- return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: " ●" }), _jsx(Text, { bold: true, color: color, children: ` ${truncate(name, Math.max(4, width - 8))}` }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
213
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, children: ` ${truncate(name, Math.max(4, width - 6))}` }), _jsx(Text, { dimColor: true, children: ` ${count}` })] }));
230
214
  }
231
215
  /**
232
216
  * Walks the already-grouped flat issue array (loadDashboard sorts it by
@@ -366,7 +350,7 @@ function windowListRows(listRows, selectedIndex, viewportRows) {
366
350
  }
367
351
  // Renders a single grouped-list row — used for both the sticky header region
368
352
  // and the scrollable body so the two stay visually identical.
369
- function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width, }) {
353
+ function ListRowView({ row, selectedIndex, identifierWidth, width, }) {
370
354
  if (row.kind === "spacer")
371
355
  return _jsx(Text, { children: " " });
372
356
  if (row.kind === "project") {
@@ -375,7 +359,7 @@ function ListRowView({ row, selectedIndex, identifierWidth, maxTitleWidth, width
375
359
  if (row.kind === "status") {
376
360
  return _jsx(StatusHeaderRow, { name: row.name, color: row.color, count: row.count, width: width });
377
361
  }
378
- return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }));
362
+ return (_jsx(IssueListRow, { d: row.d, selected: row.index === selectedIndex, identifierWidth: identifierWidth, rowWidth: width }));
379
363
  }
380
364
  // Word-wraps a single line at `width` columns, breaking on the last space
381
365
  // before the limit when that yields a reasonable cut. Falls back to a hard
@@ -830,7 +814,9 @@ export default function Dashboard() {
830
814
  }
831
815
  if (input === "o") {
832
816
  const issue = state.issues[state.selectedIndex];
833
- if (issue)
817
+ // Orphan rows carry an empty URL — nothing to open. Skip silently
818
+ // rather than asking the OS to open an empty string.
819
+ if (issue && issue.issue.url)
834
820
  openInBrowser(issue.issue.url);
835
821
  return;
836
822
  }
@@ -1104,15 +1090,13 @@ export default function Dashboard() {
1104
1090
  overlay: { ...state.overlay, prompt: next, error: null },
1105
1091
  });
1106
1092
  };
1107
- // Left pane is the issue list — it only needs room for "#N ICON title".
1108
- // We give it ~40% of the width so the detail pane (URLs, descriptions,
1109
- // branch paths) has the room it actually needs.
1110
- const listWidthPct = 0.4;
1093
+ // Left pane is the issue list — santree gives it ~half the width and the
1094
+ // detail pane still has room for URLs, descriptions and branch paths
1095
+ // because long lines wrap within the pane.
1096
+ const listWidthPct = 0.5;
1111
1097
  const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
1112
1098
  const detailWidth = columns - listWidth - 2; // border slack
1113
1099
  const identifierWidth = Math.max(3, ...issues.map((d) => d.issue.id.length));
1114
- // Lista ocupa todo menos: " #N ICON " (2-space nest indent + id + icon).
1115
- const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 9);
1116
1100
  // Reserve rows: header (2), top borders (1), footer (3).
1117
1101
  const listVisibleRows = Math.max(3, rows - 9);
1118
1102
  // Detail pane content height inside the bordered box. Header eats 2 rows,
@@ -1129,5 +1113,5 @@ export default function Dashboard() {
1129
1113
  const listRows = buildListRows(issues);
1130
1114
  const listView = windowListRows(listRows, selectedIndex, listVisibleRows);
1131
1115
  const listContentWidth = Math.max(8, listWidth - 4);
1132
- 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, maxTitleWidth: maxTitleWidth, 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, maxTitleWidth: maxTitleWidth, 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 })] })] }));
1116
+ 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 })] })] }));
1133
1117
  }
@@ -22,6 +22,7 @@ export type DashboardIssue = {
22
22
  session: SessionStateInfo | null;
23
23
  pr: PrInfo | null;
24
24
  project: IssueProjectInfo | null;
25
+ orphan?: boolean;
25
26
  };
26
27
  /**
27
28
  * Top-level loader: enriches each assigned issue with its worktree and
@@ -69,19 +69,22 @@ function isSessionState(v) {
69
69
  * its group headers by walking this already-ordered array.
70
70
  *
71
71
  * Project group order: a config-pinned project first, then other projects
72
- * alphabetically, then issues with no project ("Sin proyecto") last.
72
+ * alphabetically, then issues with no project ("Sin proyecto"), then orphan
73
+ * worktrees last.
73
74
  */
74
75
  function sortGroupedIssues(issues, configuredUrl) {
75
- const projectTier = (p) => {
76
- if (!p)
76
+ const projectTier = (d) => {
77
+ if (d.orphan)
78
+ return 3;
79
+ if (!d.project)
77
80
  return 2;
78
- if (configuredUrl && p.projectUrl === configuredUrl)
81
+ if (configuredUrl && d.project.projectUrl === configuredUrl)
79
82
  return 0;
80
83
  return 1;
81
84
  };
82
85
  return [...issues].sort((a, b) => {
83
- const ta = projectTier(a.project);
84
- const tb = projectTier(b.project);
86
+ const ta = projectTier(a);
87
+ const tb = projectTier(b);
85
88
  if (ta !== tb)
86
89
  return ta - tb;
87
90
  if (a.project && b.project) {
@@ -102,6 +105,57 @@ function sortGroupedIssues(issues, configuredUrl) {
102
105
  return b.issue.id.localeCompare(a.issue.id);
103
106
  });
104
107
  }
108
+ /**
109
+ * Synthetic DashboardIssue rows for worktrees on disk whose issueId isn't in
110
+ * the provider's assigned-issues list. These end up grouped under "Orphaned
111
+ * Worktrees" at the bottom of the dashboard so the user can find and `d`elete
112
+ * them.
113
+ *
114
+ * The `issue` stub uses the worktree directory name as the title (the part
115
+ * after the issue id, e.g. "claude-md-inicial") so the row is identifiable
116
+ * even when there's no live issue to fetch a title from.
117
+ */
118
+ function buildOrphanRows(worktreesByIssue, assignedIds, sessionLookup, prByBranch, metadataSessionId) {
119
+ const orphans = [];
120
+ for (const [issueId, w] of worktreesByIssue) {
121
+ if (assignedIds.has(issueId))
122
+ continue;
123
+ const dirName = path.basename(w.path);
124
+ // Strip the leading "<issueId>-" — that leaves the kebab description
125
+ // that originally seeded the branch name.
126
+ const desc = dirName.startsWith(`${issueId}-`)
127
+ ? dirName.slice(issueId.length + 1)
128
+ : dirName;
129
+ const sessionId = metadataSessionId(issueId);
130
+ const worktree = { ...w, sessionId };
131
+ const pr = w.branch ? (prByBranch.get(w.branch) ?? null) : null;
132
+ orphans.push({
133
+ issue: {
134
+ id: issueId,
135
+ title: desc || dirName,
136
+ state: "UNKNOWN",
137
+ url: "",
138
+ labels: [],
139
+ body: "",
140
+ createdAt: "",
141
+ updatedAt: "",
142
+ },
143
+ worktree,
144
+ session: sessionLookup(issueId),
145
+ pr,
146
+ project: {
147
+ projectTitle: "Orphaned Worktrees",
148
+ projectUrl: "",
149
+ projectNumber: 0,
150
+ status: "Orphaned",
151
+ statusColor: "gray",
152
+ statusOrder: 9999,
153
+ },
154
+ orphan: true,
155
+ });
156
+ }
157
+ return orphans;
158
+ }
105
159
  /**
106
160
  * Top-level loader: enriches each assigned issue with its worktree and
107
161
  * session snapshot. Designed to be called on dashboard mount and on every
@@ -152,5 +206,7 @@ export async function loadDashboard(repoRoot) {
152
206
  project: projectByIssue.get(issue.id) ?? null,
153
207
  };
154
208
  });
155
- return sortGroupedIssues(enriched, configuredUrl);
209
+ const assignedIds = new Set(issues.map((i) => i.id));
210
+ const orphans = buildOrphanRows(worktreesByIssue, assignedIds, (id) => readSessionState(repoRoot, id), prByBranch, (id) => metadata.issues[id]?.session_id);
211
+ return sortGroupedIssues([...enriched, ...orphans], configuredUrl);
156
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mintree",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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>",