santree 0.4.0 → 0.5.1

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.
@@ -9,9 +9,10 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
- import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, } from "../lib/git.js";
12
+ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, } from "../lib/git.js";
13
13
  import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
+ import { getInstalledClaudeVersion } from "../lib/version.js";
15
16
  import { extractTicketId } from "../lib/git.js";
16
17
  import { getMultiplexer } from "../lib/multiplexer/index.js";
17
18
  import { getPRTemplate } from "../lib/github.js";
@@ -20,26 +21,83 @@ import { getTicketContent } from "../lib/linear.js";
20
21
  import * as os from "os";
21
22
  import { initialState, reducer } from "../lib/dashboard/types.js";
22
23
  import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
23
- import IssueList from "../lib/dashboard/IssueList.js";
24
- import DetailPanel from "../lib/dashboard/DetailPanel.js";
24
+ import IssueList, { buildIssueListRows } from "../lib/dashboard/IssueList.js";
25
+ import { detectTerminalTheme, getThemeForMode, } from "../lib/dashboard/theme.js";
26
+ import DetailPanel, { buildIssueActions } from "../lib/dashboard/DetailPanel.js";
25
27
  import ReviewList from "../lib/dashboard/ReviewList.js";
26
- import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
28
+ import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
27
29
  import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
28
30
  import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
31
+ import DiffOverlay, { flattenTreeFiles, computeDiffLayout, clampDiffLeftWidth, DIFF_DIVIDER_WIDTH, } from "../lib/dashboard/DiffOverlay.js";
32
+ import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getCachedLatestVersion, getLatestVersionFor, getCachedLatestVersionFor, isUpdateAvailable, } from "../lib/version.js";
29
33
  export const description = "Interactive dashboard of your Linear issues";
30
34
  const execAsync = promisify(exec);
