santree 0.3.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.
Files changed (50) hide show
  1. package/README.md +55 -2
  2. package/dist/commands/dashboard.js +538 -188
  3. package/dist/commands/doctor.js +164 -13
  4. package/dist/commands/helpers/statusline.js +10 -2
  5. package/dist/commands/helpers/text-editor.d.ts +13 -0
  6. package/dist/commands/helpers/text-editor.js +118 -0
  7. package/dist/commands/update.d.ts +15 -0
  8. package/dist/commands/update.js +72 -0
  9. package/dist/commands/worktree/create.d.ts +1 -0
  10. package/dist/commands/worktree/create.js +30 -38
  11. package/dist/commands/worktree/diff.d.ts +13 -0
  12. package/dist/commands/worktree/diff.js +76 -0
  13. package/dist/lib/ai.d.ts +12 -2
  14. package/dist/lib/ai.js +48 -14
  15. package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
  16. package/dist/lib/dashboard/DetailPanel.js +235 -89
  17. package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
  18. package/dist/lib/dashboard/DiffOverlay.js +243 -0
  19. package/dist/lib/dashboard/IssueList.d.ts +20 -3
  20. package/dist/lib/dashboard/IssueList.js +74 -103
  21. package/dist/lib/dashboard/MultilineTextArea.js +225 -82
  22. package/dist/lib/dashboard/Overlays.js +1 -1
  23. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
  24. package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
  25. package/dist/lib/dashboard/ReviewList.d.ts +3 -1
  26. package/dist/lib/dashboard/ReviewList.js +3 -3
  27. package/dist/lib/dashboard/data.js +14 -8
  28. package/dist/lib/dashboard/external-editor.d.ts +12 -0
  29. package/dist/lib/dashboard/external-editor.js +74 -0
  30. package/dist/lib/dashboard/theme.d.ts +24 -0
  31. package/dist/lib/dashboard/theme.js +113 -0
  32. package/dist/lib/dashboard/types.d.ts +52 -1
  33. package/dist/lib/dashboard/types.js +81 -0
  34. package/dist/lib/git.d.ts +26 -4
  35. package/dist/lib/git.js +45 -33
  36. package/dist/lib/multiplexer/cmux.d.ts +2 -0
  37. package/dist/lib/multiplexer/cmux.js +97 -0
  38. package/dist/lib/multiplexer/index.d.ts +4 -0
  39. package/dist/lib/multiplexer/index.js +20 -0
  40. package/dist/lib/multiplexer/none.d.ts +2 -0
  41. package/dist/lib/multiplexer/none.js +22 -0
  42. package/dist/lib/multiplexer/tmux.d.ts +2 -0
  43. package/dist/lib/multiplexer/tmux.js +82 -0
  44. package/dist/lib/multiplexer/types.d.ts +23 -0
  45. package/dist/lib/multiplexer/types.js +3 -0
  46. package/dist/lib/session-signal.js +5 -8
  47. package/dist/lib/version.d.ts +55 -0
  48. package/dist/lib/version.js +224 -0
  49. package/package.json +1 -1
  50. package/shell/init.zsh.njk +45 -15
@@ -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,38 +9,94 @@ 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";
17
+ import { getMultiplexer } from "../lib/multiplexer/index.js";
16
18
  import { getPRTemplate } from "../lib/github.js";
17
19
  import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
18
20
  import { getTicketContent } from "../lib/linear.js";
19
21
  import * as os from "os";
20
22
  import { initialState, reducer } from "../lib/dashboard/types.js";
21
23
  import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
22
- import IssueList from "../lib/dashboard/IssueList.js";
23
- 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";
24
27
  import ReviewList from "../lib/dashboard/ReviewList.js";
25
- import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
28
+ import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
26
29
  import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
27
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";
28
33
  export const description = "Interactive dashboard of your Linear issues";
29
34
  const execAsync = promisify(exec);
