santree 0.2.9 → 0.2.11
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/dist/commands/dashboard.js +287 -15
- package/dist/lib/ai.js +5 -1
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +166 -0
- package/dist/lib/dashboard/ReviewList.d.ts +11 -0
- package/dist/lib/dashboard/ReviewList.js +53 -0
- package/dist/lib/dashboard/data.d.ts +4 -1
- package/dist/lib/dashboard/data.js +69 -1
- package/dist/lib/dashboard/types.d.ts +36 -1
- package/dist/lib/dashboard/types.js +28 -0
- package/dist/lib/github.d.ts +41 -0
- package/dist/lib/github.js +55 -0
- package/dist/lib/linear.js +6 -2
- package/package.json +1 -1
|
@@ -17,12 +17,25 @@ import { getPRTemplate } from "../lib/github.js";
|
|
|
17
17
|
import { renderPrompt, renderDiff } from "../lib/prompts.js";
|
|
18
18
|
import * as os from "os";
|
|
19
19
|
import { initialState, reducer } from "../lib/dashboard/types.js";
|
|
20
|
-
import { loadDashboardData } from "../lib/dashboard/data.js";
|
|
20
|
+
import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
|
|
21
21
|
import IssueList from "../lib/dashboard/IssueList.js";
|
|
22
22
|
import DetailPanel from "../lib/dashboard/DetailPanel.js";
|
|
23
|
+
import ReviewList from "../lib/dashboard/ReviewList.js";
|
|
24
|
+
import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
|
|
23
25
|
import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
|
|
24
26
|
export const description = "Interactive dashboard of your Linear issues";
|
|
25
27
|
const execAsync = promisify(exec);
|
|
28
|
+
const CLAUDE_VERSION = (() => {
|
|
29
|
+
const bin = path.join(os.homedir(), ".claude", "local", "claude");
|
|
30
|
+
try {
|
|
31
|
+
return (execSync(`${bin} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
|
|
32
|
+
.trim()
|
|
33
|
+
.split(" ")[0] ?? "");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
26
39
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
27
40
|
function isInTmux() {
|
|
28
41
|
return !!process.env.TMUX;
|
|
@@ -169,7 +182,7 @@ export default function Dashboard() {
|
|
|
169
182
|
const leftWidthRef = useRef(leftWidth);
|
|
170
183
|
leftWidthRef.current = leftWidth;
|
|
171
184
|
const rightWidth = columns - leftWidth - separatorWidth;
|
|
172
|
-
const contentHeight = rows -
|
|
185
|
+
const contentHeight = rows - 2; // 2 header rows (tabs + version)
|
|
173
186
|
const LIST_FOOTER_HEIGHT = 2;
|
|
174
187
|
// ── Data loading ──────────────────────────────────────────────────
|
|
175
188
|
const refresh = useCallback(async (isInitial = false) => {
|
|
@@ -182,8 +195,12 @@ export default function Dashboard() {
|
|
|
182
195
|
}
|
|
183
196
|
repoRootRef.current = repoRoot;
|
|
184
197
|
try {
|
|
185
|
-
const data = await
|
|
198
|
+
const [data, reviewData] = await Promise.all([
|
|
199
|
+
loadDashboardData(repoRoot),
|
|
200
|
+
loadReviewsData(repoRoot),
|
|
201
|
+
]);
|
|
186
202
|
dispatch({ type: "SET_DATA", ...data });
|
|
203
|
+
dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
|
|
187
204
|
}
|
|
188
205
|
catch (e) {
|
|
189
206
|
dispatch({
|
|
@@ -229,6 +246,20 @@ export default function Dashboard() {
|
|
|
229
246
|
const s = stateRef.current;
|
|
230
247
|
const lw = leftWidthRef.current;
|
|
231
248
|
const delta = button === 65 ? 3 : -3;
|
|
249
|
+
if (s.activeTab === "reviews") {
|
|
250
|
+
if (col <= lw) {
|
|
251
|
+
const maxIdx = s.flatReviews.length - 1;
|
|
252
|
+
if (maxIdx < 0)
|
|
253
|
+
return;
|
|
254
|
+
const next = Math.max(0, Math.min(s.reviewSelectedIndex + delta, maxIdx));
|
|
255
|
+
dispatch({ type: "REVIEW_SELECT", index: next });
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const next = Math.max(0, s.reviewDetailScrollOffset + delta);
|
|
259
|
+
dispatch({ type: "REVIEW_SCROLL_DETAIL", offset: next });
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
232
263
|
if (col <= lw) {
|
|
233
264
|
// Scroll left pane (issue list)
|
|
234
265
|
const maxIdx = s.flatIssues.length - 1;
|
|
@@ -254,9 +285,9 @@ export default function Dashboard() {
|
|
|
254
285
|
draggingRef.current = true;
|
|
255
286
|
return;
|
|
256
287
|
}
|
|
257
|
-
// Left-click press: select
|
|
288
|
+
// Left-click press: select item in left pane
|
|
258
289
|
const s = stateRef.current;
|
|
259
|
-
if (s.loading || s.error
|
|
290
|
+
if (s.loading || s.error)
|
|
260
291
|
return;
|
|
261
292
|
if (col > lw)
|
|
262
293
|
return;
|
|
@@ -264,6 +295,19 @@ export default function Dashboard() {
|
|
|
264
295
|
const contentRow = row - 2; // 0-based row within content area
|
|
265
296
|
if (contentRow < 0)
|
|
266
297
|
return;
|
|
298
|
+
if (s.activeTab === "reviews") {
|
|
299
|
+
if (s.flatReviews.length === 0)
|
|
300
|
+
return;
|
|
301
|
+
// Row 0 is column header, PRs start at row 1
|
|
302
|
+
const listRow = s.reviewListScrollOffset + contentRow;
|
|
303
|
+
const flatIdx = listRow - 1; // subtract column header
|
|
304
|
+
if (flatIdx >= 0 && flatIdx < s.flatReviews.length) {
|
|
305
|
+
dispatch({ type: "REVIEW_SELECT", index: flatIdx });
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (s.flatIssues.length === 0)
|
|
310
|
+
return;
|
|
267
311
|
const listRow = s.listScrollOffset + contentRow;
|
|
268
312
|
const flatIdx = getFlatIndexForListRow(s.groups, listRow);
|
|
269
313
|
if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
|
|
@@ -306,6 +350,22 @@ export default function Dashboard() {
|
|
|
306
350
|
dispatch({ type: "SCROLL_LIST", offset });
|
|
307
351
|
}
|
|
308
352
|
}, [state.selectedIndex, state.groups, contentHeight, state.listScrollOffset]);
|
|
353
|
+
// ── Review list scroll tracking ──────────────────────────────────
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
// Row index = 1 (column header) + flatIndex
|
|
356
|
+
const rowIdx = 1 + state.reviewSelectedIndex;
|
|
357
|
+
const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
|
|
358
|
+
let offset = state.reviewListScrollOffset;
|
|
359
|
+
if (rowIdx < offset) {
|
|
360
|
+
offset = Math.max(0, rowIdx - 1);
|
|
361
|
+
}
|
|
362
|
+
else if (rowIdx >= offset + maxVisible) {
|
|
363
|
+
offset = rowIdx - maxVisible + 2;
|
|
364
|
+
}
|
|
365
|
+
if (offset !== state.reviewListScrollOffset) {
|
|
366
|
+
dispatch({ type: "REVIEW_SCROLL_LIST", offset });
|
|
367
|
+
}
|
|
368
|
+
}, [state.reviewSelectedIndex, state.flatReviews, contentHeight, state.reviewListScrollOffset]);
|
|
309
369
|
// ── Actions ───────────────────────────────────────────────────────
|
|
310
370
|
const launchWorkInTmux = useCallback((di, mode, worktreePath) => {
|
|
311
371
|
const windowName = di.issue.identifier;
|
|
@@ -913,11 +973,227 @@ export default function Dashboard() {
|
|
|
913
973
|
}
|
|
914
974
|
return;
|
|
915
975
|
}
|
|
976
|
+
// Tab switching (only when no overlay is active)
|
|
977
|
+
if (key.tab) {
|
|
978
|
+
dispatch({
|
|
979
|
+
type: "SET_TAB",
|
|
980
|
+
tab: state.activeTab === "issues" ? "reviews" : "issues",
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (input === "1") {
|
|
985
|
+
dispatch({ type: "SET_TAB", tab: "issues" });
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (input === "2") {
|
|
989
|
+
dispatch({ type: "SET_TAB", tab: "reviews" });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
916
992
|
// Quit
|
|
917
993
|
if (input === "q") {
|
|
918
994
|
exit();
|
|
919
995
|
return;
|
|
920
996
|
}
|
|
997
|
+
// Refresh (shared across tabs)
|
|
998
|
+
if (input === "R") {
|
|
999
|
+
refresh();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// ── Reviews tab keyboard ─────────────────────────────────
|
|
1003
|
+
if (state.activeTab === "reviews") {
|
|
1004
|
+
const maxReviewIdx = state.flatReviews.length - 1;
|
|
1005
|
+
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
1006
|
+
const next = Math.min(state.reviewSelectedIndex + 1, maxReviewIdx);
|
|
1007
|
+
dispatch({ type: "REVIEW_SELECT", index: next });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (input === "k" || (key.upArrow && !key.shift)) {
|
|
1011
|
+
const prev = Math.max(state.reviewSelectedIndex - 1, 0);
|
|
1012
|
+
dispatch({ type: "REVIEW_SELECT", index: prev });
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (key.shift && key.downArrow) {
|
|
1016
|
+
dispatch({
|
|
1017
|
+
type: "REVIEW_SCROLL_DETAIL",
|
|
1018
|
+
offset: state.reviewDetailScrollOffset + 3,
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (key.shift && key.upArrow) {
|
|
1023
|
+
dispatch({
|
|
1024
|
+
type: "REVIEW_SCROLL_DETAIL",
|
|
1025
|
+
offset: Math.max(0, state.reviewDetailScrollOffset - 3),
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const ri = state.flatReviews[state.reviewSelectedIndex];
|
|
1030
|
+
if (!ri)
|
|
1031
|
+
return;
|
|
1032
|
+
// Open PR in browser
|
|
1033
|
+
if (input === "o") {
|
|
1034
|
+
if (ri.pr.url) {
|
|
1035
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
1036
|
+
execSync(`${openCmd} "${ri.pr.url}"`, { stdio: "ignore" });
|
|
1037
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
|
|
1038
|
+
}
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
// Create worktree from PR branch (checkout for local testing)
|
|
1042
|
+
if (input === "w") {
|
|
1043
|
+
if (ri.worktree) {
|
|
1044
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree already exists" });
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (!ri.branch) {
|
|
1048
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No branch info" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const repoRoot = repoRootRef.current;
|
|
1052
|
+
if (!repoRoot)
|
|
1053
|
+
return;
|
|
1054
|
+
if (state.creatingForTicket)
|
|
1055
|
+
return;
|
|
1056
|
+
const ticketId = extractTicketId(ri.branch);
|
|
1057
|
+
if (!ticketId) {
|
|
1058
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No ticket ID in branch" });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
dispatch({ type: "CREATION_START", ticketId });
|
|
1062
|
+
(async () => {
|
|
1063
|
+
try {
|
|
1064
|
+
dispatch({ type: "CREATION_LOG", logs: `Fetching ${ri.branch}...\n` });
|
|
1065
|
+
await execAsync(`git fetch origin ${ri.branch}`, { cwd: repoRoot });
|
|
1066
|
+
dispatch({ type: "CREATION_LOG", logs: `Creating worktree...\n` });
|
|
1067
|
+
const result = await createWorktree(ri.branch, ri.baseBranch ?? getDefaultBranch(), repoRoot);
|
|
1068
|
+
if (!result.success || !result.path) {
|
|
1069
|
+
dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
|
|
1070
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${result.error}` });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
dispatch({ type: "CREATION_LOG", logs: `Worktree at ${result.path}\n` });
|
|
1074
|
+
// Run init script if available
|
|
1075
|
+
if (hasInitScript(repoRoot)) {
|
|
1076
|
+
const initScript = getInitScriptPath(repoRoot);
|
|
1077
|
+
let canExecute = true;
|
|
1078
|
+
try {
|
|
1079
|
+
fs.accessSync(initScript, fs.constants.X_OK);
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
dispatch({ type: "CREATION_LOG", logs: "init.sh not executable, skipping\n" });
|
|
1083
|
+
canExecute = false;
|
|
1084
|
+
}
|
|
1085
|
+
if (canExecute) {
|
|
1086
|
+
dispatch({ type: "CREATION_LOG", logs: "Running init.sh...\n" });
|
|
1087
|
+
let lastLen = 0;
|
|
1088
|
+
const initResult = await spawnAsync(initScript, [], {
|
|
1089
|
+
cwd: result.path,
|
|
1090
|
+
env: {
|
|
1091
|
+
...process.env,
|
|
1092
|
+
SANTREE_WORKTREE_PATH: result.path,
|
|
1093
|
+
SANTREE_REPO_ROOT: repoRoot,
|
|
1094
|
+
},
|
|
1095
|
+
onOutput: (output) => {
|
|
1096
|
+
const delta = output.slice(lastLen);
|
|
1097
|
+
if (delta)
|
|
1098
|
+
dispatch({ type: "CREATION_LOG", logs: delta });
|
|
1099
|
+
lastLen = output.length;
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
if (initResult.code !== 0) {
|
|
1103
|
+
dispatch({
|
|
1104
|
+
type: "CREATION_LOG",
|
|
1105
|
+
logs: `\ninit.sh exited with code ${initResult.code}\n`,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
dispatch({ type: "CREATION_LOG", logs: "\nSetup complete!\n" });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
dispatch({ type: "CREATION_DONE" });
|
|
1114
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Worktree created for ${ticketId}` });
|
|
1115
|
+
// Open in editor automatically
|
|
1116
|
+
const editor = process.env.SANTREE_EDITOR || "code";
|
|
1117
|
+
spawn(editor, [result.path], { detached: true, stdio: "ignore" }).unref();
|
|
1118
|
+
refresh();
|
|
1119
|
+
}
|
|
1120
|
+
catch (e) {
|
|
1121
|
+
dispatch({ type: "CREATION_ERROR", error: e?.message ?? "Failed" });
|
|
1122
|
+
}
|
|
1123
|
+
})();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
// Open in editor
|
|
1127
|
+
if (input === "e") {
|
|
1128
|
+
if (!ri.worktree) {
|
|
1129
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree (press w to checkout)" });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const editor = process.env.SANTREE_EDITOR || "code";
|
|
1133
|
+
spawn(editor, [ri.worktree.path], { detached: true, stdio: "ignore" }).unref();
|
|
1134
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
// AI Review in tmux
|
|
1138
|
+
if (input === "r") {
|
|
1139
|
+
if (!ri.worktree) {
|
|
1140
|
+
dispatch({
|
|
1141
|
+
type: "SET_ACTION_MESSAGE",
|
|
1142
|
+
message: "No worktree (press w to checkout first)",
|
|
1143
|
+
});
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (isInTmux()) {
|
|
1147
|
+
const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
|
|
1148
|
+
try {
|
|
1149
|
+
execSync(`tmux new-window -n "${windowName}" -c "${ri.worktree.path}"`, {
|
|
1150
|
+
stdio: "ignore",
|
|
1151
|
+
});
|
|
1152
|
+
execSync("sleep 0.1", { stdio: "ignore" });
|
|
1153
|
+
execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, {
|
|
1154
|
+
stdio: "ignore",
|
|
1155
|
+
});
|
|
1156
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched AI review in tmux" });
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
leaveAltScreen();
|
|
1164
|
+
console.log(`SANTREE_CD:${ri.worktree.path}`);
|
|
1165
|
+
exit();
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
// Delete worktree
|
|
1170
|
+
if (input === "d") {
|
|
1171
|
+
if (!ri.worktree || !ri.branch) {
|
|
1172
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to remove" });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const repoRoot = repoRootRef.current;
|
|
1176
|
+
if (!repoRoot)
|
|
1177
|
+
return;
|
|
1178
|
+
const ticketId = extractTicketId(ri.branch);
|
|
1179
|
+
if (!ticketId)
|
|
1180
|
+
return;
|
|
1181
|
+
dispatch({ type: "DELETE_START", ticketId });
|
|
1182
|
+
const force = ri.worktree.dirty;
|
|
1183
|
+
removeWorktree(ri.branch, repoRoot, force).then((result) => {
|
|
1184
|
+
dispatch({ type: "DELETE_DONE" });
|
|
1185
|
+
if (result.success) {
|
|
1186
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Removed worktree` });
|
|
1187
|
+
refresh();
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${result.error}` });
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
return; // prevent fallthrough to issues actions
|
|
1196
|
+
}
|
|
921
1197
|
const maxIndex = state.flatIssues.length - 1;
|
|
922
1198
|
// Navigation
|
|
923
1199
|
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
@@ -1129,11 +1405,6 @@ export default function Dashboard() {
|
|
|
1129
1405
|
dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
|
|
1130
1406
|
return;
|
|
1131
1407
|
}
|
|
1132
|
-
// Refresh
|
|
1133
|
-
if (input === "R") {
|
|
1134
|
-
refresh();
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
1408
|
}, { isActive: state.overlay !== "commit" || state.commitPhase !== "awaiting-message" });
|
|
1138
1409
|
// ── Render ─────────────────────────────────────────────────────────
|
|
1139
1410
|
if (state.loading) {
|
|
@@ -1142,14 +1413,15 @@ export default function Dashboard() {
|
|
|
1142
1413
|
if (state.error) {
|
|
1143
1414
|
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
|
|
1144
1415
|
}
|
|
1145
|
-
if (state.flatIssues.length === 0) {
|
|
1146
|
-
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "No active issues assigned to you" }), _jsx(Text, { dimColor: true, children: "Press R to refresh or q to quit" })] }) }));
|
|
1147
|
-
}
|
|
1148
1416
|
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
1149
|
-
|
|
1417
|
+
const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
|
|
1418
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
|
|
1150
1419
|
const selected = idx === state.baseSelectIndex;
|
|
1151
1420
|
const defaultBranch = getDefaultBranch();
|
|
1152
1421
|
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
1153
1422
|
return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
|
|
1154
|
-
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: _jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket }) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.
|
|
1423
|
+
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
|
|
1424
|
+
.split("\n")
|
|
1425
|
+
.slice(-(contentHeight - 1))
|
|
1426
|
+
.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, scrollOffset: state.detailScrollOffset })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
|
|
1155
1427
|
}
|
package/dist/lib/ai.js
CHANGED
|
@@ -143,6 +143,9 @@ export function launchAgent(prompt, opts) {
|
|
|
143
143
|
throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
|
|
144
144
|
}
|
|
145
145
|
const args = [];
|
|
146
|
+
if (process.env.SANTREE_SKIP_PERMISSIONS) {
|
|
147
|
+
args.push("--dangerously-skip-permissions");
|
|
148
|
+
}
|
|
146
149
|
if (opts?.planMode) {
|
|
147
150
|
args.push("--permission-mode", "plan");
|
|
148
151
|
}
|
|
@@ -167,7 +170,8 @@ export function runAgent(prompt) {
|
|
|
167
170
|
if (!bin) {
|
|
168
171
|
throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
|
|
169
172
|
}
|
|
170
|
-
const
|
|
173
|
+
const skipPerms = process.env.SANTREE_SKIP_PERMISSIONS ? ["--dangerously-skip-permissions"] : [];
|
|
174
|
+
const result = spawnSync(bin, [...skipPerms, "-p", "--output-format", "text", "--", promptArg(prompt)], {
|
|
171
175
|
encoding: "utf-8",
|
|
172
176
|
maxBuffer: 10 * 1024 * 1024,
|
|
173
177
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EnrichedReviewPR } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
item: EnrichedReviewPR | null;
|
|
4
|
+
scrollOffset: number;
|
|
5
|
+
height: number;
|
|
6
|
+
width: number;
|
|
7
|
+
}
|
|
8
|
+
export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function relativeTime(dateStr) {
|
|
4
|
+
const now = Date.now();
|
|
5
|
+
const then = new Date(dateStr).getTime();
|
|
6
|
+
const diffMs = now - then;
|
|
7
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
8
|
+
if (minutes < 1)
|
|
9
|
+
return "just now";
|
|
10
|
+
if (minutes < 60)
|
|
11
|
+
return `${minutes}m ago`;
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
if (hours < 24)
|
|
14
|
+
return `${hours}h ago`;
|
|
15
|
+
const days = Math.floor(hours / 24);
|
|
16
|
+
if (days < 30)
|
|
17
|
+
return `${days}d ago`;
|
|
18
|
+
const months = Math.floor(days / 30);
|
|
19
|
+
return `${months}mo ago`;
|
|
20
|
+
}
|
|
21
|
+
function buildActions(item) {
|
|
22
|
+
const items = [];
|
|
23
|
+
if (item.worktree) {
|
|
24
|
+
items.push({ key: "r", label: "AI Review", color: "cyan" });
|
|
25
|
+
items.push({ key: "e", label: "Editor", color: "cyan" });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
items.push({ key: "w", label: "Checkout", color: "cyan" });
|
|
29
|
+
}
|
|
30
|
+
items.push({ key: "o", label: "Open PR", color: "gray" });
|
|
31
|
+
if (item.worktree) {
|
|
32
|
+
items.push({ key: "d", label: "Remove", color: "red" });
|
|
33
|
+
}
|
|
34
|
+
return items;
|
|
35
|
+
}
|
|
36
|
+
export default function ReviewDetailPanel({ item, scrollOffset, height, width }) {
|
|
37
|
+
if (!item) {
|
|
38
|
+
return (_jsx(Box, { width: width, height: height, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No PR selected" }) }));
|
|
39
|
+
}
|
|
40
|
+
const { pr } = item;
|
|
41
|
+
const lines = [];
|
|
42
|
+
const rule = "\u2500".repeat(width);
|
|
43
|
+
// ── Hero ──────────────────────────────────────────────────────────
|
|
44
|
+
lines.push({ text: `#${pr.number} ${pr.title}`, bold: true });
|
|
45
|
+
const meta = [`by ${pr.author.login}`];
|
|
46
|
+
if (pr.isDraft)
|
|
47
|
+
meta.push("draft");
|
|
48
|
+
meta.push(relativeTime(pr.updatedAt));
|
|
49
|
+
lines.push({ text: meta.join(" \u00b7 "), color: "cyan" });
|
|
50
|
+
// ── Changes ──────────────────────────────────────────────────────
|
|
51
|
+
if (item.changedFiles > 0) {
|
|
52
|
+
lines.push({
|
|
53
|
+
text: `${item.changedFiles} files +${item.additions} -${item.deletions}`,
|
|
54
|
+
color: "green",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// ── Branch ───────────────────────────────────────────────────────
|
|
58
|
+
if (item.branch) {
|
|
59
|
+
lines.push({ text: rule, dim: true });
|
|
60
|
+
lines.push({ text: "BRANCH", dim: true });
|
|
61
|
+
lines.push({ text: ` ${item.branch}` });
|
|
62
|
+
if (item.baseBranch) {
|
|
63
|
+
lines.push({ text: ` base: ${item.baseBranch}`, dim: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── Worktree ─────────────────────────────────────────────────────
|
|
67
|
+
if (item.worktree) {
|
|
68
|
+
lines.push({ text: rule, dim: true });
|
|
69
|
+
lines.push({ text: "WORKTREE", dim: true });
|
|
70
|
+
lines.push({ text: ` ${item.worktree.path}`, dim: true });
|
|
71
|
+
const statusParts = [];
|
|
72
|
+
if (item.worktree.dirty)
|
|
73
|
+
statusParts.push("dirty");
|
|
74
|
+
if (item.worktree.commitsAhead > 0)
|
|
75
|
+
statusParts.push(`+${item.worktree.commitsAhead} ahead`);
|
|
76
|
+
if (statusParts.length > 0) {
|
|
77
|
+
lines.push({ text: ` ${statusParts.join(" ")}`, color: "yellow" });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push({ text: " \u2713 clean", color: "green" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ── Description ──────────────────────────────────────────────────
|
|
84
|
+
if (item.body) {
|
|
85
|
+
lines.push({ text: rule, dim: true });
|
|
86
|
+
lines.push({ text: "DESCRIPTION", dim: true });
|
|
87
|
+
lines.push({ text: "" });
|
|
88
|
+
for (const line of item.body.trimEnd().split("\n")) {
|
|
89
|
+
lines.push({ text: line });
|
|
90
|
+
}
|
|
91
|
+
lines.push({ text: "" });
|
|
92
|
+
}
|
|
93
|
+
// ── Checks ───────────────────────────────────────────────────────
|
|
94
|
+
if (item.checks && item.checks.length > 0) {
|
|
95
|
+
const passCount = item.checks.filter((c) => c.bucket === "pass").length;
|
|
96
|
+
lines.push({ text: rule, dim: true });
|
|
97
|
+
lines.push({ text: `CHECKS ${passCount}/${item.checks.length} passing`, dim: true });
|
|
98
|
+
for (const check of item.checks) {
|
|
99
|
+
if (check.bucket === "pass") {
|
|
100
|
+
lines.push({ text: ` \u2713 ${check.name}`, color: "green" });
|
|
101
|
+
}
|
|
102
|
+
else if (check.bucket === "fail") {
|
|
103
|
+
const desc = check.description ? ` \u2014 ${check.description}` : "";
|
|
104
|
+
lines.push({ text: ` \u2717 ${check.name}${desc}`, color: "red" });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
lines.push({ text: ` \u25cf ${check.name} (pending)`, color: "yellow" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── Reviews ──────────────────────────────────────────────────────
|
|
112
|
+
if (item.reviews && item.reviews.length > 0) {
|
|
113
|
+
lines.push({ text: rule, dim: true });
|
|
114
|
+
lines.push({ text: "REVIEWS", dim: true });
|
|
115
|
+
for (const review of item.reviews) {
|
|
116
|
+
const author = review.author.login;
|
|
117
|
+
const rc = review.state === "APPROVED"
|
|
118
|
+
? "green"
|
|
119
|
+
: review.state === "CHANGES_REQUESTED"
|
|
120
|
+
? "red"
|
|
121
|
+
: "yellow";
|
|
122
|
+
lines.push({ text: ` ${author} ${review.state}`, color: rc });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// ── Comments ─────────────────────────────────────────────────────
|
|
126
|
+
if (item.comments && item.comments.length > 0) {
|
|
127
|
+
lines.push({ text: rule, dim: true });
|
|
128
|
+
lines.push({ text: `COMMENTS ${item.comments.length}`, dim: true });
|
|
129
|
+
// Show last 5 comments
|
|
130
|
+
const recent = item.comments.slice(-5);
|
|
131
|
+
for (const comment of recent) {
|
|
132
|
+
lines.push({ text: "" });
|
|
133
|
+
lines.push({
|
|
134
|
+
text: ` ${comment.author} ${relativeTime(comment.createdAt)}`,
|
|
135
|
+
color: "cyan",
|
|
136
|
+
});
|
|
137
|
+
// Truncate long comments to ~4 lines
|
|
138
|
+
const bodyLines = comment.body.trimEnd().split("\n");
|
|
139
|
+
const maxLines = 4;
|
|
140
|
+
for (let i = 0; i < Math.min(bodyLines.length, maxLines); i++) {
|
|
141
|
+
lines.push({ text: ` ${bodyLines[i]}` });
|
|
142
|
+
}
|
|
143
|
+
if (bodyLines.length > maxLines) {
|
|
144
|
+
lines.push({ text: ` +${bodyLines.length - maxLines} more lines`, dim: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── Build actions footer ─────────────────────────────────────────
|
|
149
|
+
const actionItems = buildActions(item);
|
|
150
|
+
const actionsHeight = 2; // separator + action row
|
|
151
|
+
const scrollableHeight = height - actionsHeight;
|
|
152
|
+
const totalLines = lines.length;
|
|
153
|
+
const canScroll = totalLines > scrollableHeight;
|
|
154
|
+
const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
|
|
155
|
+
const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
|
|
156
|
+
const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
|
|
157
|
+
let scrollArrow = null;
|
|
158
|
+
if (canScroll) {
|
|
159
|
+
const atTop = clampedOffset === 0;
|
|
160
|
+
const atBottom = clampedOffset + contentRows >= totalLines;
|
|
161
|
+
scrollArrow = atTop ? "\u2193 scroll" : atBottom ? "\u2191 scroll" : "\u2191\u2193 scroll";
|
|
162
|
+
}
|
|
163
|
+
// Truncate lines to panel width to prevent overflow into left pane
|
|
164
|
+
const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
|
|
165
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(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 }) }), _jsx(Box, { children: actionItems.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))) })] }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EnrichedReviewPR } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
flatReviews: EnrichedReviewPR[];
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
scrollOffset: number;
|
|
6
|
+
height: number;
|
|
7
|
+
width: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function getReviewListRowCount(flatReviews: EnrichedReviewPR[]): number;
|
|
10
|
+
export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function checksIndicator(checks) {
|
|
4
|
+
if (!checks || checks.length === 0)
|
|
5
|
+
return { text: "-", color: "gray" };
|
|
6
|
+
if (checks.some((c) => c.bucket === "fail"))
|
|
7
|
+
return { text: "\u2717", color: "red" };
|
|
8
|
+
if (checks.every((c) => c.bucket === "pass"))
|
|
9
|
+
return { text: "\u2713", color: "green" };
|
|
10
|
+
return { text: "\u25cf", color: "yellow" };
|
|
11
|
+
}
|
|
12
|
+
const FOOTER_HEIGHT = 2;
|
|
13
|
+
const HEADER_ROWS = 1;
|
|
14
|
+
export function getReviewListRowCount(flatReviews) {
|
|
15
|
+
return HEADER_ROWS + flatReviews.length;
|
|
16
|
+
}
|
|
17
|
+
export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }) {
|
|
18
|
+
const listHeight = height - FOOTER_HEIGHT;
|
|
19
|
+
const numColWidth = 6;
|
|
20
|
+
const authorColWidth = 12;
|
|
21
|
+
const changesColWidth = 10;
|
|
22
|
+
const checksColWidth = 2;
|
|
23
|
+
const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
|
|
24
|
+
const titleMaxWidth = Math.max(width - fixedWidth, 10);
|
|
25
|
+
const footerRule = "\u2500".repeat(width);
|
|
26
|
+
const totalRows = HEADER_ROWS + flatReviews.length;
|
|
27
|
+
const visibleStart = scrollOffset;
|
|
28
|
+
const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (let rowIdx = visibleStart; rowIdx < visibleEnd; rowIdx++) {
|
|
31
|
+
if (rowIdx === 0) {
|
|
32
|
+
rows.push(_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "#".padEnd(numColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "".padEnd(titleMaxWidth) }), _jsx(Text, { dimColor: true, children: "author".padStart(authorColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "changes".padStart(changesColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const flatIndex = rowIdx - HEADER_ROWS;
|
|
36
|
+
const item = flatReviews[flatIndex];
|
|
37
|
+
if (!item)
|
|
38
|
+
continue;
|
|
39
|
+
const { pr } = item;
|
|
40
|
+
const selected = flatIndex === selectedIndex;
|
|
41
|
+
const cursor = selected ? ">" : " ";
|
|
42
|
+
const num = `#${pr.number}`;
|
|
43
|
+
const title = pr.title.length > titleMaxWidth ? pr.title.slice(0, titleMaxWidth - 1) + "\u2026" : pr.title;
|
|
44
|
+
const author = pr.author.login.length > authorColWidth
|
|
45
|
+
? pr.author.login.slice(0, authorColWidth - 1) + "\u2026"
|
|
46
|
+
: pr.author.login;
|
|
47
|
+
const changes = `+${item.additions} -${item.deletions}`;
|
|
48
|
+
const ci = checksIndicator(item.checks);
|
|
49
|
+
const bg = selected ? "#1e3a5f" : undefined;
|
|
50
|
+
rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
|
|
51
|
+
}
|
|
52
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _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" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
|
|
53
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { DashboardIssue, ProjectGroup } from "./types.js";
|
|
1
|
+
import type { DashboardIssue, ProjectGroup, EnrichedReviewPR } from "./types.js";
|
|
2
2
|
export declare function loadDashboardData(repoRoot: string): Promise<{
|
|
3
3
|
groups: ProjectGroup[];
|
|
4
4
|
flatIssues: DashboardIssue[];
|
|
5
5
|
}>;
|
|
6
|
+
export declare function loadReviewsData(repoRoot: string): Promise<{
|
|
7
|
+
flatReviews: EnrichedReviewPR[];
|
|
8
|
+
}>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAliveInTmux, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
|
|
2
|
-
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, } from "../github.js";
|
|
2
|
+
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
|
|
3
3
|
import { fetchAssignedIssues } from "../linear.js";
|
|
4
4
|
export async function loadDashboardData(repoRoot) {
|
|
5
5
|
// Fetch issues and worktrees in parallel
|
|
@@ -225,3 +225,71 @@ export async function loadDashboardData(repoRoot) {
|
|
|
225
225
|
const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
|
|
226
226
|
return { groups, flatIssues };
|
|
227
227
|
}
|
|
228
|
+
export async function loadReviewsData(repoRoot) {
|
|
229
|
+
const repo = await getRepoNameAsync();
|
|
230
|
+
if (!repo)
|
|
231
|
+
return { flatReviews: [] };
|
|
232
|
+
const prs = await getReviewRequestedPRsAsync(repo);
|
|
233
|
+
prs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
234
|
+
// Build worktree map for matching PR branches to local worktrees
|
|
235
|
+
const worktrees = listWorktrees();
|
|
236
|
+
const branchToWt = new Map();
|
|
237
|
+
for (const wt of worktrees) {
|
|
238
|
+
if (wt.branch)
|
|
239
|
+
branchToWt.set(wt.branch, { path: wt.path, branch: wt.branch });
|
|
240
|
+
}
|
|
241
|
+
const metadata = readAllMetadata(repoRoot);
|
|
242
|
+
// Enrich each PR in parallel
|
|
243
|
+
const enriched = await Promise.all(prs.map(async (pr) => {
|
|
244
|
+
const [view, checks, reviews, comments] = await Promise.all([
|
|
245
|
+
getPRViewAsync(pr.number),
|
|
246
|
+
getPRChecksAsync(String(pr.number)),
|
|
247
|
+
getPRReviewsAsync(String(pr.number)),
|
|
248
|
+
getPRConversationCommentsAsync(String(pr.number)),
|
|
249
|
+
]);
|
|
250
|
+
// Check if we have a local worktree for this PR's branch
|
|
251
|
+
let worktreeInfo = null;
|
|
252
|
+
const branch = view?.headRefName ?? null;
|
|
253
|
+
if (branch) {
|
|
254
|
+
const wt = branchToWt.get(branch);
|
|
255
|
+
if (wt) {
|
|
256
|
+
const ticketId = extractTicketId(branch);
|
|
257
|
+
const base = getBaseBranch(branch);
|
|
258
|
+
const [gitStatusOutput, ahead] = await Promise.all([
|
|
259
|
+
getGitStatusAsync(wt.path),
|
|
260
|
+
getCommitsAheadAsync(wt.path, base),
|
|
261
|
+
]);
|
|
262
|
+
let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
|
|
263
|
+
if (sessState && ticketId && !isSessionAliveInTmux(ticketId)) {
|
|
264
|
+
clearSessionState(repoRoot, ticketId);
|
|
265
|
+
sessState = null;
|
|
266
|
+
}
|
|
267
|
+
const ss = sessState?.state ?? null;
|
|
268
|
+
worktreeInfo = {
|
|
269
|
+
path: wt.path,
|
|
270
|
+
branch: wt.branch,
|
|
271
|
+
dirty: Boolean(gitStatusOutput),
|
|
272
|
+
commitsAhead: ahead,
|
|
273
|
+
sessionId: ticketId ? (metadata[ticketId]?.session_id ?? null) : null,
|
|
274
|
+
gitStatus: gitStatusOutput,
|
|
275
|
+
sessionState: ss === "exited" ? null : ss,
|
|
276
|
+
sessionMessage: sessState?.message ?? null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
pr,
|
|
282
|
+
body: view?.body ?? null,
|
|
283
|
+
branch,
|
|
284
|
+
baseBranch: view?.baseRefName ?? null,
|
|
285
|
+
additions: view?.additions ?? 0,
|
|
286
|
+
deletions: view?.deletions ?? 0,
|
|
287
|
+
changedFiles: view?.changedFiles ?? 0,
|
|
288
|
+
checks,
|
|
289
|
+
reviews,
|
|
290
|
+
comments,
|
|
291
|
+
worktree: worktreeInfo,
|
|
292
|
+
};
|
|
293
|
+
}));
|
|
294
|
+
return { flatReviews: enriched };
|
|
295
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PRInfo, PRCheck, PRReview } from "../github.js";
|
|
1
|
+
import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
|
|
2
2
|
export interface LinearAssignedIssue {
|
|
3
3
|
identifier: string;
|
|
4
4
|
title: string;
|
|
@@ -43,15 +43,35 @@ export interface ProjectGroup {
|
|
|
43
43
|
id: string | null;
|
|
44
44
|
statusGroups: StatusGroup[];
|
|
45
45
|
}
|
|
46
|
+
export type ReviewPR = SearchPR;
|
|
47
|
+
export interface EnrichedReviewPR {
|
|
48
|
+
pr: SearchPR;
|
|
49
|
+
body: string | null;
|
|
50
|
+
branch: string | null;
|
|
51
|
+
baseBranch: string | null;
|
|
52
|
+
additions: number;
|
|
53
|
+
deletions: number;
|
|
54
|
+
changedFiles: number;
|
|
55
|
+
checks: PRCheck[] | null;
|
|
56
|
+
reviews: PRReview[] | null;
|
|
57
|
+
comments: PRConversationComment[] | null;
|
|
58
|
+
worktree: WorktreeInfo | null;
|
|
59
|
+
}
|
|
60
|
+
export type DashboardTab = "issues" | "reviews";
|
|
46
61
|
export type ActionOverlay = "mode-select" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
47
62
|
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
48
63
|
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
|
|
49
64
|
export interface DashboardState {
|
|
65
|
+
activeTab: DashboardTab;
|
|
50
66
|
groups: ProjectGroup[];
|
|
51
67
|
flatIssues: DashboardIssue[];
|
|
52
68
|
selectedIndex: number;
|
|
53
69
|
listScrollOffset: number;
|
|
54
70
|
detailScrollOffset: number;
|
|
71
|
+
flatReviews: EnrichedReviewPR[];
|
|
72
|
+
reviewSelectedIndex: number;
|
|
73
|
+
reviewListScrollOffset: number;
|
|
74
|
+
reviewDetailScrollOffset: number;
|
|
55
75
|
loading: boolean;
|
|
56
76
|
refreshing: boolean;
|
|
57
77
|
error: string | null;
|
|
@@ -180,6 +200,21 @@ export type DashboardAction = {
|
|
|
180
200
|
chosen: string;
|
|
181
201
|
} | {
|
|
182
202
|
type: "BASE_SELECT_DONE";
|
|
203
|
+
} | {
|
|
204
|
+
type: "SET_TAB";
|
|
205
|
+
tab: DashboardTab;
|
|
206
|
+
} | {
|
|
207
|
+
type: "SET_REVIEWS_DATA";
|
|
208
|
+
flatReviews: EnrichedReviewPR[];
|
|
209
|
+
} | {
|
|
210
|
+
type: "REVIEW_SELECT";
|
|
211
|
+
index: number;
|
|
212
|
+
} | {
|
|
213
|
+
type: "REVIEW_SCROLL_LIST";
|
|
214
|
+
offset: number;
|
|
215
|
+
} | {
|
|
216
|
+
type: "REVIEW_SCROLL_DETAIL";
|
|
217
|
+
offset: number;
|
|
183
218
|
};
|
|
184
219
|
export declare const initialState: DashboardState;
|
|
185
220
|
export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// ── State management ──────────────────────────────────────────────────
|
|
2
2
|
export const initialState = {
|
|
3
|
+
activeTab: "issues",
|
|
3
4
|
groups: [],
|
|
4
5
|
flatIssues: [],
|
|
5
6
|
selectedIndex: 0,
|
|
6
7
|
listScrollOffset: 0,
|
|
7
8
|
detailScrollOffset: 0,
|
|
9
|
+
flatReviews: [],
|
|
10
|
+
reviewSelectedIndex: 0,
|
|
11
|
+
reviewListScrollOffset: 0,
|
|
12
|
+
reviewDetailScrollOffset: 0,
|
|
8
13
|
loading: true,
|
|
9
14
|
refreshing: false,
|
|
10
15
|
error: null,
|
|
@@ -214,6 +219,29 @@ export function reducer(state, action) {
|
|
|
214
219
|
baseSelectOptions: [],
|
|
215
220
|
baseSelectIndex: 0,
|
|
216
221
|
};
|
|
222
|
+
case "SET_TAB":
|
|
223
|
+
return { ...state, activeTab: action.tab };
|
|
224
|
+
case "SET_REVIEWS_DATA": {
|
|
225
|
+
const prevNum = state.flatReviews[state.reviewSelectedIndex]?.pr.number;
|
|
226
|
+
let newIdx = 0;
|
|
227
|
+
if (prevNum !== undefined) {
|
|
228
|
+
const found = action.flatReviews.findIndex((p) => p.pr.number === prevNum);
|
|
229
|
+
if (found >= 0)
|
|
230
|
+
newIdx = found;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
...state,
|
|
234
|
+
flatReviews: action.flatReviews,
|
|
235
|
+
reviewSelectedIndex: newIdx,
|
|
236
|
+
reviewDetailScrollOffset: 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
case "REVIEW_SELECT":
|
|
240
|
+
return { ...state, reviewSelectedIndex: action.index, reviewDetailScrollOffset: 0 };
|
|
241
|
+
case "REVIEW_SCROLL_LIST":
|
|
242
|
+
return { ...state, reviewListScrollOffset: action.offset };
|
|
243
|
+
case "REVIEW_SCROLL_DETAIL":
|
|
244
|
+
return { ...state, reviewDetailScrollOffset: action.offset };
|
|
217
245
|
default:
|
|
218
246
|
return state;
|
|
219
247
|
}
|
package/dist/lib/github.d.ts
CHANGED
|
@@ -100,3 +100,44 @@ export interface PRReviewComment {
|
|
|
100
100
|
in_reply_to_id?: number;
|
|
101
101
|
id: number;
|
|
102
102
|
}
|
|
103
|
+
export interface SearchPR {
|
|
104
|
+
number: number;
|
|
105
|
+
title: string;
|
|
106
|
+
repository: {
|
|
107
|
+
nameWithOwner: string;
|
|
108
|
+
};
|
|
109
|
+
author: {
|
|
110
|
+
login: string;
|
|
111
|
+
};
|
|
112
|
+
url: string;
|
|
113
|
+
createdAt: string;
|
|
114
|
+
updatedAt: string;
|
|
115
|
+
isDraft: boolean;
|
|
116
|
+
commentsCount: number;
|
|
117
|
+
}
|
|
118
|
+
export interface PRViewDetail {
|
|
119
|
+
body: string;
|
|
120
|
+
headRefName: string;
|
|
121
|
+
baseRefName: string;
|
|
122
|
+
additions: number;
|
|
123
|
+
deletions: number;
|
|
124
|
+
changedFiles: number;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Fetch detailed PR info by number (async).
|
|
128
|
+
* Returns body, branch names, and change stats.
|
|
129
|
+
*/
|
|
130
|
+
export declare function getPRViewAsync(prNumber: number): Promise<PRViewDetail | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Get the current repo's `owner/name` from the GitHub CLI.
|
|
133
|
+
* Returns null if not in a GitHub repo or gh is unavailable.
|
|
134
|
+
*/
|
|
135
|
+
export declare function getRepoNameAsync(): Promise<string | null>;
|
|
136
|
+
/**
|
|
137
|
+
* Fetch open PRs where the current user's review is still pending (async).
|
|
138
|
+
* Uses the GitHub search API with `user-review-requested:@me` which excludes
|
|
139
|
+
* PRs where you've already submitted a review (unlike `review-requested` which
|
|
140
|
+
* includes stale requests). Scoped to a specific repo.
|
|
141
|
+
* Returns an empty array on failure.
|
|
142
|
+
*/
|
|
143
|
+
export declare function getReviewRequestedPRsAsync(repo: string): Promise<SearchPR[]>;
|
package/dist/lib/github.js
CHANGED
|
@@ -230,3 +230,58 @@ export async function getFailedCheckDetailsAsync(check) {
|
|
|
230
230
|
}
|
|
231
231
|
return detail;
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Fetch detailed PR info by number (async).
|
|
235
|
+
* Returns body, branch names, and change stats.
|
|
236
|
+
*/
|
|
237
|
+
export async function getPRViewAsync(prNumber) {
|
|
238
|
+
try {
|
|
239
|
+
const { stdout } = await execAsync(`gh pr view ${prNumber} --json body,headRefName,baseRefName,additions,deletions,changedFiles`);
|
|
240
|
+
return JSON.parse(stdout);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Get the current repo's `owner/name` from the GitHub CLI.
|
|
248
|
+
* Returns null if not in a GitHub repo or gh is unavailable.
|
|
249
|
+
*/
|
|
250
|
+
export async function getRepoNameAsync() {
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await execAsync(`gh repo view --json nameWithOwner --jq .nameWithOwner`);
|
|
253
|
+
return stdout.trim() || null;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Fetch open PRs where the current user's review is still pending (async).
|
|
261
|
+
* Uses the GitHub search API with `user-review-requested:@me` which excludes
|
|
262
|
+
* PRs where you've already submitted a review (unlike `review-requested` which
|
|
263
|
+
* includes stale requests). Scoped to a specific repo.
|
|
264
|
+
* Returns an empty array on failure.
|
|
265
|
+
*/
|
|
266
|
+
export async function getReviewRequestedPRsAsync(repo) {
|
|
267
|
+
try {
|
|
268
|
+
const { stdout } = await execAsync(`gh api 'search/issues?q=is:open+is:pr+user-review-requested:@me+archived:false+repo:${repo}&per_page=100' --jq '.items'`);
|
|
269
|
+
const items = JSON.parse(stdout);
|
|
270
|
+
return items.map((item) => ({
|
|
271
|
+
number: item.number,
|
|
272
|
+
title: item.title,
|
|
273
|
+
repository: {
|
|
274
|
+
nameWithOwner: repo,
|
|
275
|
+
},
|
|
276
|
+
author: { login: item.user?.login ?? "unknown" },
|
|
277
|
+
url: item.html_url ?? item.url,
|
|
278
|
+
createdAt: item.created_at,
|
|
279
|
+
updatedAt: item.updated_at,
|
|
280
|
+
isDraft: item.draft ?? false,
|
|
281
|
+
commentsCount: item.comments ?? 0,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
package/dist/lib/linear.js
CHANGED
|
@@ -116,13 +116,17 @@ export async function startOAuthFlow() {
|
|
|
116
116
|
code_challenge_method: "S256",
|
|
117
117
|
});
|
|
118
118
|
const authUrl = `${LINEAR_AUTHORIZE_URL}?${params.toString()}`;
|
|
119
|
-
//
|
|
119
|
+
// Try to open browser, fall back to printing URL
|
|
120
120
|
const openCmd = process.platform === "darwin"
|
|
121
121
|
? "open"
|
|
122
122
|
: process.platform === "win32"
|
|
123
123
|
? "start"
|
|
124
124
|
: "xdg-open";
|
|
125
|
-
exec(`${openCmd} "${authUrl}"
|
|
125
|
+
exec(`${openCmd} "${authUrl}"`, (err) => {
|
|
126
|
+
if (err) {
|
|
127
|
+
console.error(`\nCouldn't open browser automatically. Open this URL manually:\n${authUrl}\n`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
126
130
|
});
|
|
127
131
|
// Timeout after 2 minutes
|
|
128
132
|
setTimeout(() => {
|