santree 0.2.9 → 0.2.10

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.
@@ -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 - 1; // 1 header
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 loadDashboardData(repoRoot);
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 issue in left pane
288
+ // Left-click press: select item in left pane
258
289
  const s = stateRef.current;
259
- if (s.loading || s.error || s.flatIssues.length === 0)
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
- 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] }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), 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) => {
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.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 })) })] }))] }));
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 result = spawnSync(bin, ["-p", "--output-format", "text", "--", promptArg(prompt)], {
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
  }
@@ -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[]>;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",