santree 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer, useCallback, useRef, useState } from "react";
3
3
  import { Text, Box, useInput, useStdout, useApp } from "ink";
4
4
  import Spinner from "ink-spinner";
@@ -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 } 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,12 +174,46 @@ 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).
197
+ */
198
+ function CommandBar({ showWorkspace }) {
199
+ const dot = _jsx(Text, { dimColor: true, children: " · " });
200
+ const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
201
+ 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" })] }));
202
+ }
147
203
  // ── Component ─────────────────────────────────────────────────────────
148
204
  export default function Dashboard() {
149
205
  ensureAltScreen();
150
206
  const { exit } = useApp();
151
207
  const { stdout } = useStdout();
152
208
  const [state, dispatch] = useReducer(reducer, initialState);
209
+ // Theme is a visual concern only — kept outside the reducer so re-detection
210
+ // on refresh doesn't churn data flow. Defaults to dark; replaced by OSC 11
211
+ // detection on mount and on every refresh.
212
+ const [theme, setTheme] = useState(getThemeForMode("dark"));
213
+ // `E workspace` is only meaningful when the user's editor accepts a
214
+ // `.code-workspace` file (VSCode/Cursor) AND such a file exists in the
215
+ // repo root. Recomputed alongside the data refresh.
216
+ const [hasWorkspaceFile, setHasWorkspaceFile] = useState(false);
153
217
  const refreshTimerRef = useRef(null);
154
218
  const repoRootRef = useRef(null);
155
219
  const stateRef = useRef(state);
@@ -159,6 +223,28 @@ export default function Dashboard() {
159
223
  columns: stdout?.columns ?? 80,
160
224
  rows: stdout?.rows ?? 24,
161
225
  });
