santree 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/cli.js +0 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +900 -0
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/helpers/template.d.ts +1 -1
- package/dist/commands/worktree/work.js +17 -1
- package/dist/lib/ai.d.ts +7 -0
- package/dist/lib/ai.js +10 -2
- package/dist/lib/dashboard/DetailPanel.d.ts +11 -0
- package/dist/lib/dashboard/DetailPanel.js +230 -0
- package/dist/lib/dashboard/IssueList.d.ts +13 -0
- package/dist/lib/dashboard/IssueList.js +112 -0
- package/dist/lib/dashboard/Overlays.d.ts +25 -0
- package/dist/lib/dashboard/Overlays.js +25 -0
- package/dist/lib/dashboard/data.d.ts +5 -0
- package/dist/lib/dashboard/data.js +75 -0
- package/dist/lib/dashboard/types.d.ts +150 -0
- package/dist/lib/dashboard/types.js +151 -0
- package/dist/lib/git.d.ts +19 -0
- package/dist/lib/git.js +32 -1
- package/dist/lib/github.d.ts +2 -1
- package/dist/lib/github.js +3 -2
- package/dist/lib/linear.d.ts +20 -0
- package/dist/lib/linear.js +53 -0
- package/package.json +2 -2
package/dist/commands/doctor.js
CHANGED
|
@@ -294,9 +294,9 @@ export default function Doctor() {
|
|
|
294
294
|
const results = await Promise.all([
|
|
295
295
|
checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
|
|
296
296
|
checkGhAuth(),
|
|
297
|
-
checkTool("tmux", "Terminal multiplexer",
|
|
297
|
+
checkTool("tmux", "Terminal multiplexer", false, "tmux -V", "Install: brew install tmux"),
|
|
298
298
|
checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
|
|
299
|
-
checkTool("happy", "Claude CLI wrapper",
|
|
299
|
+
checkTool("happy", "Claude CLI wrapper (used over claude if installed)", false, "happy --version 2>/dev/null || echo 'installed'", "Install: npm install -g happy-coder"),
|
|
300
300
|
]);
|
|
301
301
|
// Check for either code or cursor (only need one)
|
|
302
302
|
const [codeCheck, cursorCheck] = await Promise.all([
|
|
@@ -2,8 +2,8 @@ import { z } from "zod/v4";
|
|
|
2
2
|
export declare const description = "Render a template to stdout";
|
|
3
3
|
export declare const args: z.ZodTuple<[z.ZodEnum<{
|
|
4
4
|
linear: "linear";
|
|
5
|
-
"git-changes": "git-changes";
|
|
6
5
|
pr: "pr";
|
|
6
|
+
"git-changes": "git-changes";
|
|
7
7
|
"fix-pr": "fix-pr";
|
|
8
8
|
review: "review";
|
|
9
9
|
}>], null>;
|
|
@@ -4,6 +4,8 @@ import { Text, Box } from "ink";
|
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, } from "../../lib/ai.js";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { getSessionId, setSessionId } from "../../lib/git.js";
|
|
7
9
|
export const description = "Launch Claude to work on current ticket";
|
|
8
10
|
export const options = z.object({
|
|
9
11
|
plan: z.boolean().optional().describe("Only create implementation plan"),
|
|
@@ -45,8 +47,22 @@ export default function Work({ options }) {
|
|
|
45
47
|
return;
|
|
46
48
|
setStatus("launching");
|
|
47
49
|
const prompt = renderAIPrompt("work", aiContext, { mode });
|
|
50
|
+
// Get or create a session ID for this ticket
|
|
51
|
+
let sessionId;
|
|
52
|
+
let isResume = false;
|
|
53
|
+
if (aiContext.ticketId) {
|
|
54
|
+
const existing = getSessionId(aiContext.mainRoot, aiContext.ticketId);
|
|
55
|
+
if (existing) {
|
|
56
|
+
sessionId = existing;
|
|
57
|
+
isResume = true;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
sessionId = randomUUID();
|
|
61
|
+
setSessionId(aiContext.mainRoot, aiContext.ticketId, sessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
48
64
|
try {
|
|
49
|
-
const child = launchAgent(prompt, { planMode: mode === "plan" });
|
|
65
|
+
const child = launchAgent(prompt, { planMode: mode === "plan", sessionId, resume: isResume });
|
|
50
66
|
child.on("error", (err) => {
|
|
51
67
|
setStatus("error");
|
|
52
68
|
setError(`Failed to launch agent: ${err.message}`);
|
package/dist/lib/ai.d.ts
CHANGED
|
@@ -36,6 +36,11 @@ export declare function fetchAndRenderPR(branch: string): Promise<string | null>
|
|
|
36
36
|
* Returns rendered markdown.
|
|
37
37
|
*/
|
|
38
38
|
export declare function fetchAndRenderDiff(branch: string): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve which agent binary to use (happy if installed, otherwise claude).
|
|
41
|
+
* Returns the binary name, or null if neither is installed.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveAgentBinary(): string | null;
|
|
39
44
|
/**
|
|
40
45
|
* Launch an interactive agent session with a prompt.
|
|
41
46
|
* Resolves the agent binary (happy > claude), passes prompt directly
|
|
@@ -44,6 +49,8 @@ export declare function fetchAndRenderDiff(branch: string): Promise<string>;
|
|
|
44
49
|
*/
|
|
45
50
|
export declare function launchAgent(prompt: string, opts?: {
|
|
46
51
|
planMode?: boolean;
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
resume?: boolean;
|
|
47
54
|
}): ChildProcess;
|
|
48
55
|
export interface RunAgentResult {
|
|
49
56
|
success: boolean;
|
package/dist/lib/ai.js
CHANGED
|
@@ -105,10 +105,10 @@ export async function fetchAndRenderDiff(branch) {
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
|
-
* Resolve which agent binary to use (happy
|
|
108
|
+
* Resolve which agent binary to use (happy if installed, otherwise claude).
|
|
109
109
|
* Returns the binary name, or null if neither is installed.
|
|
110
110
|
*/
|
|
111
|
-
function resolveAgentBinary() {
|
|
111
|
+
export function resolveAgentBinary() {
|
|
112
112
|
for (const bin of ["happy", "claude"]) {
|
|
113
113
|
try {
|
|
114
114
|
execSync(`which ${bin}`, { stdio: "ignore" });
|
|
@@ -150,6 +150,14 @@ export function launchAgent(prompt, opts) {
|
|
|
150
150
|
if (opts?.planMode) {
|
|
151
151
|
args.push("--permission-mode", "plan");
|
|
152
152
|
}
|
|
153
|
+
if (opts?.sessionId) {
|
|
154
|
+
if (opts.resume) {
|
|
155
|
+
args.push("--resume", opts.sessionId);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
args.push("--session-id", opts.sessionId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
153
161
|
args.push("--", promptArg(prompt));
|
|
154
162
|
return spawn(bin, args, { stdio: "inherit" });
|
|
155
163
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DashboardIssue } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
issue: DashboardIssue | null;
|
|
4
|
+
scrollOffset: number;
|
|
5
|
+
height: number;
|
|
6
|
+
width: number;
|
|
7
|
+
creatingForTicket: string | null;
|
|
8
|
+
creationLogs: string;
|
|
9
|
+
}
|
|
10
|
+
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function stateColor(type) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case "started":
|
|
6
|
+
return "green";
|
|
7
|
+
case "unstarted":
|
|
8
|
+
return "blue";
|
|
9
|
+
case "backlog":
|
|
10
|
+
return "gray";
|
|
11
|
+
default:
|
|
12
|
+
return "yellow";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function parseGitStatus(raw) {
|
|
16
|
+
if (!raw)
|
|
17
|
+
return { staged: 0, unstaged: 0, untracked: 0, files: [] };
|
|
18
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
19
|
+
let staged = 0;
|
|
20
|
+
let unstaged = 0;
|
|
21
|
+
let untracked = 0;
|
|
22
|
+
const files = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
if (line.length < 2)
|
|
25
|
+
continue;
|
|
26
|
+
const x = line[0];
|
|
27
|
+
const y = line[1];
|
|
28
|
+
const file = line.slice(3);
|
|
29
|
+
if (x === "?") {
|
|
30
|
+
untracked++;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
if (x !== " ")
|
|
34
|
+
staged++;
|
|
35
|
+
if (y !== " ")
|
|
36
|
+
unstaged++;
|
|
37
|
+
}
|
|
38
|
+
files.push({ xy: line.slice(0, 2), file });
|
|
39
|
+
}
|
|
40
|
+
return { staged, unstaged, untracked, files };
|
|
41
|
+
}
|
|
42
|
+
function fileColor(xy) {
|
|
43
|
+
const x = xy[0];
|
|
44
|
+
if (x !== " " && x !== "?")
|
|
45
|
+
return "green";
|
|
46
|
+
if (xy.startsWith("??"))
|
|
47
|
+
return "gray";
|
|
48
|
+
return "yellow";
|
|
49
|
+
}
|
|
50
|
+
function buildActions(worktree, pr) {
|
|
51
|
+
const items = [];
|
|
52
|
+
// Work/Resume
|
|
53
|
+
if (worktree?.sessionId) {
|
|
54
|
+
items.push({ key: "↵", label: "Resume", color: "cyan" });
|
|
55
|
+
}
|
|
56
|
+
else if (worktree) {
|
|
57
|
+
items.push({ key: "w", label: "Work", color: "cyan" });
|
|
58
|
+
items.push({ key: "↵", label: "Switch", color: "cyan" });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
items.push({ key: "w", label: "Work", color: "cyan" });
|
|
62
|
+
}
|
|
63
|
+
// Editor
|
|
64
|
+
if (worktree) {
|
|
65
|
+
items.push({ key: "e", label: "Editor", color: "cyan" });
|
|
66
|
+
}
|
|
67
|
+
// Commit
|
|
68
|
+
if (worktree?.dirty) {
|
|
69
|
+
items.push({ key: "C", label: "Commit", color: "cyan" });
|
|
70
|
+
}
|
|
71
|
+
// PR actions
|
|
72
|
+
if (worktree && !pr) {
|
|
73
|
+
items.push({ key: "c", label: "Create PR", color: "cyan" });
|
|
74
|
+
}
|
|
75
|
+
if (pr) {
|
|
76
|
+
items.push({ key: "f", label: "Fix PR", color: "cyan" });
|
|
77
|
+
items.push({ key: "r", label: "Review", color: "cyan" });
|
|
78
|
+
}
|
|
79
|
+
// Links
|
|
80
|
+
items.push({ key: "o", label: "Linear", color: "gray" });
|
|
81
|
+
if (pr)
|
|
82
|
+
items.push({ key: "p", label: "Open PR", color: "gray" });
|
|
83
|
+
// Destructive
|
|
84
|
+
if (worktree) {
|
|
85
|
+
items.push({ key: "d", label: "Remove", color: "red" });
|
|
86
|
+
}
|
|
87
|
+
return [items];
|
|
88
|
+
}
|
|
89
|
+
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }) {
|
|
90
|
+
// Show creation logs when selected issue is being created
|
|
91
|
+
if (issue && issue.issue.identifier === creatingForTicket) {
|
|
92
|
+
const logLines = creationLogs.split("\n");
|
|
93
|
+
const contentRows = height - 1;
|
|
94
|
+
const startIdx = Math.max(0, logLines.length - contentRows);
|
|
95
|
+
const visible = logLines.slice(startIdx, startIdx + contentRows);
|
|
96
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", creatingForTicket, "..."] }), visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] }));
|
|
97
|
+
}
|
|
98
|
+
if (!issue) {
|
|
99
|
+
return (_jsx(Box, { width: width, height: height, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No issue selected" }) }));
|
|
100
|
+
}
|
|
101
|
+
const { issue: li, worktree, pr } = issue;
|
|
102
|
+
const lines = [];
|
|
103
|
+
const rule = "─".repeat(width);
|
|
104
|
+
// ── Hero: identifier + title ──────────────────────────────────────
|
|
105
|
+
lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
|
|
106
|
+
const meta = [];
|
|
107
|
+
meta.push(li.state.name);
|
|
108
|
+
meta.push(li.priorityLabel);
|
|
109
|
+
if (li.labels.length > 0)
|
|
110
|
+
meta.push(li.labels.join(", "));
|
|
111
|
+
lines.push({ text: meta.join(" · "), color: stateColor(li.state.type) });
|
|
112
|
+
// ── Description ───────────────────────────────────────────────────
|
|
113
|
+
if (li.description) {
|
|
114
|
+
lines.push({ text: rule, dim: true });
|
|
115
|
+
lines.push({ text: "" });
|
|
116
|
+
for (const dLine of li.description.trimEnd().split("\n")) {
|
|
117
|
+
lines.push({ text: dLine });
|
|
118
|
+
}
|
|
119
|
+
lines.push({ text: "" });
|
|
120
|
+
}
|
|
121
|
+
// ── Worktree (enhanced) ───────────────────────────────────────────
|
|
122
|
+
lines.push({ text: rule, dim: true });
|
|
123
|
+
lines.push({ text: "WORKTREE", dim: true });
|
|
124
|
+
if (worktree) {
|
|
125
|
+
lines.push({ text: ` ${worktree.branch}` });
|
|
126
|
+
lines.push({ text: ` ${worktree.path}`, dim: true });
|
|
127
|
+
const gs = parseGitStatus(worktree.gitStatus);
|
|
128
|
+
const statusParts = [];
|
|
129
|
+
if (gs.staged > 0)
|
|
130
|
+
statusParts.push(`+${gs.staged} staged`);
|
|
131
|
+
if (gs.unstaged > 0)
|
|
132
|
+
statusParts.push(`~${gs.unstaged} unstaged`);
|
|
133
|
+
if (gs.untracked > 0)
|
|
134
|
+
statusParts.push(`?${gs.untracked} untracked`);
|
|
135
|
+
if (worktree.commitsAhead > 0)
|
|
136
|
+
statusParts.push(`+${worktree.commitsAhead} ahead`);
|
|
137
|
+
if (statusParts.length > 0) {
|
|
138
|
+
lines.push({
|
|
139
|
+
text: ` ${statusParts.join(" ")}`,
|
|
140
|
+
color: worktree.dirty ? "yellow" : "green",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
lines.push({ text: " ✓ clean", color: "green" });
|
|
145
|
+
}
|
|
146
|
+
// Show individual files (up to 8)
|
|
147
|
+
const maxFiles = 8;
|
|
148
|
+
for (let i = 0; i < Math.min(gs.files.length, maxFiles); i++) {
|
|
149
|
+
const f = gs.files[i];
|
|
150
|
+
lines.push({ text: ` ${f.xy} ${f.file}`, color: fileColor(f.xy) });
|
|
151
|
+
}
|
|
152
|
+
if (gs.files.length > maxFiles) {
|
|
153
|
+
lines.push({ text: ` +${gs.files.length - maxFiles} more`, dim: true });
|
|
154
|
+
}
|
|
155
|
+
if (worktree.sessionId) {
|
|
156
|
+
lines.push({ text: ` session: ${worktree.sessionId}`, color: "cyan" });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
lines.push({ text: " session: none", color: "red" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push({ text: " –", dim: true });
|
|
164
|
+
}
|
|
165
|
+
// ── Pull Request ──────────────────────────────────────────────────
|
|
166
|
+
const { checks, reviews } = issue;
|
|
167
|
+
lines.push({ text: rule, dim: true });
|
|
168
|
+
lines.push({ text: "PULL REQUEST", dim: true });
|
|
169
|
+
if (pr) {
|
|
170
|
+
const sc = pr.state === "MERGED" ? "magenta" : pr.state === "OPEN" ? "green" : "red";
|
|
171
|
+
const draft = pr.isDraft ? " draft" : "";
|
|
172
|
+
lines.push({ text: ` #${pr.number} ${pr.state}${draft}`, color: sc });
|
|
173
|
+
if (pr.url) {
|
|
174
|
+
lines.push({ text: ` ${pr.url}`, dim: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
lines.push({ text: " –", dim: true });
|
|
179
|
+
}
|
|
180
|
+
// ── Checks ────────────────────────────────────────────────────────
|
|
181
|
+
if (checks && checks.length > 0) {
|
|
182
|
+
const passCount = checks.filter((c) => c.bucket === "pass").length;
|
|
183
|
+
lines.push({ text: rule, dim: true });
|
|
184
|
+
lines.push({ text: `CHECKS ${passCount}/${checks.length} passing`, dim: true });
|
|
185
|
+
for (const check of checks) {
|
|
186
|
+
if (check.bucket === "pass") {
|
|
187
|
+
lines.push({ text: ` ✓ ${check.name}`, color: "green" });
|
|
188
|
+
}
|
|
189
|
+
else if (check.bucket === "fail") {
|
|
190
|
+
const desc = check.description ? ` — ${check.description}` : "";
|
|
191
|
+
lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
lines.push({ text: ` ● ${check.name} (pending)`, color: "yellow" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── Reviews ───────────────────────────────────────────────────────
|
|
199
|
+
if (reviews && reviews.length > 0) {
|
|
200
|
+
lines.push({ text: rule, dim: true });
|
|
201
|
+
lines.push({ text: "REVIEWS", dim: true });
|
|
202
|
+
for (const review of reviews) {
|
|
203
|
+
const author = review.author.login;
|
|
204
|
+
const rc = review.state === "APPROVED"
|
|
205
|
+
? "green"
|
|
206
|
+
: review.state === "CHANGES_REQUESTED"
|
|
207
|
+
? "red"
|
|
208
|
+
: "yellow";
|
|
209
|
+
lines.push({ text: ` ${author} ${review.state}`, color: rc });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ── Build actions footer ──────────────────────────────────────────
|
|
213
|
+
const actionRows = buildActions(worktree, pr);
|
|
214
|
+
// +1 for the separator line
|
|
215
|
+
const actionsHeight = actionRows.length + 1;
|
|
216
|
+
const scrollableHeight = height - actionsHeight;
|
|
217
|
+
// ── Render scrollable content ─────────────────────────────────────
|
|
218
|
+
const totalLines = lines.length;
|
|
219
|
+
const canScroll = totalLines > scrollableHeight;
|
|
220
|
+
const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
|
|
221
|
+
const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
|
|
222
|
+
const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
|
|
223
|
+
let scrollArrow = null;
|
|
224
|
+
if (canScroll) {
|
|
225
|
+
const atTop = clampedOffset === 0;
|
|
226
|
+
const atBottom = clampedOffset + contentRows >= totalLines;
|
|
227
|
+
scrollArrow = atTop ? "↓ scroll" : atBottom ? "↑ scroll" : "↑↓ scroll";
|
|
228
|
+
}
|
|
229
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text || " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: rule }) }), actionRows.map((row, i) => (_jsx(Box, { children: row.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : "white", children: [" ", item.label] })] }, j))) }, `a-${i}`)))] }));
|
|
230
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProjectGroup, DashboardIssue } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
groups: ProjectGroup[];
|
|
4
|
+
flatIssues: DashboardIssue[];
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
scrollOffset: number;
|
|
7
|
+
height: number;
|
|
8
|
+
width: number;
|
|
9
|
+
creatingForTicket: string | null;
|
|
10
|
+
deletingForTicket: string | null;
|
|
11
|
+
}
|
|
12
|
+
export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, creatingForTicket, deletingForTicket, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function stateColor(type) {
|
|
4
|
+
switch (type) {
|
|
5
|
+
case "started":
|
|
6
|
+
return "green";
|
|
7
|
+
case "unstarted":
|
|
8
|
+
return "blue";
|
|
9
|
+
case "backlog":
|
|
10
|
+
return "gray";
|
|
11
|
+
default:
|
|
12
|
+
return "yellow";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function priorityIndicator(priority) {
|
|
16
|
+
switch (priority) {
|
|
17
|
+
case 1:
|
|
18
|
+
return { text: "!!!", color: "red" };
|
|
19
|
+
case 2:
|
|
20
|
+
return { text: "!! ", color: "yellow" };
|
|
21
|
+
case 3:
|
|
22
|
+
return { text: "! ", color: "blue" };
|
|
23
|
+
case 4:
|
|
24
|
+
return { text: "· ", color: "gray" };
|
|
25
|
+
default:
|
|
26
|
+
return { text: " ", color: "gray" };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function checksIndicator(checks) {
|
|
30
|
+
if (!checks || checks.length === 0)
|
|
31
|
+
return { text: "-", color: "gray" };
|
|
32
|
+
if (checks.some((c) => c.bucket === "fail"))
|
|
33
|
+
return { text: "✗", color: "red" };
|
|
34
|
+
if (checks.every((c) => c.bucket === "pass"))
|
|
35
|
+
return { text: "✓", color: "green" };
|
|
36
|
+
return { text: "●", color: "yellow" };
|
|
37
|
+
}
|
|
38
|
+
function prIndicator(pr) {
|
|
39
|
+
if (!pr)
|
|
40
|
+
return { text: "-", color: "gray" };
|
|
41
|
+
const label = `#${pr.number}`;
|
|
42
|
+
if (pr.state === "MERGED")
|
|
43
|
+
return { text: label, color: "magenta" };
|
|
44
|
+
if (pr.state === "CLOSED")
|
|
45
|
+
return { text: label, color: "red" };
|
|
46
|
+
if (pr.isDraft)
|
|
47
|
+
return { text: label, color: "gray" };
|
|
48
|
+
return { text: label, color: "green" };
|
|
49
|
+
}
|
|
50
|
+
function sessionIndicator(wt, isCreating, isDeleting) {
|
|
51
|
+
if (isDeleting)
|
|
52
|
+
return { text: " deleting", color: "red" };
|
|
53
|
+
if (isCreating)
|
|
54
|
+
return { text: " creating", color: "yellow" };
|
|
55
|
+
if (!wt)
|
|
56
|
+
return { text: " -", color: "gray" };
|
|
57
|
+
if (wt.sessionId)
|
|
58
|
+
return { text: " " + wt.sessionId.slice(0, 8), color: "cyan" };
|
|
59
|
+
return { text: " none", color: "red" };
|
|
60
|
+
}
|
|
61
|
+
function buildRows(groups, flatIssues) {
|
|
62
|
+
const rows = [{ kind: "columns" }];
|
|
63
|
+
// Build a map from issue identifier to flat index
|
|
64
|
+
const indexMap = new Map();
|
|
65
|
+
flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
rows.push({ kind: "header", name: group.name, count: group.issues.length });
|
|
68
|
+
for (const di of group.issues) {
|
|
69
|
+
rows.push({ kind: "issue", issue: di, flatIndex: indexMap.get(di.issue.identifier) ?? -1 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
const FOOTER_HEIGHT = 2;
|
|
75
|
+
export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, creatingForTicket, deletingForTicket, }) {
|
|
76
|
+
const rows = buildRows(groups, flatIssues);
|
|
77
|
+
const listHeight = height - FOOTER_HEIGHT;
|
|
78
|
+
const visible = rows.slice(scrollOffset, scrollOffset + listHeight);
|
|
79
|
+
// 2 cursor + 2 dot + 4 priority + 11 id + title + 9 session + 1 space + 6 pr + 1 space + 2 checks
|
|
80
|
+
const prColWidth = 6;
|
|
81
|
+
const checksColWidth = 2;
|
|
82
|
+
const sessionColWidth = 9;
|
|
83
|
+
const priorityColWidth = 4;
|
|
84
|
+
const fixedWidth = 2 + 2 + priorityColWidth + 11 + sessionColWidth + 1 + prColWidth + 1 + checksColWidth;
|
|
85
|
+
const titleMaxWidth = Math.max(width - fixedWidth, 10);
|
|
86
|
+
const footerRule = "─".repeat(width);
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: visible.map((row, i) => {
|
|
88
|
+
if (row.kind === "columns") {
|
|
89
|
+
const labelPad = 14 + priorityColWidth + titleMaxWidth;
|
|
90
|
+
return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "".padEnd(labelPad) }), _jsx(Text, { dimColor: true, children: "session".padStart(sessionColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "pr".padStart(prColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
|
|
91
|
+
}
|
|
92
|
+
if (row.kind === "header") {
|
|
93
|
+
return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, bold: true, children: ["── ", row.name, " (", row.count, ")", " ──"] }) }, `h-${i}`));
|
|
94
|
+
}
|
|
95
|
+
const { issue, flatIndex } = row;
|
|
96
|
+
const selected = flatIndex === selectedIndex;
|
|
97
|
+
const di = issue;
|
|
98
|
+
const sc = stateColor(di.issue.state.type);
|
|
99
|
+
const isCreating = di.issue.identifier === creatingForTicket;
|
|
100
|
+
const isDeleting = di.issue.identifier === deletingForTicket;
|
|
101
|
+
const sess = sessionIndicator(di.worktree, isCreating, isDeleting);
|
|
102
|
+
const ci = checksIndicator(di.checks);
|
|
103
|
+
const pr = prIndicator(di.pr);
|
|
104
|
+
const prio = priorityIndicator(di.issue.priority);
|
|
105
|
+
const cursor = selected ? ">" : " ";
|
|
106
|
+
const title = di.issue.title.length > titleMaxWidth
|
|
107
|
+
? di.issue.title.slice(0, titleMaxWidth - 1) + "…"
|
|
108
|
+
: di.issue.title;
|
|
109
|
+
const bg = selected ? "#1e3a5f" : undefined;
|
|
110
|
+
return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsx(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: di.issue.identifier.padEnd(10) }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
|
|
111
|
+
}) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Shift + \u2191\u2193" }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "E" }), _jsx(Text, { color: "white", children: " Workspace" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), _jsx(Text, { color: "white", children: " Refresh" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CommitPhase, PrCreatePhase, DashboardAction } from "./types.js";
|
|
2
|
+
interface CommitOverlayProps {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
branch: string | null;
|
|
6
|
+
ticketId: string | null;
|
|
7
|
+
gitStatus: string;
|
|
8
|
+
phase: CommitPhase;
|
|
9
|
+
message: string;
|
|
10
|
+
error: string | null;
|
|
11
|
+
dispatch: React.Dispatch<DashboardAction>;
|
|
12
|
+
onSubmit: (value: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function CommitOverlay({ width, height, branch, ticketId, gitStatus, phase, message, error, dispatch, onSubmit, }: CommitOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
interface PrCreateOverlayProps {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
branch: string | null;
|
|
19
|
+
ticketId: string | null;
|
|
20
|
+
phase: PrCreatePhase;
|
|
21
|
+
error: string | null;
|
|
22
|
+
url: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phase, message, error, dispatch, onSubmit, }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Commit & Push" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), gitStatus ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Changes:" }), gitStatus
|
|
7
|
+
.split("\n")
|
|
8
|
+
.slice(0, 8)
|
|
9
|
+
.map((line, i) => {
|
|
10
|
+
let color;
|
|
11
|
+
if (line.length >= 2 && line[0] !== " " && line[0] !== "?") {
|
|
12
|
+
color = "green";
|
|
13
|
+
}
|
|
14
|
+
else if (line.startsWith("??")) {
|
|
15
|
+
color = "gray";
|
|
16
|
+
}
|
|
17
|
+
else if (line.startsWith(" ")) {
|
|
18
|
+
color = "yellow";
|
|
19
|
+
}
|
|
20
|
+
return (_jsxs(Text, { color: color, children: [" ", line] }, i));
|
|
21
|
+
}), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
22
|
+
}
|
|
23
|
+
export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, }) {
|
|
24
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 auto-fill title & body from commits"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
|
|
2
|
+
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, } from "../github.js";
|
|
3
|
+
import { fetchAssignedIssues } from "../linear.js";
|
|
4
|
+
export async function loadDashboardData(repoRoot) {
|
|
5
|
+
// Fetch issues and worktrees in parallel
|
|
6
|
+
const [issues, worktrees] = await Promise.all([
|
|
7
|
+
fetchAssignedIssues(repoRoot),
|
|
8
|
+
Promise.resolve(listWorktrees()),
|
|
9
|
+
]);
|
|
10
|
+
if (!issues)
|
|
11
|
+
throw new Error("Failed to fetch Linear issues. Check authentication.");
|
|
12
|
+
// Build worktree map: ticketId -> worktree info
|
|
13
|
+
const wtMap = new Map();
|
|
14
|
+
for (const wt of worktrees) {
|
|
15
|
+
if (!wt.branch)
|
|
16
|
+
continue;
|
|
17
|
+
const tid = extractTicketId(wt.branch);
|
|
18
|
+
if (tid)
|
|
19
|
+
wtMap.set(tid, { path: wt.path, branch: wt.branch });
|
|
20
|
+
}
|
|
21
|
+
// Read metadata once for session IDs
|
|
22
|
+
const metadata = readAllMetadata(repoRoot);
|
|
23
|
+
// Enrich issues in parallel
|
|
24
|
+
const enriched = await Promise.all(issues.map(async (issue) => {
|
|
25
|
+
const wt = wtMap.get(issue.identifier);
|
|
26
|
+
let worktreeInfo = null;
|
|
27
|
+
let prInfo = null;
|
|
28
|
+
let checksInfo = null;
|
|
29
|
+
let reviewsInfo = null;
|
|
30
|
+
if (wt) {
|
|
31
|
+
const base = getBaseBranch(wt.branch);
|
|
32
|
+
const [gitStatusOutput, ahead, pr] = await Promise.all([
|
|
33
|
+
getGitStatusAsync(wt.path),
|
|
34
|
+
getCommitsAheadAsync(wt.path, base),
|
|
35
|
+
getPRInfoAsync(wt.branch),
|
|
36
|
+
]);
|
|
37
|
+
worktreeInfo = {
|
|
38
|
+
path: wt.path,
|
|
39
|
+
branch: wt.branch,
|
|
40
|
+
dirty: Boolean(gitStatusOutput),
|
|
41
|
+
commitsAhead: ahead,
|
|
42
|
+
sessionId: metadata[issue.identifier]?.session_id ?? null,
|
|
43
|
+
gitStatus: gitStatusOutput,
|
|
44
|
+
};
|
|
45
|
+
prInfo = pr;
|
|
46
|
+
if (pr) {
|
|
47
|
+
[checksInfo, reviewsInfo] = await Promise.all([
|
|
48
|
+
getPRChecksAsync(pr.number),
|
|
49
|
+
getPRReviewsAsync(pr.number),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
issue,
|
|
55
|
+
worktree: worktreeInfo,
|
|
56
|
+
pr: prInfo,
|
|
57
|
+
checks: checksInfo,
|
|
58
|
+
reviews: reviewsInfo,
|
|
59
|
+
};
|
|
60
|
+
}));
|
|
61
|
+
// Group by project
|
|
62
|
+
const groupMap = new Map();
|
|
63
|
+
for (const di of enriched) {
|
|
64
|
+
const key = di.issue.projectName ?? "No Project";
|
|
65
|
+
const list = groupMap.get(key) ?? [];
|
|
66
|
+
list.push(di);
|
|
67
|
+
groupMap.set(key, list);
|
|
68
|
+
}
|
|
69
|
+
const groups = [...groupMap.entries()].map(([name, issues]) => ({
|
|
70
|
+
name,
|
|
71
|
+
id: issues[0]?.issue.projectId ?? null,
|
|
72
|
+
issues,
|
|
73
|
+
}));
|
|
74
|
+
return { groups, flatIssues: groups.flatMap((g) => g.issues) };
|
|
75
|
+
}
|