mintree 0.1.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.
- package/README.md +188 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +849 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +327 -0
- package/dist/commands/helpers/index.d.ts +1 -0
- package/dist/commands/helpers/index.js +1 -0
- package/dist/commands/helpers/session-signal/end.d.ts +2 -0
- package/dist/commands/helpers/session-signal/end.js +9 -0
- package/dist/commands/helpers/session-signal/index.d.ts +1 -0
- package/dist/commands/helpers/session-signal/index.js +1 -0
- package/dist/commands/helpers/session-signal/install.d.ts +2 -0
- package/dist/commands/helpers/session-signal/install.js +25 -0
- package/dist/commands/helpers/session-signal/notification.d.ts +2 -0
- package/dist/commands/helpers/session-signal/notification.js +9 -0
- package/dist/commands/helpers/session-signal/prompt.d.ts +2 -0
- package/dist/commands/helpers/session-signal/prompt.js +9 -0
- package/dist/commands/helpers/session-signal/stop.d.ts +2 -0
- package/dist/commands/helpers/session-signal/stop.js +9 -0
- package/dist/commands/helpers/shell-init.d.ts +11 -0
- package/dist/commands/helpers/shell-init.js +111 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +6 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/worktree/clean.d.ts +11 -0
- package/dist/commands/worktree/clean.js +206 -0
- package/dist/commands/worktree/create.d.ts +18 -0
- package/dist/commands/worktree/create.js +93 -0
- package/dist/commands/worktree/index.d.ts +1 -0
- package/dist/commands/worktree/index.js +1 -0
- package/dist/commands/worktree/list.d.ts +10 -0
- package/dist/commands/worktree/list.js +143 -0
- package/dist/commands/worktree/remove.d.ts +12 -0
- package/dist/commands/worktree/remove.js +46 -0
- package/dist/commands/worktree/work.d.ts +15 -0
- package/dist/commands/worktree/work.js +192 -0
- package/dist/lib/branch.d.ts +26 -0
- package/dist/lib/branch.js +57 -0
- package/dist/lib/claude.d.ts +26 -0
- package/dist/lib/claude.js +67 -0
- package/dist/lib/dashboard.d.ts +50 -0
- package/dist/lib/dashboard.js +139 -0
- package/dist/lib/exec.d.ts +2 -0
- package/dist/lib/exec.js +15 -0
- package/dist/lib/git.d.ts +110 -0
- package/dist/lib/git.js +320 -0
- package/dist/lib/github.d.ts +7 -0
- package/dist/lib/github.js +15 -0
- package/dist/lib/markers.d.ts +21 -0
- package/dist/lib/markers.js +43 -0
- package/dist/lib/metadata.d.ts +18 -0
- package/dist/lib/metadata.js +44 -0
- package/dist/lib/session-signal.d.ts +63 -0
- package/dist/lib/session-signal.js +160 -0
- package/dist/lib/worktreeCreate.d.ts +36 -0
- package/dist/lib/worktreeCreate.js +184 -0
- package/dist/lib/worktreeRemove.d.ts +21 -0
- package/dist/lib/worktreeRemove.js +84 -0
- package/package.json +63 -0
- package/shell/init.bash +106 -0
- package/shell/init.zsh +125 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import Spinner from "ink-spinner";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import { findBranchConventionDoc, findMainRepoRoot, getMintreeDir, pathExists, } from "../lib/git.js";
|
|
9
|
+
import { resolveClaudeBinary } from "../lib/claude.js";
|
|
10
|
+
import { tryExec } from "../lib/exec.js";
|
|
11
|
+
import { ALLOWED_TYPES } from "../lib/branch.js";
|
|
12
|
+
import { runCreate } from "../lib/worktreeCreate.js";
|
|
13
|
+
import { runRemove } from "../lib/worktreeRemove.js";
|
|
14
|
+
import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
|
|
15
|
+
import { loadDashboard, } from "../lib/dashboard.js";
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const { version: mintreeVersion } = require("../../package.json");
|
|
18
|
+
export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
|
|
19
|
+
// xterm/iTerm/etc switch to the alternate screen buffer with these escape
|
|
20
|
+
// codes. Using the buffer means the dashboard owns the whole window for its
|
|
21
|
+
// lifetime, and the previous shell content reappears unchanged the moment
|
|
22
|
+
// we switch back. ALT_SCREEN_ENTER also homes the cursor so the first Ink
|
|
23
|
+
// render starts at row 1.
|
|
24
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h\x1b[H";
|
|
25
|
+
const ALT_SCREEN_LEAVE = "\x1b[?1049l";
|
|
26
|
+
// SGR mouse tracking — \x1b[?1002h enables button-event tracking (press,
|
|
27
|
+
// release, drag, wheel); \x1b[?1006h switches reports to the SGR extended
|
|
28
|
+
// format \x1b[<button;col;row(M|m), which works past col/row 223 and is
|
|
29
|
+
// what we parse below. We only consume wheel events; press/release fall
|
|
30
|
+
// through harmlessly.
|
|
31
|
+
const MOUSE_ON = "\x1b[?1002h\x1b[?1006h";
|
|
32
|
+
const MOUSE_OFF = "\x1b[?1006l\x1b[?1002l";
|
|
33
|
+
const MOUSE_SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
34
|
+
const SCROLL_STEP = 3;
|
|
35
|
+
function StateIcon({ state }) {
|
|
36
|
+
if (!state)
|
|
37
|
+
return _jsx(Text, { dimColor: true, children: "\u00B7" });
|
|
38
|
+
switch (state) {
|
|
39
|
+
case "active":
|
|
40
|
+
return _jsx(Text, { color: "green", children: "\u25CF" });
|
|
41
|
+
case "waiting":
|
|
42
|
+
return _jsx(Text, { color: "yellow", children: "!" });
|
|
43
|
+
case "idle":
|
|
44
|
+
return _jsx(Text, { dimColor: true, children: "\u25CB" });
|
|
45
|
+
case "exited":
|
|
46
|
+
return _jsx(Text, { dimColor: true, children: "\u2014" });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function truncate(s, max) {
|
|
50
|
+
if (max <= 1)
|
|
51
|
+
return s.slice(0, max);
|
|
52
|
+
if (s.length <= max)
|
|
53
|
+
return s;
|
|
54
|
+
return s.slice(0, max - 1) + "…";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Compact "X ago" formatter for ISO 8601 timestamps. Returns "—" for
|
|
58
|
+
* unparseable input so callers can use it directly in JSX without needing
|
|
59
|
+
* to handle the missing/invalid case themselves.
|
|
60
|
+
*/
|
|
61
|
+
function relativeTime(iso) {
|
|
62
|
+
if (!iso)
|
|
63
|
+
return "—";
|
|
64
|
+
const t = Date.parse(iso);
|
|
65
|
+
if (Number.isNaN(t))
|
|
66
|
+
return "—";
|
|
67
|
+
const seconds = Math.floor((Date.now() - t) / 1000);
|
|
68
|
+
if (seconds < 60)
|
|
69
|
+
return `${seconds}s ago`;
|
|
70
|
+
const minutes = Math.floor(seconds / 60);
|
|
71
|
+
if (minutes < 60)
|
|
72
|
+
return `${minutes}m ago`;
|
|
73
|
+
const hours = Math.floor(minutes / 60);
|
|
74
|
+
if (hours < 24)
|
|
75
|
+
return `${hours}h ago`;
|
|
76
|
+
const days = Math.floor(hours / 24);
|
|
77
|
+
if (days < 30)
|
|
78
|
+
return `${days}d ago`;
|
|
79
|
+
const months = Math.floor(days / 30);
|
|
80
|
+
if (months < 12)
|
|
81
|
+
return `${months}mo ago`;
|
|
82
|
+
const years = Math.floor(days / 365);
|
|
83
|
+
return `${years}y ago`;
|
|
84
|
+
}
|
|
85
|
+
// Default cap on the number of words mintree pulls from the issue title to
|
|
86
|
+
// build the suggested branch description. Five words matches the typical
|
|
87
|
+
// "<type>/<issue>-<short-kebab-desc>" convention found in most projects.
|
|
88
|
+
// The user can always extend the desc by hand in the overlay.
|
|
89
|
+
const SUGGESTED_DESC_MAX_WORDS = 5;
|
|
90
|
+
/**
|
|
91
|
+
* Suggests a default kebab-case description from an issue title. Strips
|
|
92
|
+
* non-ascii / punctuation, collapses whitespace, and caps at SUGGESTED_DESC
|
|
93
|
+
* _MAX_WORDS so a verbose title doesn't produce an unreadable branch name.
|
|
94
|
+
*/
|
|
95
|
+
function kebabize(title) {
|
|
96
|
+
return title
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.normalize("NFD")
|
|
99
|
+
.replace(/[̀-ͯ]/g, "") // strip diacritics
|
|
100
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
101
|
+
.split(/\s+/)
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.slice(0, SUGGESTED_DESC_MAX_WORDS)
|
|
104
|
+
.join("-")
|
|
105
|
+
.replace(/-+/g, "-")
|
|
106
|
+
.replace(/^-+|-+$/g, "");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Sanitises whatever the user typed into the desc field on every keystroke.
|
|
110
|
+
* Same rules as kebabize but without the word cap — this is for live input.
|
|
111
|
+
*/
|
|
112
|
+
function sanitizeDesc(value) {
|
|
113
|
+
return value
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.normalize("NFD")
|
|
116
|
+
.replace(/[̀-ͯ]/g, "")
|
|
117
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
118
|
+
.replace(/-+/g, "-")
|
|
119
|
+
.replace(/^-+/, "");
|
|
120
|
+
}
|
|
121
|
+
function openInBrowser(url) {
|
|
122
|
+
try {
|
|
123
|
+
const cmd = process.platform === "darwin"
|
|
124
|
+
? "open"
|
|
125
|
+
: process.platform === "win32"
|
|
126
|
+
? "start"
|
|
127
|
+
: "xdg-open";
|
|
128
|
+
execSync(`${cmd} ${shQuote(url)}`, { stdio: "ignore" });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function shQuote(value) {
|
|
136
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
137
|
+
}
|
|
138
|
+
function useTerminalSize() {
|
|
139
|
+
const { stdout } = useStdout();
|
|
140
|
+
const [size, setSize] = useState({
|
|
141
|
+
columns: stdout?.columns ?? 100,
|
|
142
|
+
rows: stdout?.rows ?? 24,
|
|
143
|
+
});
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!stdout)
|
|
146
|
+
return;
|
|
147
|
+
const onResize = () => setSize({ columns: stdout.columns ?? 100, rows: stdout.rows ?? 24 });
|
|
148
|
+
stdout.on("resize", onResize);
|
|
149
|
+
return () => {
|
|
150
|
+
stdout.off("resize", onResize);
|
|
151
|
+
};
|
|
152
|
+
}, [stdout]);
|
|
153
|
+
return size;
|
|
154
|
+
}
|
|
155
|
+
function HeaderRow({ repoName, claudeVersion, issueCount, }) {
|
|
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}` }), 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}) ` }) })] }));
|
|
157
|
+
}
|
|
158
|
+
function FooterRow({ phase, overlayKind, }) {
|
|
159
|
+
if (phase === "error") {
|
|
160
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
|
|
161
|
+
}
|
|
162
|
+
if (overlayKind === "create") {
|
|
163
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " change type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
|
|
164
|
+
}
|
|
165
|
+
if (overlayKind === "remove") {
|
|
166
|
+
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" })] }));
|
|
167
|
+
}
|
|
168
|
+
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: " remove" })] }) }), _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" })] })] }));
|
|
169
|
+
}
|
|
170
|
+
function RemoveOverlayView({ overlay }) {
|
|
171
|
+
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.number}` })] }), _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 })] }), _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] }) }))] }));
|
|
172
|
+
}
|
|
173
|
+
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
174
|
+
const labelWidth = 14;
|
|
175
|
+
const branchPreview = `${overlay.type}/${overlay.issue.issue.number}-${overlay.desc}`;
|
|
176
|
+
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.number}` })] }), _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 === "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: "(optional) initial message for Claude" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(optional — 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: " Branch:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _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: [_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."] }), 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] }) }))] }));
|
|
177
|
+
}
|
|
178
|
+
function stateChar(state) {
|
|
179
|
+
if (!state)
|
|
180
|
+
return "·";
|
|
181
|
+
if (state === "active")
|
|
182
|
+
return "●";
|
|
183
|
+
if (state === "waiting")
|
|
184
|
+
return "!";
|
|
185
|
+
if (state === "idle")
|
|
186
|
+
return "○";
|
|
187
|
+
return "—";
|
|
188
|
+
}
|
|
189
|
+
function IssueListRow({ d, selected, identifierWidth, maxTitleWidth, }) {
|
|
190
|
+
const idText = `#${d.issue.number}`.padEnd(identifierWidth, " ");
|
|
191
|
+
const icon = stateChar(d.session?.state ?? null);
|
|
192
|
+
const title = truncate(d.issue.title, maxTitleWidth);
|
|
193
|
+
// One single Text with a single string so the background highlight is
|
|
194
|
+
// continuous across the whole row. Coloured per-state icons live in the
|
|
195
|
+
// detail pane instead — keeps the list selection visually solid.
|
|
196
|
+
const line = ` ${idText} ${icon} ${title}`;
|
|
197
|
+
return (_jsx(Box, { children: _jsx(Text, { backgroundColor: selected ? "blue" : undefined, color: selected ? "white" : undefined, children: line }) }));
|
|
198
|
+
}
|
|
199
|
+
// Word-wraps a single line at `width` columns, breaking on the last space
|
|
200
|
+
// before the limit when that yields a reasonable cut. Falls back to a hard
|
|
201
|
+
// cut for unbroken runs (long URLs, code-fence content) so the detail pane
|
|
202
|
+
// width is never exceeded. An empty input returns [""] so blank lines round-
|
|
203
|
+
// trip through the wrapper.
|
|
204
|
+
function wrapLine(s, width) {
|
|
205
|
+
if (width <= 0)
|
|
206
|
+
return [s];
|
|
207
|
+
if (s.length <= width)
|
|
208
|
+
return [s];
|
|
209
|
+
const out = [];
|
|
210
|
+
let rest = s;
|
|
211
|
+
while (rest.length > width) {
|
|
212
|
+
const space = rest.lastIndexOf(" ", width);
|
|
213
|
+
const cut = space > Math.floor(width * 0.4) ? space : width;
|
|
214
|
+
out.push(rest.slice(0, cut));
|
|
215
|
+
rest = rest.slice(cut).replace(/^ +/, "");
|
|
216
|
+
}
|
|
217
|
+
if (rest.length > 0)
|
|
218
|
+
out.push(rest);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
// Wraps a markdown-ish body to fit `width`, preserving paragraph breaks
|
|
222
|
+
// (consecutive empty lines collapse to one) and trimming leading/trailing
|
|
223
|
+
// blank lines. Used to feed the description into the flat-line renderer.
|
|
224
|
+
function wrapBody(body, width) {
|
|
225
|
+
const raw = body.replace(/\r\n/g, "\n").split("\n").map(l => l.trimEnd());
|
|
226
|
+
while (raw.length > 0 && raw[0] === "")
|
|
227
|
+
raw.shift();
|
|
228
|
+
while (raw.length > 0 && raw[raw.length - 1] === "")
|
|
229
|
+
raw.pop();
|
|
230
|
+
const out = [];
|
|
231
|
+
let lastBlank = false;
|
|
232
|
+
for (const l of raw) {
|
|
233
|
+
if (l === "") {
|
|
234
|
+
if (!lastBlank)
|
|
235
|
+
out.push("");
|
|
236
|
+
lastBlank = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
lastBlank = false;
|
|
240
|
+
for (const w of wrapLine(l, Math.max(1, width)))
|
|
241
|
+
out.push(w);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
function sessionIconColor(state) {
|
|
246
|
+
switch (state) {
|
|
247
|
+
case "active":
|
|
248
|
+
return { text: "●", color: "green" };
|
|
249
|
+
case "waiting":
|
|
250
|
+
return { text: "!", color: "yellow" };
|
|
251
|
+
case "idle":
|
|
252
|
+
return { text: "○", dim: true };
|
|
253
|
+
case "exited":
|
|
254
|
+
return { text: "—", dim: true };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Builds the detail pane as a flat array of styled lines so the renderer
|
|
258
|
+
// can slice by `scrollOffset` and the user can scroll the entire pane (not
|
|
259
|
+
// just the description). Word-wraps the issue body and title at `width` so
|
|
260
|
+
// long content stays inside the pane instead of getting truncated with "…".
|
|
261
|
+
function buildDetailLines(d, width) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
const blank = () => [{ text: " " }];
|
|
264
|
+
const w = Math.max(20, width);
|
|
265
|
+
const titlePrefix = `#${d.issue.number} `;
|
|
266
|
+
const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
|
|
267
|
+
titleWrapped.forEach((chunk, i) => {
|
|
268
|
+
if (i === 0) {
|
|
269
|
+
lines.push([{ text: titlePrefix, bold: true }, { text: chunk, bold: true }]);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
lines.push([{ text: " ".repeat(titlePrefix.length) + chunk, bold: true }]);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
const labels = d.issue.labels.map(l => l.name);
|
|
276
|
+
const labelText = labels.length > 0 ? labels.map(l => `[${l}]`).join(" ") : "(no labels)";
|
|
277
|
+
for (const w2 of wrapLine(labelText, w))
|
|
278
|
+
lines.push([{ text: w2, dim: true }]);
|
|
279
|
+
lines.push([
|
|
280
|
+
{
|
|
281
|
+
text: `updated ${relativeTime(d.issue.updatedAt)} · created ${relativeTime(d.issue.createdAt)}`,
|
|
282
|
+
dim: true,
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
lines.push(blank());
|
|
286
|
+
for (const u of wrapLine(d.issue.url, w))
|
|
287
|
+
lines.push([{ text: u, dim: true }]);
|
|
288
|
+
const body = (d.issue.body ?? "").trim();
|
|
289
|
+
if (body.length > 0) {
|
|
290
|
+
lines.push(blank());
|
|
291
|
+
lines.push([{ text: "📝 Description", bold: true }]);
|
|
292
|
+
for (const bl of wrapBody(body, w - 1)) {
|
|
293
|
+
lines.push([{ text: bl ? ` ${bl}` : " ", dim: true }]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
lines.push(blank());
|
|
297
|
+
lines.push([{ text: "⌥ Worktree", bold: true }]);
|
|
298
|
+
if (d.worktree) {
|
|
299
|
+
for (const w2 of wrapLine(` branch: ${d.worktree.branch}`, w))
|
|
300
|
+
lines.push([{ text: w2, dim: true }]);
|
|
301
|
+
for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
|
|
302
|
+
lines.push([{ text: w2, dim: true }]);
|
|
303
|
+
const statusLine = [{ text: ` status: `, dim: true }];
|
|
304
|
+
statusLine.push(d.worktree.dirty
|
|
305
|
+
? { text: "dirty", color: "yellow" }
|
|
306
|
+
: { text: "clean", color: "green" });
|
|
307
|
+
if (d.worktree.ab) {
|
|
308
|
+
statusLine.push({
|
|
309
|
+
text: ` +${d.worktree.ab.ahead} / -${d.worktree.ab.behind}`,
|
|
310
|
+
dim: true,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
lines.push(statusLine);
|
|
314
|
+
if (d.worktree.sessionId) {
|
|
315
|
+
lines.push([{ text: ` session: ${d.worktree.sessionId.slice(0, 8)}…`, dim: true }]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
lines.push([{ text: " no worktree for this issue", dim: true }]);
|
|
320
|
+
}
|
|
321
|
+
lines.push(blank());
|
|
322
|
+
lines.push([{ text: "● Pull Request", bold: true }]);
|
|
323
|
+
if (d.pr) {
|
|
324
|
+
const stateColor = d.pr.state === "OPEN" ? "green" : d.pr.state === "MERGED" ? "magenta" : "yellow";
|
|
325
|
+
lines.push([
|
|
326
|
+
{ text: ` #${d.pr.number} `, dim: true },
|
|
327
|
+
{ text: d.pr.state, color: stateColor },
|
|
328
|
+
]);
|
|
329
|
+
for (const w2 of wrapLine(` ${d.pr.url}`, w))
|
|
330
|
+
lines.push([{ text: w2, dim: true }]);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
lines.push([{ text: " no PR yet", dim: true }]);
|
|
334
|
+
}
|
|
335
|
+
lines.push(blank());
|
|
336
|
+
lines.push([{ text: "◌ Session", bold: true }]);
|
|
337
|
+
if (d.session) {
|
|
338
|
+
const ic = sessionIconColor(d.session.state);
|
|
339
|
+
lines.push([
|
|
340
|
+
{ text: ` state: `, dim: true },
|
|
341
|
+
{ text: ic.text, color: ic.color, dim: ic.dim },
|
|
342
|
+
{ text: ` ${d.session.state}`, dim: true },
|
|
343
|
+
]);
|
|
344
|
+
if (d.session.message) {
|
|
345
|
+
for (const w2 of wrapLine(` message: ${d.session.message}`, w))
|
|
346
|
+
lines.push([{ text: w2, dim: true }]);
|
|
347
|
+
}
|
|
348
|
+
if (d.session.at)
|
|
349
|
+
lines.push([{ text: ` at: ${d.session.at}`, dim: true }]);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
lines.push([{ text: " no live session signal", dim: true }]);
|
|
353
|
+
}
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
function DetailPane({ d, contentWidth, contentHeight, scrollOffset, }) {
|
|
357
|
+
if (!d) {
|
|
358
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No issue selected." }) }));
|
|
359
|
+
}
|
|
360
|
+
const lines = buildDetailLines(d, contentWidth);
|
|
361
|
+
const totalLines = lines.length;
|
|
362
|
+
const canScroll = totalLines > contentHeight;
|
|
363
|
+
// Reserve last row for the scroll hint when overflow exists.
|
|
364
|
+
const visibleHeight = Math.max(1, canScroll ? contentHeight - 1 : contentHeight);
|
|
365
|
+
const maxOffset = Math.max(0, totalLines - visibleHeight);
|
|
366
|
+
const offset = Math.min(Math.max(0, scrollOffset), maxOffset);
|
|
367
|
+
const visible = lines.slice(offset, offset + visibleHeight);
|
|
368
|
+
let scrollHint = null;
|
|
369
|
+
if (canScroll) {
|
|
370
|
+
const atTop = offset === 0;
|
|
371
|
+
const atBottom = offset + visibleHeight >= totalLines;
|
|
372
|
+
const range = `(${offset + 1}-${Math.min(offset + visibleHeight, totalLines)} / ${totalLines})`;
|
|
373
|
+
const arrows = atTop ? "↓" : atBottom ? "↑" : "↑↓";
|
|
374
|
+
scrollHint = `${arrows} scroll ${range}`;
|
|
375
|
+
}
|
|
376
|
+
return (_jsxs(Box, { flexDirection: "column", children: [visible.map((segs, i) => (_jsx(Box, { children: _jsx(Text, { children: segs.map((seg, j) => (_jsx(Text, { color: seg.color, bold: seg.bold, dimColor: seg.dim, children: seg.text }, j))) }) }, i))), scrollHint && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollHint }) }))] }));
|
|
377
|
+
}
|
|
378
|
+
export default function Dashboard() {
|
|
379
|
+
const { exit } = useApp();
|
|
380
|
+
const [state, setState] = useState({ phase: "loading" });
|
|
381
|
+
const [repoName, setRepoName] = useState(null);
|
|
382
|
+
const [claudeVersion, setClaudeVersion] = useState(null);
|
|
383
|
+
const { columns, rows } = useTerminalSize();
|
|
384
|
+
// Switch to the alt-screen buffer once, synchronously, on the first render
|
|
385
|
+
// pass. Doing this here (instead of inside a useEffect) is what makes the
|
|
386
|
+
// loading state already write into the alt-screen — useEffect only fires
|
|
387
|
+
// after the first commit, which leaves "Loading..." stranded on the parent
|
|
388
|
+
// buffer when the dashboard exits. The ref keeps it idempotent.
|
|
389
|
+
const altScreenEntered = useRef(false);
|
|
390
|
+
if (!altScreenEntered.current) {
|
|
391
|
+
process.stdout.write(ALT_SCREEN_ENTER);
|
|
392
|
+
altScreenEntered.current = true;
|
|
393
|
+
}
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
return () => {
|
|
396
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
397
|
+
};
|
|
398
|
+
}, []);
|
|
399
|
+
// Live value for the mouse handler (mounted once) to read without
|
|
400
|
+
// re-binding on every resize.
|
|
401
|
+
const listWidthRef = useRef(0);
|
|
402
|
+
const refresh = async () => {
|
|
403
|
+
const root = findMainRepoRoot();
|
|
404
|
+
if (!root) {
|
|
405
|
+
setState({
|
|
406
|
+
phase: "error",
|
|
407
|
+
message: "Not in a git repository.",
|
|
408
|
+
hint: "Run `git init` and then `mintree init`.",
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!pathExists(getMintreeDir(root))) {
|
|
413
|
+
setState({
|
|
414
|
+
phase: "error",
|
|
415
|
+
message: ".mintree/ not found in this repo.",
|
|
416
|
+
hint: "Run `mintree init` first.",
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const issues = await loadDashboard(root);
|
|
421
|
+
if (!issues) {
|
|
422
|
+
setState({
|
|
423
|
+
phase: "error",
|
|
424
|
+
message: "Could not fetch issues from GitHub.",
|
|
425
|
+
hint: "Check `mintree doctor` — gh must be authenticated and the repo must live on GitHub.",
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
setState(prev => {
|
|
430
|
+
const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
|
|
431
|
+
const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
|
|
432
|
+
const previousToast = prev.phase === "ready" ? prev.toast : null;
|
|
433
|
+
const previousScroll = prev.phase === "ready" ? prev.detailScrollOffset : 0;
|
|
434
|
+
const clamped = Math.min(previousIndex, Math.max(0, issues.length - 1));
|
|
435
|
+
// Preserve scroll only when the selected issue stayed put — clamping
|
|
436
|
+
// to a different row means the user is now reading something else.
|
|
437
|
+
const detailScrollOffset = clamped === previousIndex ? previousScroll : 0;
|
|
438
|
+
return {
|
|
439
|
+
phase: "ready",
|
|
440
|
+
issues,
|
|
441
|
+
selectedIndex: clamped,
|
|
442
|
+
detailScrollOffset,
|
|
443
|
+
refreshing: false,
|
|
444
|
+
overlay: previousOverlay,
|
|
445
|
+
toast: previousToast,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
void refresh();
|
|
451
|
+
// Cheap meta-info for the header row, fetched once on mount.
|
|
452
|
+
(async () => {
|
|
453
|
+
const repo = await tryExec("gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null");
|
|
454
|
+
setRepoName(repo);
|
|
455
|
+
const bin = resolveClaudeBinary();
|
|
456
|
+
if (bin) {
|
|
457
|
+
const v = await tryExec(`"${bin}" --version 2>/dev/null | head -1`);
|
|
458
|
+
if (v) {
|
|
459
|
+
const m = v.match(/([\d.]+)/);
|
|
460
|
+
setClaudeVersion(m && m[1] ? m[1] : v);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
})();
|
|
464
|
+
}, []);
|
|
465
|
+
// SGR mouse tracking: enable on mount, disable on unmount, and route
|
|
466
|
+
// wheel events. Press/release/drag are ignored — we only care about
|
|
467
|
+
// scroll. Wheel button 64 = up, 65 = down.
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
if (!process.stdin.isTTY)
|
|
470
|
+
return;
|
|
471
|
+
process.stdout.write(MOUSE_ON);
|
|
472
|
+
const onData = (data) => {
|
|
473
|
+
const str = data.toString("utf-8");
|
|
474
|
+
MOUSE_SGR_RE.lastIndex = 0;
|
|
475
|
+
let match;
|
|
476
|
+
while ((match = MOUSE_SGR_RE.exec(str)) !== null) {
|
|
477
|
+
if (match[4] !== "M")
|
|
478
|
+
continue;
|
|
479
|
+
const button = parseInt(match[1], 10);
|
|
480
|
+
if (button !== 64 && button !== 65)
|
|
481
|
+
continue;
|
|
482
|
+
const col = parseInt(match[2], 10);
|
|
483
|
+
const delta = button === 65 ? SCROLL_STEP : -SCROLL_STEP;
|
|
484
|
+
const lw = listWidthRef.current;
|
|
485
|
+
const inLeftPane = col <= lw;
|
|
486
|
+
// Functional setState so a fast wheel doesn't read stale
|
|
487
|
+
// scroll/selection through the ref between dispatches.
|
|
488
|
+
setState(prev => {
|
|
489
|
+
if (prev.phase !== "ready")
|
|
490
|
+
return prev;
|
|
491
|
+
if (prev.overlay)
|
|
492
|
+
return prev; // overlay pauses scroll routing
|
|
493
|
+
if (inLeftPane) {
|
|
494
|
+
const next = Math.max(0, Math.min(prev.issues.length - 1, prev.selectedIndex + delta));
|
|
495
|
+
if (next === prev.selectedIndex)
|
|
496
|
+
return prev;
|
|
497
|
+
return { ...prev, selectedIndex: next, detailScrollOffset: 0 };
|
|
498
|
+
}
|
|
499
|
+
const next = Math.max(0, prev.detailScrollOffset + delta);
|
|
500
|
+
if (next === prev.detailScrollOffset)
|
|
501
|
+
return prev;
|
|
502
|
+
return { ...prev, detailScrollOffset: next };
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
process.stdin.on("data", onData);
|
|
507
|
+
return () => {
|
|
508
|
+
process.stdout.write(MOUSE_OFF);
|
|
509
|
+
process.stdin.removeListener("data", onData);
|
|
510
|
+
};
|
|
511
|
+
}, []);
|
|
512
|
+
// While the create overlay is open, ink-text-input owns stdin and reads
|
|
513
|
+
// the leading ESC of every mouse report as key.escape — which dismisses
|
|
514
|
+
// the overlay on any click/scroll. Pause SGR tracking until the overlay
|
|
515
|
+
// closes, then re-enable.
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
if (state.phase !== "ready" || !state.overlay)
|
|
518
|
+
return;
|
|
519
|
+
if (!process.stdin.isTTY)
|
|
520
|
+
return;
|
|
521
|
+
process.stdout.write(MOUSE_OFF);
|
|
522
|
+
return () => {
|
|
523
|
+
process.stdout.write(MOUSE_ON);
|
|
524
|
+
};
|
|
525
|
+
}, [state.phase === "ready" && state.overlay ? state.overlay.kind : null]);
|
|
526
|
+
// Auto-refresh every 30s while the dashboard is idle. Skipped while an
|
|
527
|
+
// overlay is open so we don't yank state from under a confirmation, and
|
|
528
|
+
// while a manual refresh is in flight to avoid stomping on its spinner.
|
|
529
|
+
const stateRef = useRef(state);
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
stateRef.current = state;
|
|
532
|
+
}, [state]);
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
const id = setInterval(() => {
|
|
535
|
+
const s = stateRef.current;
|
|
536
|
+
if (s.phase !== "ready")
|
|
537
|
+
return;
|
|
538
|
+
if (s.overlay)
|
|
539
|
+
return;
|
|
540
|
+
if (s.refreshing)
|
|
541
|
+
return;
|
|
542
|
+
void refresh();
|
|
543
|
+
}, 30_000);
|
|
544
|
+
return () => clearInterval(id);
|
|
545
|
+
}, []);
|
|
546
|
+
useInput((input, key) => {
|
|
547
|
+
// Esc closes the create overlay first if it's open; only then quits the
|
|
548
|
+
// dashboard. Ctrl-C and `q` always quit (the desc TextInput swallows
|
|
549
|
+
// raw `q` when it has focus, so `q` while editing types a literal q).
|
|
550
|
+
if (state.phase === "ready" && state.overlay) {
|
|
551
|
+
handleOverlayInput(input, key);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (input === "q" || key.escape || (input === "c" && key.ctrl)) {
|
|
555
|
+
exit();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (state.phase !== "ready")
|
|
559
|
+
return;
|
|
560
|
+
if (key.upArrow || input === "k") {
|
|
561
|
+
setState({
|
|
562
|
+
...state,
|
|
563
|
+
selectedIndex: Math.max(0, state.selectedIndex - 1),
|
|
564
|
+
detailScrollOffset: 0,
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (key.downArrow || input === "j") {
|
|
569
|
+
setState({
|
|
570
|
+
...state,
|
|
571
|
+
selectedIndex: Math.min(state.issues.length - 1, state.selectedIndex + 1),
|
|
572
|
+
detailScrollOffset: 0,
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (key.pageUp) {
|
|
577
|
+
setState({
|
|
578
|
+
...state,
|
|
579
|
+
detailScrollOffset: Math.max(0, state.detailScrollOffset - SCROLL_STEP),
|
|
580
|
+
});
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (key.pageDown) {
|
|
584
|
+
setState({
|
|
585
|
+
...state,
|
|
586
|
+
detailScrollOffset: state.detailScrollOffset + SCROLL_STEP,
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (input === "r") {
|
|
591
|
+
setState({ ...state, refreshing: true });
|
|
592
|
+
void refresh();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (input === "o") {
|
|
596
|
+
const issue = state.issues[state.selectedIndex];
|
|
597
|
+
if (issue)
|
|
598
|
+
openInBrowser(issue.issue.url);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (input === "w") {
|
|
602
|
+
const issue = state.issues[state.selectedIndex];
|
|
603
|
+
if (!issue)
|
|
604
|
+
return;
|
|
605
|
+
if (issue.worktree) {
|
|
606
|
+
// Already has a worktree — `w` would be a no-op; `↵` resumes.
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
openCreateOverlay(issue);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (key.return) {
|
|
613
|
+
const issue = state.issues[state.selectedIndex];
|
|
614
|
+
if (!issue)
|
|
615
|
+
return;
|
|
616
|
+
if (issue.worktree) {
|
|
617
|
+
// Resume Claude in the existing worktree: same marker handshake
|
|
618
|
+
// as `worktree create --work`, minus the create. The wrapper
|
|
619
|
+
// will cd + run `mintree worktree work`, which itself sees the
|
|
620
|
+
// session_id in metadata and uses --resume.
|
|
621
|
+
emitMarkers([
|
|
622
|
+
`MINTREE_CD:${issue.worktree.path}`,
|
|
623
|
+
"MINTREE_WORK:1",
|
|
624
|
+
]);
|
|
625
|
+
exit();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
openCreateOverlay(issue);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (input === "d") {
|
|
632
|
+
const issue = state.issues[state.selectedIndex];
|
|
633
|
+
if (!issue || !issue.worktree)
|
|
634
|
+
return;
|
|
635
|
+
setState({
|
|
636
|
+
...state,
|
|
637
|
+
overlay: {
|
|
638
|
+
kind: "remove",
|
|
639
|
+
issue,
|
|
640
|
+
branch: issue.worktree.branch,
|
|
641
|
+
dirty: issue.worktree.dirty,
|
|
642
|
+
error: null,
|
|
643
|
+
},
|
|
644
|
+
toast: null,
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
function openCreateOverlay(issue) {
|
|
650
|
+
if (state.phase !== "ready")
|
|
651
|
+
return;
|
|
652
|
+
const root = findMainRepoRoot();
|
|
653
|
+
setState({
|
|
654
|
+
...state,
|
|
655
|
+
overlay: {
|
|
656
|
+
kind: "create",
|
|
657
|
+
issue,
|
|
658
|
+
type: "feat",
|
|
659
|
+
desc: kebabize(issue.issue.title) || `issue-${issue.issue.number}`,
|
|
660
|
+
prompt: "",
|
|
661
|
+
field: "type",
|
|
662
|
+
error: null,
|
|
663
|
+
conventionDoc: root ? findBranchConventionDoc(root) : null,
|
|
664
|
+
},
|
|
665
|
+
toast: null,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
function handleOverlayInput(input, key) {
|
|
669
|
+
if (state.phase !== "ready" || !state.overlay)
|
|
670
|
+
return;
|
|
671
|
+
const overlay = state.overlay;
|
|
672
|
+
if (key.escape || (input === "c" && key.ctrl)) {
|
|
673
|
+
setState({ ...state, overlay: null });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (overlay.kind === "remove") {
|
|
677
|
+
handleRemoveOverlayInput(input, key, overlay);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// Create overlay from here on.
|
|
681
|
+
if (key.tab) {
|
|
682
|
+
const nextField = overlay.field === "type"
|
|
683
|
+
? "desc"
|
|
684
|
+
: overlay.field === "desc"
|
|
685
|
+
? "prompt"
|
|
686
|
+
: "type";
|
|
687
|
+
setState({
|
|
688
|
+
...state,
|
|
689
|
+
overlay: { ...overlay, field: nextField },
|
|
690
|
+
});
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (overlay.field === "type") {
|
|
694
|
+
if (key.leftArrow || input === "h") {
|
|
695
|
+
const idx = ALLOWED_TYPES.indexOf(overlay.type);
|
|
696
|
+
const next = ALLOWED_TYPES[(idx - 1 + ALLOWED_TYPES.length) % ALLOWED_TYPES.length];
|
|
697
|
+
setState({ ...state, overlay: { ...overlay, type: next } });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (key.rightArrow || input === "l") {
|
|
701
|
+
const idx = ALLOWED_TYPES.indexOf(overlay.type);
|
|
702
|
+
const next = ALLOWED_TYPES[(idx + 1) % ALLOWED_TYPES.length];
|
|
703
|
+
setState({ ...state, overlay: { ...overlay, type: next } });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (key.return) {
|
|
708
|
+
void confirmCreate(overlay);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
// Any other input while in the desc field is handled by <TextInput>'s
|
|
712
|
+
// own onChange, not here — useInput still fires for those keystrokes
|
|
713
|
+
// but we want them to fall through.
|
|
714
|
+
}
|
|
715
|
+
function handleRemoveOverlayInput(input, key, overlay) {
|
|
716
|
+
if (state.phase !== "ready")
|
|
717
|
+
return;
|
|
718
|
+
// Force-confirm with capital Y when dirty; lowercase y otherwise.
|
|
719
|
+
// Either Enter alone counts as "no" so we don't accidentally remove
|
|
720
|
+
// dirty state on a stray return.
|
|
721
|
+
if (input === "y" && !overlay.dirty) {
|
|
722
|
+
void confirmRemove(overlay, false);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (input === "Y" && overlay.dirty) {
|
|
726
|
+
void confirmRemove(overlay, true);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (input === "n" || input === "N" || key.return) {
|
|
730
|
+
setState({ ...state, overlay: null });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function confirmCreate(overlay) {
|
|
735
|
+
if (state.phase !== "ready")
|
|
736
|
+
return;
|
|
737
|
+
const desc = overlay.desc.trim();
|
|
738
|
+
if (!desc) {
|
|
739
|
+
setState({
|
|
740
|
+
...state,
|
|
741
|
+
overlay: { ...overlay, error: "Description is required." },
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const branch = `${overlay.type}/${overlay.issue.issue.number}-${desc}`;
|
|
746
|
+
const prompt = overlay.prompt.trim();
|
|
747
|
+
const result = runCreate(branch, {
|
|
748
|
+
work: true,
|
|
749
|
+
...(prompt.length > 0 ? { prompt } : {}),
|
|
750
|
+
});
|
|
751
|
+
if (!result.ok) {
|
|
752
|
+
setState({
|
|
753
|
+
...state,
|
|
754
|
+
overlay: { ...overlay, error: result.message + (result.hint ? ` — ${result.hint}` : "") },
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
emitMarkers(buildCreateMarkers({
|
|
759
|
+
worktreePath: result.worktreePath,
|
|
760
|
+
work: result.work,
|
|
761
|
+
promptFile: result.promptFile,
|
|
762
|
+
permissionMode: result.permissionMode,
|
|
763
|
+
}));
|
|
764
|
+
exit();
|
|
765
|
+
}
|
|
766
|
+
async function confirmRemove(overlay, force) {
|
|
767
|
+
if (state.phase !== "ready")
|
|
768
|
+
return;
|
|
769
|
+
const result = runRemove(overlay.branch, force);
|
|
770
|
+
if (!result.ok) {
|
|
771
|
+
setState({
|
|
772
|
+
...state,
|
|
773
|
+
overlay: { ...overlay, error: result.message + (result.hint ? ` — ${result.hint}` : "") },
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
// Close the overlay, surface a toast, and refetch so the row updates
|
|
778
|
+
// (worktree/session/PR all flip back to null).
|
|
779
|
+
setState({
|
|
780
|
+
...state,
|
|
781
|
+
overlay: null,
|
|
782
|
+
toast: {
|
|
783
|
+
kind: "success",
|
|
784
|
+
text: result.variant === "pruned-orphan"
|
|
785
|
+
? `Pruned dangling reference for ${result.branch}.`
|
|
786
|
+
: `Removed worktree for ${result.branch}.${result.wasDirty ? " (forced past dirty)" : ""}`,
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
void refresh();
|
|
790
|
+
}
|
|
791
|
+
if (state.phase === "loading") {
|
|
792
|
+
return (_jsxs(Box, { width: columns, height: rows, alignItems: "center", justifyContent: "center", children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading issues..." })] }));
|
|
793
|
+
}
|
|
794
|
+
if (state.phase === "error") {
|
|
795
|
+
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" }) })] }));
|
|
796
|
+
}
|
|
797
|
+
const { issues, selectedIndex, refreshing, overlay, toast } = state;
|
|
798
|
+
const selected = issues[selectedIndex] ?? null;
|
|
799
|
+
const onOverlayDescChange = (next) => {
|
|
800
|
+
if (state.phase !== "ready" || !state.overlay)
|
|
801
|
+
return;
|
|
802
|
+
if (state.overlay.kind !== "create")
|
|
803
|
+
return;
|
|
804
|
+
setState({
|
|
805
|
+
...state,
|
|
806
|
+
overlay: { ...state.overlay, desc: sanitizeDesc(next), error: null },
|
|
807
|
+
});
|
|
808
|
+
};
|
|
809
|
+
const onOverlayPromptChange = (next) => {
|
|
810
|
+
if (state.phase !== "ready" || !state.overlay)
|
|
811
|
+
return;
|
|
812
|
+
if (state.overlay.kind !== "create")
|
|
813
|
+
return;
|
|
814
|
+
setState({
|
|
815
|
+
...state,
|
|
816
|
+
overlay: { ...state.overlay, prompt: next, error: null },
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
// Left pane is the issue list — it only needs room for "#N ICON title".
|
|
820
|
+
// We give it ~40% of the width so the detail pane (URLs, descriptions,
|
|
821
|
+
// branch paths) has the room it actually needs.
|
|
822
|
+
const listWidthPct = 0.40;
|
|
823
|
+
const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
|
|
824
|
+
const detailWidth = columns - listWidth - 2; // border slack
|
|
825
|
+
const identifierWidth = Math.max(3, ...issues.map(d => `#${d.issue.number}`.length));
|
|
826
|
+
// Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
|
|
827
|
+
const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
|
|
828
|
+
// Reserve rows: header (2), top borders (1), footer (3).
|
|
829
|
+
const listVisibleRows = Math.max(3, rows - 9);
|
|
830
|
+
// Detail pane content height inside the bordered box. Header eats 2 rows,
|
|
831
|
+
// the box's borders eat 2, the footer eats 2-3 — match the list reserve so
|
|
832
|
+
// both panes anchor to the same outer chrome.
|
|
833
|
+
const detailContentHeight = Math.max(3, rows - 9);
|
|
834
|
+
// Mouse handler needs the current list width to route wheel events to the
|
|
835
|
+
// correct pane. Ref lets the stdin listener (mounted once) read the live
|
|
836
|
+
// value without re-binding on every resize.
|
|
837
|
+
listWidthRef.current = listWidth;
|
|
838
|
+
const startIdx = Math.max(0, Math.min(Math.max(0, issues.length - listVisibleRows), selectedIndex - Math.floor(listVisibleRows / 2)));
|
|
839
|
+
const endIdx = Math.min(issues.length, startIdx + listVisibleRows);
|
|
840
|
+
const slice = issues.slice(startIdx, endIdx);
|
|
841
|
+
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 }) }), 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: [slice.map((d, i) => {
|
|
842
|
+
const absoluteIdx = startIdx + i;
|
|
843
|
+
return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
|
|
844
|
+
}), startIdx > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " more above"] })), endIdx < issues.length && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", issues.length - endIdx, " 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"
|
|
845
|
+
? "green"
|
|
846
|
+
: toast.kind === "error"
|
|
847
|
+
? "red"
|
|
848
|
+
: "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 })] })] }));
|
|
849
|
+
}
|