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.
- package/README.md +55 -2
- package/dist/commands/dashboard.js +538 -188
- package/dist/commands/doctor.js +164 -13
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/commands/worktree/diff.d.ts +13 -0
- package/dist/commands/worktree/diff.js +76 -0
- package/dist/lib/ai.d.ts +12 -2
- package/dist/lib/ai.js +48 -14
- package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/DetailPanel.js +235 -89
- package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
- package/dist/lib/dashboard/DiffOverlay.js +243 -0
- package/dist/lib/dashboard/IssueList.d.ts +20 -3
- package/dist/lib/dashboard/IssueList.js +74 -103
- package/dist/lib/dashboard/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
- package/dist/lib/dashboard/ReviewList.d.ts +3 -1
- package/dist/lib/dashboard/ReviewList.js +3 -3
- package/dist/lib/dashboard/data.js +14 -8
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/theme.d.ts +24 -0
- package/dist/lib/dashboard/theme.js +113 -0
- package/dist/lib/dashboard/types.d.ts +52 -1
- package/dist/lib/dashboard/types.js +81 -0
- package/dist/lib/git.d.ts +26 -4
- package/dist/lib/git.js +45 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
187
|
-
|
|
188
|
-
const
|
|
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
|
-
|
|
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;
|
|
242
|
-
|
|
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 (
|
|
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 (
|
|
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 +
|
|
285
|
-
const divEnd = lw +
|
|
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
|
|
297
|
-
const contentRow = row -
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
656
|
+
: `Launched ${mode} in ${mux.kind} window: ${windowName}`,
|
|
421
657
|
});
|
|
422
658
|
}
|
|
423
|
-
|
|
424
|
-
dispatch({
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
447
|
-
dispatch({
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1540
|
+
const mux = getMultiplexer();
|
|
1541
|
+
if (mux.isActive()) {
|
|
1238
1542
|
const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1716
|
+
const mux = getMultiplexer();
|
|
1717
|
+
if (mux.isActive()) {
|
|
1412
1718
|
const windowName = `review-${di.issue.identifier}`;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1783
|
+
const mux = getMultiplexer();
|
|
1784
|
+
if (mux.isActive()) {
|
|
1471
1785
|
const windowName = `fix-${di.issue.identifier}`;
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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: "
|
|
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(
|
|
1516
|
-
.map((line, i) => (_jsx(
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
}
|