30
- const CLAUDE_VERSION = (() => {
31
- const bin = path.join(os.homedir(), ".claude", "local", "claude");
32
- try {
33
- return (execSync(`${bin} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
34
- .trim()
35
- .split(" ")[0] ?? "");
36
- }
37
- catch {
38
- return "";
39
- }
40
- })();
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() ?? "";
41
38
  // ── Helpers ───────────────────────────────────────────────────────────
42
- function isInTmux() {
43
- return !!process.env.TMUX;
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;
44
100
  }
45
101
  function slugify(title) {
46
102
  return title
@@ -50,58 +106,30 @@ function slugify(title) {
50
106
  .slice(0, 40);
51
107
  }
52
108
  // ── Scroll helpers ────────────────────────────────────────────────────
53
- function countWithChildren(di) {
54
- let count = 1;
55
- if (di.children) {
56
- for (const child of di.children) {
57
- count += countWithChildren(child);
58
- }
59
- }
60
- return count;
61
- }
62
- function getRowIndexForFlatIndex(groups, flatIndex) {
63
- let row = 1; // skip column header row
64
- let issuesSeen = 0;
65
- for (const g of groups) {
66
- row++; // project header
67
- for (const sg of g.statusGroups) {
68
- row++; // status header
69
- for (const di of sg.issues) {
70
- const total = countWithChildren(di);
71
- if (flatIndex >= issuesSeen && flatIndex < issuesSeen + total) {
72
- // The target is within this issue or its children
73
- return row + (flatIndex - issuesSeen);
74
- }
75
- row += total;
76
- issuesSeen += total;
77
- }
78
- }
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;
79
120
  }
80
121
  return 0;
81
122
  }
82
- function getFlatIndexForListRow(groups, listRow) {
83
- if (listRow === 0)
84
- return null; // column header row
85
- let row = 1; // skip column header row
86
- let issuesSeen = 0;
87
- for (const g of groups) {
88
- if (row === listRow)
89
- return null; // project header row
90
- row++;
91
- for (const sg of g.statusGroups) {
92
- if (row === listRow)
93
- return null; // status header row
94
- row++;
95
- for (const di of sg.issues) {
96
- const total = countWithChildren(di);
97
- if (listRow >= row && listRow < row + total) {
98
- return issuesSeen + (listRow - row);
99
- }
100
- row += total;
101
- issuesSeen += total;
102
- }
103
- }
104
- }
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;
105
133
  return null;
106
134
  }
107
135
  // ── Terminal escape sequences ─────────────────────────────────────────
@@ -137,12 +165,7 @@ function ensureAltScreen() {
137
165
  if (altScreenEntered)
138
166
  return;
139
167
  altScreenEntered = true;
140
- if (isInTmux()) {
141
- try {
142
- execSync('tmux rename-window "santree"', { stdio: "ignore" });
143
- }
144
- catch { }
145
- }
168
+ getMultiplexer().renameWindow("", "santree");
146
169
  process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
147
170
  process.stdout.write("\x1b[?25l"); // Hide cursor
148
171
  }
@@ -151,12 +174,46 @@ function leaveAltScreen() {
151
174
  process.stdout.write("\x1b[?1049l"); // Leave alternate screen buffer
152
175
  process.stdout.write("\x1b[?25h"); // Show cursor
153
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
+ }
154
203
  // ── Component ─────────────────────────────────────────────────────────
155
204
  export default function Dashboard() {
156
205
  ensureAltScreen();
157
206
  const { exit } = useApp();
158
207
  const { stdout } = useStdout();
159
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);
160
217
  const refreshTimerRef = useRef(null);
161
218
  const repoRootRef = useRef(null);
162
219
  const stateRef = useRef(state);
@@ -166,6 +223,28 @@ export default function Dashboard() {
166
223
  columns: stdout?.columns ?? 80,
167
224
  rows: stdout?.rows ?? 24,
168
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);
169
248
  useEffect(() => {
170
249
  const onResize = () => {
171
250
  setTermSize({
@@ -180,12 +259,14 @@ export default function Dashboard() {
180
259
  }, [stdout]);
181
260
  const { columns, rows } = termSize;
182
261
  const separatorWidth = 3;
183
- 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));
184
264
  const leftWidthRef = useRef(leftWidth);
185
265
  leftWidthRef.current = leftWidth;
186
- const rightWidth = columns - leftWidth - separatorWidth;
187
- const contentHeight = rows - 2; // 2 header rows (tabs + version)
188
- 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;
189
270
  // ── Data loading ──────────────────────────────────────────────────
190
271
  const refresh = useCallback(async (isInitial = false) => {
191
272
  if (!isInitial)
@@ -197,10 +278,29 @@ export default function Dashboard() {
197
278
  }
198
279
  repoRootRef.current = repoRoot;
199
280
  try {
200
- 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([
201
284
  loadDashboardData(repoRoot),
202
285
  loadReviewsData(repoRoot),
286
+ detectTerminalTheme(),
203
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);
204
304
  dispatch({ type: "SET_DATA", ...data });
205
305
  dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
206
306
  }
@@ -238,8 +338,9 @@ export default function Dashboard() {
238
338
  }
239
339
  // Drag — resize if actively dragging
240
340
  if (isDrag && draggingRef.current) {
241
- // col is 1-based; place divider center at mouse position
242
- 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));
243
344
  setLeftWidth(newLeft);
244
345
  return;
245
346
  }
@@ -248,8 +349,42 @@ export default function Dashboard() {
248
349
  const s = stateRef.current;
249
350
  const lw = leftWidthRef.current;
250
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;
251
386
  if (s.activeTab === "reviews") {
252
- if (col <= lw) {
387
+ if (inLeftPane) {
253
388
  const maxIdx = s.flatReviews.length - 1;
254
389
  if (maxIdx < 0)
255
390
  return;
@@ -262,7 +397,7 @@ export default function Dashboard() {
262
397
  }
263
398
  return;
264
399
  }
265
- if (col <= lw) {
400
+ if (inLeftPane) {
266
401
  // Scroll left pane (issue list)
267
402
  const maxIdx = s.flatIssues.length - 1;
268
403
  if (maxIdx < 0)
@@ -279,22 +414,50 @@ export default function Dashboard() {
279
414
  }
280
415
  if (!isPress)
281
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
+ }
282
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).
283
446
  const lw = leftWidthRef.current;
284
- const divStart = lw + 1; // 1-based start of separator
285
- const divEnd = lw + sepW; // 1-based end of separator
447
+ const divStart = lw + 2;
448
+ const divEnd = lw + 1 + sepW;
286
449
  if (col >= divStart && col <= divEnd) {
287
450
  draggingRef.current = true;
288
451
  return;
289
452
  }
290
- // Left-click press: select item in left pane
453
+ // Left-click press: select item in left pane (cols 2..lw+1)
291
454
  const s = stateRef.current;
292
455
  if (s.loading || s.error)
293
456
  return;
294
- if (col > lw)
457
+ if (col < 2 || col > lw + 1)
295
458
  return;
296
- // Row 1 is the header line, content starts at row 2 (1-based)
297
- 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
298
461
  if (contentRow < 0)
299
462
  return;
300
463
  if (s.activeTab === "reviews") {
@@ -311,7 +474,7 @@ export default function Dashboard() {
311
474
  if (s.flatIssues.length === 0)
312
475
  return;
313
476
  const listRow = s.listScrollOffset + contentRow;
314
- const flatIdx = getFlatIndexForListRow(s.groups, listRow);
477
+ const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
315
478
  if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
316
479
  dispatch({ type: "SELECT", index: flatIdx });
317
480
  }
@@ -339,7 +502,7 @@ export default function Dashboard() {
339
502
  }, [refresh]);
340
503
  // ── List scroll tracking ──────────────────────────────────────────
341
504
  useEffect(() => {
342
- const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
505
+ const rowIdx = getRowIndexForFlatIndex(state.groups, state.flatIssues, state.selectedIndex);
343
506
  const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
344
507
  let offset = state.listScrollOffset;
345
508
  if (rowIdx < offset) {
@@ -384,67 +547,149 @@ export default function Dashboard() {
384
547
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
385
548
  };
386
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
+ ]);
387
617
  // ── Actions ───────────────────────────────────────────────────────
388
- const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
618
+ const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
389
619
  const windowName = di.issue.identifier;
390
620
  const sessionId = di.worktree?.sessionId;
391
621
  const bin = resolveAgentBinary();
392
622
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
393
623
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
394
624
  const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
395
- try {
396
- // Switch to existing window if it exists
397
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
398
- const cmd = resumeCmd ?? workCmd;
399
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
400
- dispatch({
401
- type: "SET_ACTION_MESSAGE",
402
- message: resumeCmd
403
- ? `Resumed session in: ${windowName}`
404
- : `Launched ${mode} in: ${windowName}`,
405
- });
625
+ const cmd = resumeCmd ?? workCmd;
626
+ const mux = getMultiplexer();
627
+ const selected = await mux.selectWindow(windowName);
628
+ if (selected.ok) {
629
+ const sent = mux.sendCommand(windowName, cmd);
630
+ if (sent.ok) {
631
+ dispatch({
632
+ type: "SET_ACTION_MESSAGE",
633
+ message: resumeCmd
634
+ ? `Resumed session in: ${windowName}`
635
+ : `Launched ${mode} in: ${windowName}`,
636
+ });
637
+ }
638
+ else {
639
+ dispatch({
640
+ type: "SET_ACTION_MESSAGE",
641
+ message: `Focused ${windowName} — run \`${cmd}\` manually (${sent.reason})`,
642
+ });
643
+ }
406
644
  }
407
- catch {
408
- // Window doesn't exist — create it
409
- try {
410
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
411
- // Small delay so the new shell can start reading input before we send keys,
412
- // otherwise buffered keystrokes from the dashboard pane can leak in.
413
- execSync("sleep 0.1", { stdio: "ignore" });
414
- const cmd = resumeCmd ?? workCmd;
415
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
645
+ else {
646
+ const created = await mux.createWindow({
647
+ name: windowName,
648
+ cwd: worktreePath,
649
+ command: cmd,
650
+ });
651
+ if (created.ok) {
416
652
  dispatch({
417
653
  type: "SET_ACTION_MESSAGE",
418
654
  message: resumeCmd
419
655
  ? `Resumed session in new window: ${windowName}`
420
- : `Launched ${mode} in tmux window: ${windowName}`,
656
+ : `Launched ${mode} in ${mux.kind} window: ${windowName}`,
421
657
  });
422
658
  }
423
- catch {
424
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to create tmux window" });
659
+ else {
660
+ dispatch({
661
+ type: "SET_ACTION_MESSAGE",
662
+ message: `Failed to create ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
663
+ });
425
664
  }
426
665
  }
427
666
  // Delayed refresh to pick up session ID created by `st worktree work`
428
667
  setTimeout(() => refresh(), 3000);
429
668
  }, [refresh]);
430
- const launchAfterCreation = useCallback((mode, worktreePath, ticketId, contextFile) => {
431
- if (isInTmux()) {
669
+ const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
670
+ const mux = getMultiplexer();
671
+ if (mux.isActive()) {
432
672
  const windowName = ticketId;
433
673
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
434
674
  const workCmd = mode === "plan"
435
675
  ? `st worktree work --plan${contextArg}`
436
676
  : `st worktree work${contextArg}`;
437
- try {
438
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
439
- execSync("sleep 0.1", { stdio: "ignore" });
440
- execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
677
+ const created = await mux.createWindow({
678
+ name: windowName,
679
+ cwd: worktreePath,
680
+ command: workCmd,
681
+ });
682
+ if (created.ok) {
441
683
  dispatch({
442
684
  type: "SET_ACTION_MESSAGE",
443
685
  message: `Created worktree + launched ${mode} in: ${windowName}`,
444
686
  });
445
687
  }
446
- catch {
447
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree created, but tmux failed" });
688
+ else {
689
+ dispatch({
690
+ type: "SET_ACTION_MESSAGE",
691
+ message: `Worktree created, but ${mux.kind} failed${created.message ? `: ${created.message}` : ""}`,
692
+ });
448
693
  }
449
694
  setTimeout(() => refresh(), 3000);
450
695
  }
@@ -590,8 +835,8 @@ export default function Dashboard() {
590
835
  const contextFile = writeContextFile(customContext);
591
836
  if (di.worktree) {
592
837
  // Worktree exists — launch work
593
- if (isInTmux()) {
594
- launchWorkInTmux(di, mode, di.worktree.path, contextFile);
838
+ if (getMultiplexer().isActive()) {
839
+ void launchWorkInTmux(di, mode, di.worktree.path, contextFile);
595
840
  }
596
841
  else {
597
842
  leaveAltScreen();
@@ -1028,6 +1273,64 @@ export default function Dashboard() {
1028
1273
  }
1029
1274
  return;
1030
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
+ }
1031
1334
  // Confirm delete overlay
1032
1335
  if (state.overlay === "confirm-delete") {
1033
1336
  if (input === "y") {
@@ -1225,7 +1528,7 @@ export default function Dashboard() {
1225
1528
  dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
1226
1529
  return;
1227
1530
  }
1228
- // AI Review in tmux
1531
+ // AI Review in multiplexer
1229
1532
  if (input === "r") {
1230
1533
  if (!ri.worktree) {
1231
1534
  dispatch({
@@ -1234,21 +1537,23 @@ export default function Dashboard() {
1234
1537
  });
1235
1538
  return;
1236
1539
  }
1237
- if (isInTmux()) {
1540
+ const mux = getMultiplexer();
1541
+ if (mux.isActive()) {
1238
1542
  const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
1239
- try {
1240
- execSync(`tmux new-window -n "${windowName}" -c "${ri.worktree.path}"`, {
1241
- stdio: "ignore",
1543
+ const cwd = ri.worktree.path;
1544
+ void (async () => {
1545
+ const created = await mux.createWindow({
1546
+ name: windowName,
1547
+ cwd,
1548
+ command: "st pr review",
1242
1549
  });
1243
- execSync("sleep 0.1", { stdio: "ignore" });
1244
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, {
1245
- stdio: "ignore",
1550
+ dispatch({
1551
+ type: "SET_ACTION_MESSAGE",
1552
+ message: created.ok
1553
+ ? `Launched AI review in ${mux.kind}`
1554
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1246
1555
  });
1247
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched AI review in tmux" });
1248
- }
1249
- catch {
1250
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1251
- }
1556
+ })();
1252
1557
  }
1253
1558
  else {
1254
1559
  leaveAltScreen();
@@ -1330,30 +1635,30 @@ export default function Dashboard() {
1330
1635
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
1331
1636
  return;
1332
1637
  }
1333
- if (isInTmux()) {
1638
+ const mux = getMultiplexer();
1639
+ if (mux.isActive()) {
1334
1640
  const windowName = di.issue.identifier;
1335
1641
  const sessionId = di.worktree.sessionId;
1336
1642
  const bin = resolveAgentBinary();
1337
1643
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
1338
- try {
1339
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
1340
- }
1341
- catch {
1342
- // Window doesn't exist — create one and resume/launch
1343
- try {
1344
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1345
- stdio: "ignore",
1346
- });
1347
- execSync("sleep 0.1", { stdio: "ignore" });
1348
- const cmd = resumeCmd ?? "st worktree work";
1349
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, {
1350
- stdio: "ignore",
1644
+ const worktreePath = di.worktree.path;
1645
+ void (async () => {
1646
+ const selected = await mux.selectWindow(windowName);
1647
+ if (selected.ok)
1648
+ return;
1649
+ const cmd = resumeCmd ?? "st worktree work";
1650
+ const created = await mux.createWindow({
1651
+ name: windowName,
1652
+ cwd: worktreePath,
1653
+ command: cmd,
1654
+ });
1655
+ if (!created.ok) {
1656
+ dispatch({
1657
+ type: "SET_ACTION_MESSAGE",
1658
+ message: `Failed to switch ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
1351
1659
  });
1352
1660
  }
1353
- catch {
1354
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
1355
- }
1356
- }
1661
+ })();
1357
1662
  }
1358
1663
  else {
1359
1664
  leaveAltScreen();
@@ -1408,18 +1713,23 @@ export default function Dashboard() {
1408
1713
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
1409
1714
  return;
1410
1715
  }
1411
- if (isInTmux()) {
1716
+ const mux = getMultiplexer();
1717
+ if (mux.isActive()) {
1412
1718
  const windowName = `review-${di.issue.identifier}`;
1413
- try {
1414
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1415
- stdio: "ignore",
1719
+ const cwd = di.worktree.path;
1720
+ void (async () => {
1721
+ const created = await mux.createWindow({
1722
+ name: windowName,
1723
+ cwd,
1724
+ command: "st pr review",
1416
1725
  });
1417
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, { stdio: "ignore" });
1418
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched review in tmux" });
1419
- }
1420
- catch {
1421
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1422
- }
1726
+ dispatch({
1727
+ type: "SET_ACTION_MESSAGE",
1728
+ message: created.ok
1729
+ ? `Launched review in ${mux.kind}`
1730
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1731
+ });
1732
+ })();
1423
1733
  }
1424
1734
  else {
1425
1735
  leaveAltScreen();
@@ -1437,9 +1747,12 @@ export default function Dashboard() {
1437
1747
  openInEditor(di.worktree.path);
1438
1748
  return;
1439
1749
  }
1440
- // 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.
1441
1753
  if (input === "E") {
1442
- openWorkspace();
1754
+ if (hasWorkspaceFile)
1755
+ openWorkspace();
1443
1756
  return;
1444
1757
  }
1445
1758
  // Commit & push
@@ -1467,18 +1780,23 @@ export default function Dashboard() {
1467
1780
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
1468
1781
  return;
1469
1782
  }
1470
- if (isInTmux()) {
1783
+ const mux = getMultiplexer();
1784
+ if (mux.isActive()) {
1471
1785
  const windowName = `fix-${di.issue.identifier}`;
1472
- try {
1473
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1474
- stdio: "ignore",
1786
+ const cwd = di.worktree.path;
1787
+ void (async () => {
1788
+ const created = await mux.createWindow({
1789
+ name: windowName,
1790
+ cwd,
1791
+ command: "st pr fix",
1475
1792
  });
1476
- execSync(`tmux send-keys -t "${windowName}" "st pr fix" Enter`, { stdio: "ignore" });
1477
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched PR fix in tmux" });
1478
- }
1479
- catch {
1480
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch PR fix" });
1481
- }
1793
+ dispatch({
1794
+ type: "SET_ACTION_MESSAGE",
1795
+ message: created.ok
1796
+ ? `Launched PR fix in ${mux.kind}`
1797
+ : `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
1798
+ });
1799
+ })();
1482
1800
  }
1483
1801
  else {
1484
1802
  leaveAltScreen();
@@ -1487,6 +1805,21 @@ export default function Dashboard() {
1487
1805
  }
1488
1806
  return;
1489
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
+ }
1490
1823
  // Delete worktree
1491
1824
  if (input === "d") {
1492
1825
  if (!di.worktree) {
@@ -1510,16 +1843,33 @@ export default function Dashboard() {
1510
1843
  }
1511
1844
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1512
1845
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1513
- 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: "Enter" }), " newline ", _jsx(Text, { color: "cyan", bold: true, children: "\u2191\u2193\u2190\u2192" }), " move ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+V" }), " paste image ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " continue ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " 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
1514
1855
  .split("\n")
1515
- .slice(0, 12)
1516
- .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) => {
1517
- const selected = idx === state.baseSelectIndex;
1518
- const defaultBranch = getDefaultBranch();
1519
- const label = branch === defaultBranch ? `${branch} (default)` : branch;
1520
- return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1521
- }), _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
1522
- .split("\n")
1523
- .slice(-(contentHeight - 1))
1524
- .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))) }));
1525
1875
  }