226
+ // Show cached values immediately so the banner appears on first paint when
227
+ // known-stale; refresh in the background without blocking dashboard load.
228
+ const [latestVersion, setLatestVersion] = useState(getCachedLatestVersion);
229
+ const [latestClaudeVersion, setLatestClaudeVersion] = useState(() => getCachedLatestVersionFor(CLAUDE_CODE_PACKAGE));
230
+ useEffect(() => {
231
+ let cancelled = false;
232
+ getLatestVersion().then((v) => {
233
+ if (!cancelled && v)
234
+ setLatestVersion(v);
235
+ });
236
+ getLatestVersionFor(CLAUDE_CODE_PACKAGE).then((v) => {
237
+ if (!cancelled && v)
238
+ setLatestClaudeVersion(v);
239
+ });
240
+ return () => {
241
+ cancelled = true;
242
+ };
243
+ }, []);
244
+ const updateAvailable = latestVersion ? isUpdateAvailable(CURRENT_VERSION, latestVersion) : false;
245
+ const claudeUpdateAvailable = !!CLAUDE_VERSION &&
246
+ !!latestClaudeVersion &&
247
+ isUpdateAvailable(CLAUDE_VERSION, latestClaudeVersion);
162
248
  useEffect(() => {
163
249
  const onResize = () => {
164
250
  setTermSize({
@@ -173,12 +259,14 @@ export default function Dashboard() {
173
259
  }, [stdout]);
174
260
  const { columns, rows } = termSize;
175
261
  const separatorWidth = 3;
176
- const [leftWidth, setLeftWidth] = useState(Math.floor(columns * 0.42));
262
+ const innerWidth = Math.max(40, columns - 2); // outer border consumes 1 col on each side
263
+ const [leftWidth, setLeftWidth] = useState(Math.floor(innerWidth * 0.42));
177
264
  const leftWidthRef = useRef(leftWidth);
178
265
  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;
266
+ const rightWidth = innerWidth - leftWidth - separatorWidth;
267
+ // Header (1) + tab strip (1) + 2 borders + command bar (1, inside box) = 5 rows
268
+ const contentHeight = Math.max(3, rows - 5);
269
+ const LIST_FOOTER_HEIGHT = 0;
182
270
  // ── Data loading ──────────────────────────────────────────────────
183
271
  const refresh = useCallback(async (isInitial = false) => {
184
272
  if (!isInitial)
@@ -190,10 +278,29 @@ export default function Dashboard() {
190
278
  }
191
279
  repoRootRef.current = repoRoot;
192
280
  try {
193
- const [data, reviewData] = await Promise.all([
281
+ // Re-detect terminal theme alongside data fetch so light↔dark switches
282
+ // propagate within one refresh cycle (≤30s).
283
+ const [data, reviewData, themeMode] = await Promise.all([
194
284
  loadDashboardData(repoRoot),
195
285
  loadReviewsData(repoRoot),
286
+ detectTerminalTheme(),
196
287
  ]);
288
+ setTheme(getThemeForMode(themeMode));
289
+ // Workspace file presence — only meaningful when the editor consumes
290
+ // `.code-workspace` files. Cheap directory read; recomputed each cycle
291
+ // in case the user adds/removes one.
292
+ const editor = (process.env.SANTREE_EDITOR ?? "code").toLowerCase();
293
+ const editorAcceptsWorkspace = editor === "code" || editor === "cursor";
294
+ let hasWs = false;
295
+ if (editorAcceptsWorkspace) {
296
+ try {
297
+ hasWs = fs.readdirSync(repoRoot).some((f) => f.endsWith(".code-workspace"));
298
+ }
299
+ catch {
300
+ hasWs = false;
301
+ }
302
+ }
303
+ setHasWorkspaceFile(hasWs);
197
304
  dispatch({ type: "SET_DATA", ...data });
198
305
  dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
199
306
  }
@@ -231,8 +338,9 @@ export default function Dashboard() {
231
338
  }
232
339
  // Drag — resize if actively dragging
233
340
  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));
341
+ // col is 1-based; outer border consumes col 1, so left pane spans cols 2..(lw+1).
342
+ // Setting newLeft = col - 1 keeps the divider at the user's cursor.
343
+ const newLeft = Math.max(minW, Math.min(col - 1, cols - 2 - sepW - minW));
236
344
  setLeftWidth(newLeft);
237
345
  return;
238
346
  }
@@ -241,8 +349,42 @@ export default function Dashboard() {
241
349
  const s = stateRef.current;
242
350
  const lw = leftWidthRef.current;
243
351
  const delta = button === 65 ? 3 : -3;
352
+ // Diff overlay: file navigation (left pane) or content scroll (right pane)
353
+ if (s.overlay === "diff") {
354
+ const cols = stdout?.columns ?? 80;
355
+ const rowsRem = stdout?.rows ?? 24;
356
+ // contentHeight = total - dashboard header (1) - tab bar (1) - bottom margin (0)
357
+ const contentHeight = Math.max(3, rowsRem - 5);
358
+ const layout = computeDiffLayout({
359
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
360
+ height: contentHeight,
361
+ files: s.diffFiles,
362
+ fileIndex: s.diffFileIndex,
363
+ fileScrollOffset: s.diffFileScrollOffset,
364
+ });
365
+ // Body's first line is at absolute row 6 (title + tab + top border + overlay title + rule)
366
+ const bodyRow = row - 6;
367
+ if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
368
+ return;
369
+ if (col <= layout.leftWidth) {
370
+ const maxIdx = s.diffFiles.length - 1;
371
+ if (maxIdx < 0)
372
+ return;
373
+ const next = Math.max(0, Math.min(s.diffFileIndex + delta, maxIdx));
374
+ dispatch({ type: "DIFF_FILE_SELECT", index: next });
375
+ }
376
+ else {
377
+ const totalLines = s.diffContent ? s.diffContent.split("\n").length : 0;
378
+ const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
379
+ const next = Math.max(0, Math.min(maxScroll, s.diffContentScrollOffset + delta));
380
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: next });
381
+ }
382
+ return;
383
+ }
384
+ // Outer border at col 1; left pane spans cols 2..(lw+1).
385
+ const inLeftPane = col >= 2 && col <= lw + 1;
244
386
  if (s.activeTab === "reviews") {
245
- if (col <= lw) {
387
+ if (inLeftPane) {
246
388
  const maxIdx = s.flatReviews.length - 1;
247
389
  if (maxIdx < 0)
248
390
  return;
@@ -255,7 +397,7 @@ export default function Dashboard() {
255
397
  }
256
398
  return;
257
399
  }
258
- if (col <= lw) {
400
+ if (inLeftPane) {
259
401
  // Scroll left pane (issue list)
260
402
  const maxIdx = s.flatIssues.length - 1;
261
403
  if (maxIdx < 0)
@@ -272,22 +414,50 @@ export default function Dashboard() {
272
414
  }
273
415
  if (!isPress)
274
416
  return;
417
+ // Diff overlay click: select file row in left pane
418
+ {
419
+ const s = stateRef.current;
420
+ if (s.overlay === "diff") {
421
+ const cols = stdout?.columns ?? 80;
422
+ const rowsRem = stdout?.rows ?? 24;
423
+ const contentHeight = Math.max(3, rowsRem - 5);
424
+ const layout = computeDiffLayout({
425
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
426
+ height: contentHeight,
427
+ files: s.diffFiles,
428
+ fileIndex: s.diffFileIndex,
429
+ fileScrollOffset: s.diffFileScrollOffset,
430
+ });
431
+ if (col > layout.leftWidth)
432
+ return;
433
+ const bodyRow = row - 6;
434
+ if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
435
+ return;
436
+ const absRowIdx = layout.effectiveScroll + bodyRow;
437
+ const clickedRow = layout.rows[absRowIdx];
438
+ if (clickedRow && clickedRow.fileIndex !== null) {
439
+ dispatch({ type: "DIFF_FILE_SELECT", index: clickedRow.fileIndex });
440
+ }
441
+ return;
442
+ }
443
+ }
275
444
  // Left-click press: check if on divider to start drag
445
+ // Outer border is at col 1; left pane spans cols 2..(lw+1); divider spans (lw+2)..(lw+1+sepW).
276
446
  const lw = leftWidthRef.current;
277
- const divStart = lw + 1; // 1-based start of separator
278
- const divEnd = lw + sepW; // 1-based end of separator
447
+ const divStart = lw + 2;
448
+ const divEnd = lw + 1 + sepW;
279
449
  if (col >= divStart && col <= divEnd) {
280
450
  draggingRef.current = true;
281
451
  return;
282
452
  }
283
- // Left-click press: select item in left pane
453
+ // Left-click press: select item in left pane (cols 2..lw+1)
284
454
  const s = stateRef.current;
285
455
  if (s.loading || s.error)
286
456
  return;
287
- if (col > lw)
457
+ if (col < 2 || col > lw + 1)
288
458
  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
459
+ // Row 1 = title, row 2 = tab strip, row 3 = top border, content starts at row 4 (1-based)
460
+ const contentRow = row - 4; // 0-based row within content area
291
461
  if (contentRow < 0)
292
462
  return;
293
463
  if (s.activeTab === "reviews") {
@@ -304,7 +474,7 @@ export default function Dashboard() {
304
474
  if (s.flatIssues.length === 0)
305
475
  return;
306
476
  const listRow = s.listScrollOffset + contentRow;
307
- const flatIdx = getFlatIndexForListRow(s.groups, listRow);
477
+ const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
308
478
  if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
309
479
  dispatch({ type: "SELECT", index: flatIdx });
310
480
  }
@@ -332,7 +502,7 @@ export default function Dashboard() {
332
502
  }, [refresh]);
333
503
  // ── List scroll tracking ──────────────────────────────────────────
334
504
  useEffect(() => {
335
- const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
505
+ const rowIdx = getRowIndexForFlatIndex(state.groups, state.flatIssues, state.selectedIndex);
336
506
  const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
337
507
  let offset = state.listScrollOffset;
338
508
  if (rowIdx < offset) {
@@ -377,6 +547,73 @@ export default function Dashboard() {
377
547
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
378
548
  };
379
549
  }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
550
+ // ── Diff overlay: load file list when opened ──────────────────────
551
+ // Resolves merge-base against the configured base branch so upstream-only
552
+ // changes (commits on master we haven't pulled) are excluded — same semantics
553
+ // as a GitHub PR diff.
554
+ useEffect(() => {
555
+ if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
556
+ return;
557
+ if (!state.diffLoadingFiles)
558
+ return;
559
+ const cwd = state.diffWorktreePath;
560
+ const base = state.diffBaseBranch;
561
+ void (async () => {
562
+ try {
563
+ const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
564
+ const mergeBase = mergeBaseOut.trim() || base;
565
+ const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
566
+ const files = parseNameStatus(stdout);
567
+ const ordered = flattenTreeFiles(files);
568
+ dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
569
+ }
570
+ catch (err) {
571
+ const msg = err instanceof Error ? err.message : String(err);
572
+ dispatch({ type: "DIFF_FILES_ERROR", error: msg });
573
+ }
574
+ })();
575
+ }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
576
+ // ── Diff overlay: load content for selected file ──────────────────
577
+ // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
578
+ // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
579
+ // colorization. The tool's ANSI output is then rendered as-is by Ink.
580
+ useEffect(() => {
581
+ if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffMergeBase)
582
+ return;
583
+ const file = state.diffFiles[state.diffFileIndex];
584
+ if (!file)
585
+ return;
586
+ const cwd = state.diffWorktreePath;
587
+ const mergeBase = state.diffMergeBase;
588
+ const tool = getDiffTool();
589
+ dispatch({ type: "DIFF_CONTENT_LOADING" });
590
+ void (async () => {
591
+ try {
592
+ if (tool) {
593
+ // Pipe git diff (with colors enabled so the tool can pass them
594
+ // through if desired) into the configured tool. Use spawn pipes
595
+ // rather than shell to avoid quoting concerns.
596
+ const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
597
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
598
+ }
599
+ else {
600
+ // No external tool — get raw unified diff and render colors ourselves.
601
+ const { stdout } = await execAsync(`git -C "${cwd}" diff --no-color "${mergeBase}" -- "${file.path}"`, { maxBuffer: 32 * 1024 * 1024 });
602
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: stdout });
603
+ }
604
+ }
605
+ catch (err) {
606
+ const msg = err instanceof Error ? err.message : String(err);
607
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: `Error loading diff: ${msg}` });
608
+ }
609
+ })();
610
+ }, [
611
+ state.overlay,
612
+ state.diffWorktreePath,
613
+ state.diffMergeBase,
614
+ state.diffFileIndex,
615
+ state.diffFiles,
616
+ ]);
380
617
  // ── Actions ───────────────────────────────────────────────────────
381
618
  const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
382
619
  const windowName = di.issue.identifier;
@@ -1036,6 +1273,64 @@ export default function Dashboard() {
1036
1273
  }
1037
1274
  return;
1038
1275
  }
1276
+ // Diff overlay
1277
+ if (state.overlay === "diff") {
1278
+ if (key.escape || input === "q") {
1279
+ dispatch({ type: "DIFF_CLOSE" });
1280
+ return;
1281
+ }
1282
+ const fileCount = state.diffFiles.length;
1283
+ if (fileCount === 0)
1284
+ return;
1285
+ // Compute max scroll so we never scroll past the end of the diff.
1286
+ const cols = stdout?.columns ?? 80;
1287
+ const rowsRem = stdout?.rows ?? 24;
1288
+ const contentHeight = Math.max(3, rowsRem - 2);
1289
+ const layout = computeDiffLayout({
1290
+ width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
1291
+ height: contentHeight,
1292
+ files: state.diffFiles,
1293
+ fileIndex: state.diffFileIndex,
1294
+ fileScrollOffset: state.diffFileScrollOffset,
1295
+ });
1296
+ const totalLines = state.diffContent ? state.diffContent.split("\n").length : 0;
1297
+ const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
1298
+ // Scroll diff content (J/K or shift+arrows)
1299
+ if ((key.shift && key.downArrow) || input === "J") {
1300
+ dispatch({
1301
+ type: "DIFF_CONTENT_SCROLL",
1302
+ offset: Math.min(maxScroll, state.diffContentScrollOffset + 5),
1303
+ });
1304
+ return;
1305
+ }
1306
+ if ((key.shift && key.upArrow) || input === "K") {
1307
+ dispatch({
1308
+ type: "DIFF_CONTENT_SCROLL",
1309
+ offset: Math.max(0, state.diffContentScrollOffset - 5),
1310
+ });
1311
+ return;
1312
+ }
1313
+ if (input === "g") {
1314
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: 0 });
1315
+ return;
1316
+ }
1317
+ if (input === "G") {
1318
+ dispatch({ type: "DIFF_CONTENT_SCROLL", offset: maxScroll });
1319
+ return;
1320
+ }
1321
+ // Navigate file list (j/k or arrows)
1322
+ if (input === "j" || (key.downArrow && !key.shift)) {
1323
+ const next = Math.min(state.diffFileIndex + 1, fileCount - 1);
1324
+ dispatch({ type: "DIFF_FILE_SELECT", index: next });
1325
+ return;
1326
+ }
1327
+ if (input === "k" || (key.upArrow && !key.shift)) {
1328
+ const prev = Math.max(state.diffFileIndex - 1, 0);
1329
+ dispatch({ type: "DIFF_FILE_SELECT", index: prev });
1330
+ return;
1331
+ }
1332
+ return;
1333
+ }
1039
1334
  // Confirm delete overlay
1040
1335
  if (state.overlay === "confirm-delete") {
1041
1336
  if (input === "y") {
@@ -1452,9 +1747,12 @@ export default function Dashboard() {
1452
1747
  openInEditor(di.worktree.path);
1453
1748
  return;
1454
1749
  }
1455
- // Open workspace
1750
+ // Open workspace — no-op unless the editor accepts a .code-workspace
1751
+ // file and one exists. Keeps the keybinding from firing surprises on
1752
+ // editors like zed/nvim that don't have the concept.
1456
1753
  if (input === "E") {
1457
- openWorkspace();
1754
+ if (hasWorkspaceFile)
1755
+ openWorkspace();
1458
1756
  return;
1459
1757
  }
1460
1758
  // Commit & push
@@ -1507,6 +1805,21 @@ export default function Dashboard() {
1507
1805
  }
1508
1806
  return;
1509
1807
  }
1808
+ // View diff (inline overlay)
1809
+ if (input === "v") {
1810
+ if (!di.worktree) {
1811
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to diff" });
1812
+ return;
1813
+ }
1814
+ const baseBranch = getBaseBranch(di.worktree.branch);
1815
+ dispatch({
1816
+ type: "DIFF_OPEN",
1817
+ ticketId: di.issue.identifier,
1818
+ worktreePath: di.worktree.path,
1819
+ baseBranch,
1820
+ });
1821
+ return;
1822
+ }
1510
1823
  // Delete worktree
1511
1824
  if (input === "d") {
1512
1825
  if (!di.worktree) {
@@ -1530,16 +1843,33 @@ export default function Dashboard() {
1530
1843
  }
1531
1844
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1532
1845
  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)")
1846
+ 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)")
1847
+ .split("\n")
1848
+ .slice(0, 12)
1849
+ .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) => {
1850
+ const selected = idx === state.baseSelectIndex;
1851
+ const defaultBranch = getDefaultBranch();
1852
+ const label = branch === defaultBranch ? `${branch} (default)` : branch;
1853
+ return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1854
+ }), _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 })) : 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
1855
  .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 })) })] }))] }));
1856
+ .slice(-(contentHeight - 1))
1857
+ .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 }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview }) })] })] })] }));
1858
+ }
1859
+ /**
1860
+ * Renders the per-issue action key hints (Resume / Editor / View diff / …)
1861
+ * lifted out of the detail panels so they sit on the same row as the global
1862
+ * command bar. Empty when nothing is selected.
1863
+ */
1864
+ function ActionRow({ activeTab, selectedIssue, selectedReview, }) {
1865
+ const items = activeTab === "reviews"
1866
+ ? selectedReview
1867
+ ? buildReviewActions(selectedReview)
1868
+ : []
1869
+ : selectedIssue
1870
+ ? buildIssueActions(selectedIssue)
1871
+ : [];
1872
+ if (items.length === 0)
1873
+ return _jsx(Text, { children: " " });
1874
+ 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
1875
  }