31
- const CLAUDE_VERSION = (() => {
32
- const bin = path.join(os.homedir(), ".claude", "local", "claude");
33
- try {
34
- return (execSync(`${bin} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
35
- .trim()
36
- .split(" ")[0] ?? "");
37
- }
38
- catch {
39
- return "";
40
- }
41
- })();
35
+ // Resolved at module load — cheap. Honors cmux's bundled binary when running
36
+ // inside cmux so the header reflects the binary santree will actually use.
37
+ const CLAUDE_VERSION = getInstalledClaudeVersion() ?? "";
42
38
  // ── Helpers ───────────────────────────────────────────────────────────
39
+ /**
40
+ * Parse `git diff --name-status` output. Each line is a tab-separated record:
41
+ * M\tpath/to/file.ts
42
+ * R100\told/path\tnew/path
43
+ * For renames/copies, the status code has a similarity suffix we strip.
44
+ */
45
+ /**
46
+ * Pipe `git diff` output through an external tool (e.g. delta) and return the
47
+ * combined ANSI output. Uses spawn pipes — no shell — so the tool name is safe
48
+ * even though we already validate it in getDiffTool().
49
+ */
50
+ function runPipedDiff(cwd, mergeBase, filePath, tool) {
51
+ return new Promise((resolve, reject) => {
52
+ const git = spawn("git", ["-C", cwd, "diff", "--color=always", mergeBase, "--", filePath], {
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ });
55
+ const pager = spawn(tool, [], { stdio: ["pipe", "pipe", "pipe"] });
56
+ let out = "";
57
+ let err = "";
58
+ git.stdout.pipe(pager.stdin);
59
+ git.stderr.on("data", (d) => {
60
+ err += d.toString();
61
+ });
62
+ pager.stdout.on("data", (d) => {
63
+ out += d.toString();
64
+ });
65
+ pager.stderr.on("data", (d) => {
66
+ err += d.toString();
67
+ });
68
+ pager.on("error", reject);
69
+ git.on("error", reject);
70
+ pager.on("close", (code) => {
71
+ if (code !== 0 && !out) {
72
+ reject(new Error(err || `${tool} exited with code ${code}`));
73
+ }
74
+ else {
75
+ resolve(out);
76
+ }
77
+ });
78
+ });
79
+ }
80
+ function parseNameStatus(raw) {
81
+ const files = [];
82
+ for (const line of raw.split("\n")) {
83
+ if (!line.trim())
84
+ continue;
85
+ const parts = line.split("\t");
86
+ if (parts.length < 2)
87
+ continue;
88
+ const code = parts[0].charAt(0).toUpperCase();
89
+ const status = code === "M" || code === "A" || code === "D" || code === "R" || code === "C" || code === "U"
90
+ ? code
91
+ : "?";
92
+ if ((status === "R" || status === "C") && parts.length >= 3) {
93
+ files.push({ status, path: parts[2], oldPath: parts[1] });
94
+ }
95
+ else {
96
+ files.push({ status, path: parts[1] });
97
+ }
98
+ }
99
+ return files;
100
+ }
43
101
  function slugify(title) {
44
102
  return title
45
103
  .toLowerCase()
@@ -48,58 +106,30 @@ function slugify(title) {
48
106
  .slice(0, 40);
49
107
  }
50
108
  // ── Scroll helpers ────────────────────────────────────────────────────
51
- function countWithChildren(di) {
52
- let count = 1;
53
- if (di.children) {
54
- for (const child of di.children) {
55
- count += countWithChildren(child);
56
- }
57
- }
58
- return count;
59
- }
60
- function getRowIndexForFlatIndex(groups, flatIndex) {
61
- let row = 1; // skip column header row
62
- let issuesSeen = 0;
63
- for (const g of groups) {
64
- row++; // project header
65
- for (const sg of g.statusGroups) {
66
- row++; // status header
67
- for (const di of sg.issues) {
68
- const total = countWithChildren(di);
69
- if (flatIndex >= issuesSeen && flatIndex < issuesSeen + total) {
70
- // The target is within this issue or its children
71
- return row + (flatIndex - issuesSeen);
72
- }
73
- row += total;
74
- issuesSeen += total;
75
- }
76
- }
109
+ /**
110
+ * Walk the rendered list rows and return the absolute row index of the
111
+ * issue's main row (not its detail sub-rows). Used to keep the selected
112
+ * issue scrolled into view as `j`/`k` moves selection.
113
+ */
114
+ function getRowIndexForFlatIndex(groups, flatIssues, flatIndex) {
115
+ const rows = buildIssueListRows(groups, flatIssues);
116
+ for (let i = 0; i < rows.length; i++) {
117
+ const r = rows[i];
118
+ if (r.kind === "issue" && r.flatIndex === flatIndex)
119
+ return i;
77
120
  }
78
121
  return 0;
79
122
  }
80
- function getFlatIndexForListRow(groups, listRow) {
81
- if (listRow === 0)
82
- return null; // column header row
83
- let row = 1; // skip column header row
84
- let issuesSeen = 0;
85
- for (const g of groups) {
86
- if (row === listRow)
87
- return null; // project header row
88
- row++;
89
- for (const sg of g.statusGroups) {
90
- if (row === listRow)
91
- return null; // status header row
92
- row++;
93
- for (const di of sg.issues) {
94
- const total = countWithChildren(di);
95
- if (listRow >= row && listRow < row + total) {
96
- return issuesSeen + (listRow - row);
97
- }
98
- row += total;
99
- issuesSeen += total;
100
- }
101
- }
102
- }
123
+ /**
124
+ * Map a clicked list row back to its parent issue's flat index, if any.
125
+ */
126
+ function getFlatIndexForListRow(groups, flatIssues, listRow) {
127
+ const rows = buildIssueListRows(groups, flatIssues);
128
+ const row = rows[listRow];
129
+ if (!row)
130
+ return null;
131
+ if (row.kind === "issue")
132
+ return row.flatIndex;
103
133
  return null;
104
134
  }
105
135
  // ── Terminal escape sequences ─────────────────────────────────────────
@@ -144,21 +174,81 @@ function leaveAltScreen() {
144
174
  process.stdout.write("\x1b[?1049l"); // Leave alternate screen buffer
145
175
  process.stdout.write("\x1b[?25h"); // Show cursor
146
176
  }
177
+ /**
178
+ * Tab pill — active tab uses an explicit hex bg + fg so contrast doesn't
179
+ * depend on the user's ANSI palette interpretation (terminal "cyan" can be a
180
+ * pale teal in light themes that doesn't read against ANSI "black"). Light
181
+ * mode gets a darker teal pill with white text; dark mode keeps a bright
182
+ * cyan pill with black text. Inactive tabs use default foreground.
183
+ */
184
+ function Tab({ active, label, mode }) {
185
+ if (active) {
186
+ const bg = mode === "light" ? "#0e7490" : "#22d3ee";
187
+ const fg = mode === "light" ? "white" : "black";
188
+ return (_jsx(Text, { backgroundColor: bg, color: fg, bold: true, children: ` ${label} ` }));
189
+ }
190
+ return _jsx(Text, { children: ` ${label} ` });
191
+ }
192
+ /**
193
+ * Single-line global keymap shown at the bottom-left of the dashboard. The
194
+ * `E workspace` hint only appears when the action is meaningful
195
+ * (`SANTREE_EDITOR` is `code`/`cursor` and a `.code-workspace` file exists in
196
+ * the repo root). When the diff overlay is active, the keymap switches to
197
+ * diff-specific bindings since the global ones don't apply.
198
+ */
199
+ function CommandBar({ showWorkspace, mode = "default", }) {
200
+ const dot = _jsx(Text, { dimColor: true, children: " · " });
201
+ const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
202
+ if (mode === "diff") {
203
+ return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "g/G" }), _jsx(Text, { dimColor: true, children: " top/bot" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
204
+ }
205
+ return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
206
+ }
147
207
  // ── Component ─────────────────────────────────────────────────────────
148
208
  export default function Dashboard() {
149
209
  ensureAltScreen();
150
210
  const { exit } = useApp();
151
211
  const { stdout } = useStdout();
152
212
  const [state, dispatch] = useReducer(reducer, initialState);
213
+ // Theme is a visual concern only — kept outside the reducer so re-detection
214
+ // on refresh doesn't churn data flow. Defaults to dark; replaced by OSC 11
215
+ // detection on mount and on every refresh.
216
+ const [theme, setTheme] = useState(getThemeForMode("dark"));
217
+ // `E workspace` is only meaningful when the user's editor accepts a
218
+ // `.code-workspace` file (VSCode/Cursor) AND such a file exists in the
219
+ // repo root. Recomputed alongside the data refresh.
220
+ const [hasWorkspaceFile, setHasWorkspaceFile] = useState(false);
153
221
  const refreshTimerRef = useRef(null);
154
222
  const repoRootRef = useRef(null);
155
223
  const stateRef = useRef(state);
156
224
  stateRef.current = state;
157
- const draggingRef = useRef(false);
225
+ const draggingRef = useRef(null);
158
226
  const [termSize, setTermSize] = useState({
159
227
  columns: stdout?.columns ?? 80,
160
228
  rows: stdout?.rows ?? 24,
161
229
  });
230
+ // Show cached values immediately so the banner appears on first paint when
231
+ // known-stale; refresh in the background without blocking dashboard load.
232
+ const [latestVersion, setLatestVersion] = useState(getCachedLatestVersion);
233
+ const [latestClaudeVersion, setLatestClaudeVersion] = useState(() => getCachedLatestVersionFor(CLAUDE_CODE_PACKAGE));
234
+ useEffect(() => {
235
+ let cancelled = false;
236
+ getLatestVersion().then((v) => {
237
+ if (!cancelled && v)
238
+ setLatestVersion(v);
239
+ });
240
+ getLatestVersionFor(CLAUDE_CODE_PACKAGE).then((v) => {
241
+ if (!cancelled && v)
242
+ setLatestClaudeVersion(v);
243
+ });
244
+ return () => {
245
+ cancelled = true;
246
+ };
247
+ }, []);
248
+ const updateAvailable = latestVersion ? isUpdateAvailable(CURRENT_VERSION, latestVersion) : false;
249
+ const claudeUpdateAvailable = !!CLAUDE_VERSION &&
250
+ !!latestClaudeVersion &&
251
+ isUpdateAvailable(CLAUDE_VERSION, latestClaudeVersion);
162
252
  useEffect(() => {
163
253
  const onResize = () => {
164
254
  setTermSize({
@@ -173,12 +263,21 @@ export default function Dashboard() {
173
263
  }, [stdout]);
174
264
  const { columns, rows } = termSize;
175
265
  const separatorWidth = 3;
176
- const [leftWidth, setLeftWidth] = useState(Math.floor(columns * 0.42));
266
+ const innerWidth = Math.max(40, columns - 2); // outer border consumes 1 col on each side
267
+ const [leftWidth, setLeftWidth] = useState(Math.floor(innerWidth * 0.42));
177
268
  const leftWidthRef = useRef(leftWidth);
178
269
  leftWidthRef.current = leftWidth;
179
- const rightWidth = columns - leftWidth - separatorWidth;
180
- const contentHeight = rows - 2; // 2 header rows (tabs + version)
181
- const LIST_FOOTER_HEIGHT = 2;
270
+ const rightWidth = innerWidth - leftWidth - separatorWidth;
271
+ // Diff overlay's left pane width null means "use the default formula"
272
+ // (computed inside computeDiffLayout). Becomes a number once the user drags
273
+ // the divider, and persists across overlay open/close while the dashboard
274
+ // session is alive.
275
+ const [diffLeftWidth, setDiffLeftWidth] = useState(null);
276
+ const diffLeftWidthRef = useRef(diffLeftWidth);
277
+ diffLeftWidthRef.current = diffLeftWidth;
278
+ // Header (1) + tab strip (1) + 2 borders + command bar (1, inside box) = 5 rows
279
+ const contentHeight = Math.max(3, rows - 5);
280
+ const LIST_FOOTER_HEIGHT = 0;
182
281
  // ── Data loading ──────────────────────────────────────────────────
183
282
  const refresh = useCallback(async (isInitial = false) => {
184
283
  if (!isInitial)
@@ -190,10 +289,29 @@ export default function Dashboard() {
190
289
  }
191
290
  repoRootRef.current = repoRoot;
192
291
  try {
193
- const [data, reviewData] = await Promise.all([
292
+ // Re-detect terminal theme alongside data fetch so light↔dark switches
293
+ // propagate within one refresh cycle (≤30s).
294
+ const [data, reviewData, themeMode] = await Promise.all([
194
295
  loadDashboardData(repoRoot),
195
296
  loadReviewsData(repoRoot),
297
+ detectTerminalTheme(),
196
298
  ]);
299
+ setTheme(getThemeForMode(themeMode));
300
+ // Workspace file presence — only meaningful when the editor consumes
301
+ // `.code-workspace` files. Cheap directory read; recomputed each cycle
302
+ // in case the user adds/removes one.
303
+ const editor = (process.env.SANTREE_EDITOR ?? "code").toLowerCase();
304
+ const editorAcceptsWorkspace = editor === "code" || editor === "cursor";
305
+ let hasWs = false;
306
+ if (editorAcceptsWorkspace) {
307
+ try {
308
+ hasWs = fs.readdirSync(repoRoot).some((f) => f.endsWith(".code-workspace"));
309
+ }
310
+ catch {
311
+ hasWs = false;
312
+ }
313
+ }
314
+ setHasWorkspaceFile(hasWs);
197
315
  dispatch({ type: "SET_DATA", ...data });
198
316
  dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
199
317
  }
@@ -226,13 +344,23 @@ export default function Dashboard() {
226
344
  const sepW = 3;
227
345
  // Release — stop dragging
228
346
  if (isRelease && draggingRef.current) {
229
- draggingRef.current = false;
347
+ draggingRef.current = null;
230
348
  return;
231
349
  }
232
350
  // Drag — resize if actively dragging
233
351
  if (isDrag && draggingRef.current) {
234
- // col is 1-based; place divider center at mouse position
235
- const newLeft = Math.max(minW, Math.min(col - 1, cols - sepW - minW));
352
+ if (draggingRef.current === "diff") {
353
+ // DiffOverlay starts at abs col 2 with width=innerWidth; its
354
+ // 1-col divider sits at relative col (leftWidth+1) → abs col
355
+ // (leftWidth+2). Setting newLeft = col - 2 keeps it under the
356
+ // cursor; clampDiffLeftWidth enforces pane minimums.
357
+ const innerW = Math.max(40, cols - 2);
358
+ setDiffLeftWidth(clampDiffLeftWidth(col - 2, innerW));
359
+ return;
360
+ }
361
+ // col is 1-based; outer border consumes col 1, so left pane spans cols 2..(lw+1).
362
+ // Setting newLeft = col - 1 keeps the divider at the user's cursor.
363
+ const newLeft = Math.max(minW, Math.min(col - 1, cols - 2 - sepW - minW));
236
364
  setLeftWidth(newLeft);
237
365
  return;
238
366
  }
@@ -241,8 +369,45 @@ export default function Dashboard() {
241
369
  const s = stateRef.current;
242
370
  const lw = leftWidthRef.current;
243
371
  const delta = button === 65 ? 3 : -3;
372
+ // Diff overlay: file navigation (left pane) or content scroll (right pane)
373
+ if (s.overlay === "diff") {
374
+ const cols = stdout?.columns ?? 80;
375
+ const rowsRem = stdout?.rows ?? 24;
376
+ // contentHeight = total - dashboard header (1) - tab bar (1) - bottom margin (0)
377
+ const contentHeight = Math.max(3, rowsRem - 5);
378
+ const layout = computeDiffLayout({
379
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
380
+ height: contentHeight,
381
+ files: s.diffFiles,
382
+ fileIndex: s.diffFileIndex,
383
+ fileScrollOffset: s.diffFileScrollOffset,
384
+ leftWidthOverride: diffLeftWidthRef.current ?? undefined,
385
+ });
386
+ // Body's first line is at absolute row 6 (title + tab + top border + overlay title + rule)
387
+ const bodyRow = row - 6;
388
+ if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
389
+ return;
390
+ // DiffOverlay starts at abs col 2; left pane occupies abs cols
391
+ // 2..(leftWidth+1).
392
+ if (col <= layout.leftWidth + 1) {
393
+ const maxIdx = s.diffFiles.length - 1;
394
+ if (maxIdx < 0)
395
+ return;
396
+ const next = Math.max(0, Math.min(s.diffFileIndex + delta, maxIdx));
397
+ dispatch({ type: "DIFF_FILE_SELECT", index: next });
398
+ }
399
+ else {
400
+ const totalLines = s.diffContent ? s.diffContent.split("\n").length : 0;
401
+ const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
402
+ const next = Math.max(0, Math.min(maxScroll, s.diffContentScrollOffset + delta));
403
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: next });
404
+ }
405
+ return;
406
+ }
407
+ // Outer border at col 1; left pane spans cols 2..(lw+1).
408
+ const inLeftPane = col >= 2 && col <= lw + 1;
244
409
  if (s.activeTab === "reviews") {
245
- if (col <= lw) {
410
+ if (inLeftPane) {
246
411
  const maxIdx = s.flatReviews.length - 1;
247
412
  if (maxIdx < 0)
248
413
  return;
@@ -255,7 +420,7 @@ export default function Dashboard() {
255
420
  }
256
421
  return;
257
422
  }
258
- if (col <= lw) {
423
+ if (inLeftPane) {
259
424
  // Scroll left pane (issue list)
260
425
  const maxIdx = s.flatIssues.length - 1;
261
426
  if (maxIdx < 0)
@@ -272,22 +437,59 @@ export default function Dashboard() {
272
437
  }
273
438
  if (!isPress)
274
439
  return;
440
+ // Diff overlay click: drag divider, or select file row in left pane
441
+ {
442
+ const s = stateRef.current;
443
+ if (s.overlay === "diff") {
444
+ const cols = stdout?.columns ?? 80;
445
+ const rowsRem = stdout?.rows ?? 24;
446
+ const contentHeight = Math.max(3, rowsRem - 5);
447
+ const layout = computeDiffLayout({
448
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
449
+ height: contentHeight,
450
+ files: s.diffFiles,
451
+ fileIndex: s.diffFileIndex,
452
+ fileScrollOffset: s.diffFileScrollOffset,
453
+ leftWidthOverride: diffLeftWidthRef.current ?? undefined,
454
+ });
455
+ // Divider sits at abs col leftWidth+2 (DiffOverlay starts at
456
+ // abs col 2; divider at relative col leftWidth+1). Allow ±1
457
+ // tolerance — a 1-col target is hard to hit precisely.
458
+ const diffDivAbsCol = layout.leftWidth + 2;
459
+ if (col >= diffDivAbsCol - 1 && col <= diffDivAbsCol - 1 + DIFF_DIVIDER_WIDTH + 1) {
460
+ draggingRef.current = "diff";
461
+ return;
462
+ }
463
+ if (col > layout.leftWidth + 1)
464
+ return;
465
+ const bodyRow = row - 6;
466
+ if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
467
+ return;
468
+ const absRowIdx = layout.effectiveScroll + bodyRow;
469
+ const clickedRow = layout.rows[absRowIdx];
470
+ if (clickedRow && clickedRow.fileIndex !== null) {
471
+ dispatch({ type: "DIFF_FILE_SELECT", index: clickedRow.fileIndex });
472
+ }
473
+ return;
474
+ }
475
+ }
275
476
  // Left-click press: check if on divider to start drag
477
+ // Outer border is at col 1; left pane spans cols 2..(lw+1); divider spans (lw+2)..(lw+1+sepW).
276
478
  const lw = leftWidthRef.current;
277
- const divStart = lw + 1; // 1-based start of separator
278
- const divEnd = lw + sepW; // 1-based end of separator
479
+ const divStart = lw + 2;
480
+ const divEnd = lw + 1 + sepW;
279
481
  if (col >= divStart && col <= divEnd) {
280
- draggingRef.current = true;
482
+ draggingRef.current = "main";
281
483
  return;
282
484
  }
283
- // Left-click press: select item in left pane
485
+ // Left-click press: select item in left pane (cols 2..lw+1)
284
486
  const s = stateRef.current;
285
487
  if (s.loading || s.error)
286
488
  return;
287
- if (col > lw)
489
+ if (col < 2 || col > lw + 1)
288
490
  return;
289
- // Row 1 is the header line, content starts at row 2 (1-based)
290
- const contentRow = row - 2; // 0-based row within content area
491
+ // Row 1 = title, row 2 = tab strip, row 3 = top border, content starts at row 4 (1-based)
492
+ const contentRow = row - 4; // 0-based row within content area
291
493
  if (contentRow < 0)
292
494
  return;
293
495
  if (s.activeTab === "reviews") {
@@ -304,7 +506,7 @@ export default function Dashboard() {
304
506
  if (s.flatIssues.length === 0)
305
507
  return;
306
508
  const listRow = s.listScrollOffset + contentRow;
307
- const flatIdx = getFlatIndexForListRow(s.groups, listRow);
509
+ const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
308
510
  if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
309
511
  dispatch({ type: "SELECT", index: flatIdx });
310
512
  }
@@ -332,7 +534,7 @@ export default function Dashboard() {
332
534
  }, [refresh]);
333
535
  // ── List scroll tracking ──────────────────────────────────────────
334
536
  useEffect(() => {
335
- const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
537
+ const rowIdx = getRowIndexForFlatIndex(state.groups, state.flatIssues, state.selectedIndex);
336
538
  const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
337
539
  let offset = state.listScrollOffset;
338
540
  if (rowIdx < offset) {
@@ -377,6 +579,73 @@ export default function Dashboard() {
377
579
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
378
580
  };
379
581
  }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
582
+ // ── Diff overlay: load file list when opened ──────────────────────
583
+ // Resolves merge-base against the configured base branch so upstream-only
584
+ // changes (commits on master we haven't pulled) are excluded — same semantics
585
+ // as a GitHub PR diff.
586
+ useEffect(() => {
587
+ if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
588
+ return;
589
+ if (!state.diffLoadingFiles)
590
+ return;
591
+ const cwd = state.diffWorktreePath;
592
+ const base = state.diffBaseBranch;
593
+ void (async () => {
594
+ try {
595
+ const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
596
+ const mergeBase = mergeBaseOut.trim() || base;
597
+ const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
598
+ const files = parseNameStatus(stdout);
599
+ const ordered = flattenTreeFiles(files);
600
+ dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
601
+ }
602
+ catch (err) {
603
+ const msg = err instanceof Error ? err.message : String(err);
604
+ dispatch({ type: "DIFF_FILES_ERROR", error: msg });
605
+ }
606
+ })();
607
+ }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
608
+ // ── Diff overlay: load content for selected file ──────────────────
609
+ // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
610
+ // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
611
+ // colorization. The tool's ANSI output is then rendered as-is by Ink.
612
+ useEffect(() => {
613
+ if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffMergeBase)
614
+ return;
615
+ const file = state.diffFiles[state.diffFileIndex];
616
+ if (!file)
617
+ return;
618
+ const cwd = state.diffWorktreePath;
619
+ const mergeBase = state.diffMergeBase;
620
+ const tool = getDiffTool();
621
+ dispatch({ type: "DIFF_CONTENT_LOADING" });
622
+ void (async () => {
623
+ try {
624
+ if (tool) {
625
+ // Pipe git diff (with colors enabled so the tool can pass them
626
+ // through if desired) into the configured tool. Use spawn pipes
627
+ // rather than shell to avoid quoting concerns.
628
+ const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
629
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
630
+ }
631
+ else {
632
+ // No external tool — get raw unified diff and render colors ourselves.
633
+ const { stdout } = await execAsync(`git -C "${cwd}" diff --no-color "${mergeBase}" -- "${file.path}"`, { maxBuffer: 32 * 1024 * 1024 });
634
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: stdout });
635
+ }
636
+ }
637
+ catch (err) {
638
+ const msg = err instanceof Error ? err.message : String(err);
639
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: `Error loading diff: ${msg}` });
640
+ }
641
+ })();
642
+ }, [
643
+ state.overlay,
644
+ state.diffWorktreePath,
645
+ state.diffMergeBase,
646
+ state.diffFileIndex,
647
+ state.diffFiles,
648
+ ]);
380
649
  // ── Actions ───────────────────────────────────────────────────────
381
650
  const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
382
651
  const windowName = di.issue.identifier;
@@ -1036,6 +1305,65 @@ export default function Dashboard() {
1036
1305
  }
1037
1306
  return;
1038
1307
  }
1308
+ // Diff overlay
1309
+ if (state.overlay === "diff") {
1310
+ if (key.escape || input === "q") {
1311
+ dispatch({ type: "DIFF_CLOSE" });
1312
+ return;
1313
+ }
1314
+ const fileCount = state.diffFiles.length;
1315
+ if (fileCount === 0)
1316
+ return;
1317
+ // Compute max scroll so we never scroll past the end of the diff.
1318
+ const cols = stdout?.columns ?? 80;
1319
+ const rowsRem = stdout?.rows ?? 24;
1320
+ const contentHeight = Math.max(3, rowsRem - 2);
1321
+ const layout = computeDiffLayout({
1322
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
1323
+ height: contentHeight,
1324
+ files: state.diffFiles,
1325
+ fileIndex: state.diffFileIndex,
1326
+ fileScrollOffset: state.diffFileScrollOffset,
1327
+ leftWidthOverride: diffLeftWidth ?? undefined,
1328
+ });
1329
+ const totalLines = state.diffContent ? state.diffContent.split("\n").length : 0;
1330
+ const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
1331
+ // Scroll diff content (J/K or shift+arrows)
1332
+ if ((key.shift && key.downArrow) || input === "J") {
1333
+ dispatch({
1334
+ type: "DIFF_CONTENT_SCROLL",
1335
+ offset: Math.min(maxScroll, state.diffContentScrollOffset + 5),
1336
+ });
1337
+ return;
1338
+ }
1339
+ if ((key.shift && key.upArrow) || input === "K") {
1340
+ dispatch({
1341
+ type: "DIFF_CONTENT_SCROLL",
1342
+ offset: Math.max(0, state.diffContentScrollOffset - 5),
1343
+ });
1344
+ return;
1345
+ }
1346
+ if (input === "g") {
1347
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: 0 });
1348
+ return;
1349
+ }
1350
+ if (input === "G") {
1351
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: maxScroll });
1352
+ return;
1353
+ }
1354
+ // Navigate file list (j/k or arrows)
1355
+ if (input === "j" || (key.downArrow && !key.shift)) {
1356
+ const next = Math.min(state.diffFileIndex + 1, fileCount - 1);
1357
+ dispatch({ type: "DIFF_FILE_SELECT", index: next });
1358
+ return;
1359
+ }
1360
+ if (input === "k" || (key.upArrow && !key.shift)) {
1361
+ const prev = Math.max(state.diffFileIndex - 1, 0);
1362
+ dispatch({ type: "DIFF_FILE_SELECT", index: prev });
1363
+ return;
1364
+ }
1365
+ return;
1366
+ }
1039
1367
  // Confirm delete overlay
1040
1368
  if (state.overlay === "confirm-delete") {
1041
1369
  if (input === "y") {
@@ -1452,9 +1780,12 @@ export default function Dashboard() {
1452
1780
  openInEditor(di.worktree.path);
1453
1781
  return;
1454
1782
  }
1455
- // Open workspace
1783
+ // Open workspace — no-op unless the editor accepts a .code-workspace
1784
+ // file and one exists. Keeps the keybinding from firing surprises on
1785
+ // editors like zed/nvim that don't have the concept.
1456
1786
  if (input === "E") {
1457
- openWorkspace();
1787
+ if (hasWorkspaceFile)
1788
+ openWorkspace();
1458
1789
  return;
1459
1790
  }
1460
1791
  // Commit & push
@@ -1507,6 +1838,21 @@ export default function Dashboard() {
1507
1838
  }
1508
1839
  return;
1509
1840
  }
1841
+ // View diff (inline overlay)
1842
+ if (input === "v") {
1843
+ if (!di.worktree) {
1844
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to diff" });
1845
+ return;
1846
+ }
1847
+ const baseBranch = getBaseBranch(di.worktree.branch);
1848
+ dispatch({
1849
+ type: "DIFF_OPEN",
1850
+ ticketId: di.issue.identifier,
1851
+ worktreePath: di.worktree.path,
1852
+ baseBranch,
1853
+ });
1854
+ return;
1855
+ }
1510
1856
  // Delete worktree
1511
1857
  if (input === "d") {
1512
1858
  if (!di.worktree) {
@@ -1530,16 +1876,38 @@ export default function Dashboard() {
1530
1876
  }
1531
1877
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1532
1878
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1533
- 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 === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1879
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [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 === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1880
+ .split("\n")
1881
+ .slice(0, 12)
1882
+ .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " 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) => {
1883
+ const selected = idx === state.baseSelectIndex;
1884
+ const defaultBranch = getDefaultBranch();
1885
+ const label = branch === defaultBranch ? `${branch} (default)` : branch;
1886
+ return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1887
+ }), _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 === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined })) : 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, selectionBg: theme.selectionBg })) : 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, selectionBg: theme.selectionBg })) }), _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
1534
1888
  .split("\n")
1535
- .slice(0, 12)
1536
- .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " 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) => {
1537
- const selected = idx === state.baseSelectIndex;
1538
- const defaultBranch = getDefaultBranch();
1539
- const label = branch === defaultBranch ? `${branch} (default)` : branch;
1540
- return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1541
- }), _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
1542
- .split("\n")
1543
- .slice(-(contentHeight - 1))
1544
- .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, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1889
+ .slice(-(contentHeight - 1))
1890
+ .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, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsxs(Box, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: state.overlay === "diff" ? "diff" : "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })] })] }));
1891
+ }
1892
+ /**
1893
+ * Renders the per-issue action key hints (Resume / Editor / View diff / …)
1894
+ * lifted out of the detail panels so they sit on the same row as the global
1895
+ * command bar. Empty when nothing is selected.
1896
+ */
1897
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, }) {
1898
+ // During the diff overlay, none of the per-issue actions apply (View diff
1899
+ // is what got us here, Commit/PR/etc. need the detail panel context). Keep
1900
+ // the row blank so the diff-specific CommandBar reads cleanly.
1901
+ if (overlay === "diff")
1902
+ return _jsx(Text, { children: " " });
1903
+ const items = activeTab === "reviews"
1904
+ ? selectedReview
1905
+ ? buildReviewActions(selectedReview)
1906
+ : []
1907
+ : selectedIssue
1908
+ ? buildIssueActions(selectedIssue)
1909
+ : [];
1910
+ if (items.length === 0)
1911
+ return _jsx(Text, { children: " " });
1912
+ return (_jsx(Text, { children: items.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : undefined, children: [" ", item.label] })] }, j))) }));
1545
1913
  }