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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
// GUI editors that detach by default and need a `--wait` flag to make spawnSync
|
|
6
|
+
// block until the file is closed. Terminal editors (vim, nvim, nano, emacs -nw)
|
|
7
|
+
// already block, so they aren't listed here.
|
|
8
|
+
const GUI_EDITORS_NEEDING_WAIT = new Set([
|
|
9
|
+
"zed",
|
|
10
|
+
"code",
|
|
11
|
+
"code-insiders",
|
|
12
|
+
"cursor",
|
|
13
|
+
"windsurf",
|
|
14
|
+
"subl",
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Open the user's editor on a temp file seeded with `initial`, then return the
|
|
18
|
+
* saved content. Empty buffer is treated as cancel (matches `git commit`).
|
|
19
|
+
*
|
|
20
|
+
* Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
|
|
21
|
+
*/
|
|
22
|
+
export function editExternally(initial, ext = "md") {
|
|
23
|
+
const editorRaw = process.env["SANTREE_EDITOR"] || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
|
|
24
|
+
const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext.replace(/^\./, "")}`);
|
|
25
|
+
try {
|
|
26
|
+
fs.writeFileSync(filePath, initial);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { ok: false, content: initial, cancelled: false };
|
|
30
|
+
}
|
|
31
|
+
const parts = editorRaw.split(/\s+/).filter(Boolean);
|
|
32
|
+
const cmd = parts[0] ?? "vim";
|
|
33
|
+
const baseArgs = parts.slice(1);
|
|
34
|
+
const needsWait = GUI_EDITORS_NEEDING_WAIT.has(path.basename(cmd)) &&
|
|
35
|
+
!baseArgs.includes("--wait") &&
|
|
36
|
+
!baseArgs.includes("-w");
|
|
37
|
+
const args = [...baseArgs, ...(needsWait ? ["--wait"] : []), filePath];
|
|
38
|
+
const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
|
|
39
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
40
|
+
try {
|
|
41
|
+
process.stdin.setRawMode(false);
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
46
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
47
|
+
try {
|
|
48
|
+
process.stdin.setRawMode(wasRaw);
|
|
49
|
+
}
|
|
50
|
+
catch { }
|
|
51
|
+
}
|
|
52
|
+
if (result.error || result.status !== 0) {
|
|
53
|
+
try {
|
|
54
|
+
fs.unlinkSync(filePath);
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
return { ok: false, content: initial, cancelled: false };
|
|
58
|
+
}
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { ok: false, content: initial, cancelled: false };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(filePath);
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
if (content.trim().length === 0) {
|
|
71
|
+
return { ok: true, content: "", cancelled: true };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, content, cancelled: false };
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard theme detection — picks light vs dark based on the terminal's
|
|
3
|
+
* actual background color (queried via OSC 11) or an explicit override from
|
|
4
|
+
* SANTREE_THEME (`light` / `dark` / `auto`, default `auto`).
|
|
5
|
+
*
|
|
6
|
+
* Most foreground colors used in the dashboard are terminal-native names
|
|
7
|
+
* (`green`, `red`, `yellow`, `cyan`, etc.) that the terminal renders in a
|
|
8
|
+
* scheme-appropriate way, so the only piece of styling that needs to flip
|
|
9
|
+
* is the selection background. That's the surface this module exposes.
|
|
10
|
+
*/
|
|
11
|
+
export type ThemeMode = "light" | "dark";
|
|
12
|
+
export interface DashboardTheme {
|
|
13
|
+
mode: ThemeMode;
|
|
14
|
+
selectionBg: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function getThemeForMode(mode: ThemeMode): DashboardTheme;
|
|
17
|
+
/**
|
|
18
|
+
* Query the terminal for its background color via OSC 11 and resolve the
|
|
19
|
+
* detected ThemeMode within `timeoutMs`. Falls back to `dark` on timeout or
|
|
20
|
+
* non-TTY stdin/stdout.
|
|
21
|
+
*
|
|
22
|
+
* The returned promise never rejects — failures resolve to `dark`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectTerminalTheme(timeoutMs?: number): Promise<ThemeMode>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard theme detection — picks light vs dark based on the terminal's
|
|
3
|
+
* actual background color (queried via OSC 11) or an explicit override from
|
|
4
|
+
* SANTREE_THEME (`light` / `dark` / `auto`, default `auto`).
|
|
5
|
+
*
|
|
6
|
+
* Most foreground colors used in the dashboard are terminal-native names
|
|
7
|
+
* (`green`, `red`, `yellow`, `cyan`, etc.) that the terminal renders in a
|
|
8
|
+
* scheme-appropriate way, so the only piece of styling that needs to flip
|
|
9
|
+
* is the selection background. That's the surface this module exposes.
|
|
10
|
+
*/
|
|
11
|
+
const DARK = { mode: "dark", selectionBg: "#1e3a5f" };
|
|
12
|
+
const LIGHT = { mode: "light", selectionBg: "#bfdbfe" };
|
|
13
|
+
export function getThemeForMode(mode) {
|
|
14
|
+
return mode === "light" ? LIGHT : DARK;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Honor SANTREE_THEME env override. Returns null when set to `auto` or
|
|
18
|
+
* unset/invalid — caller should fall back to terminal detection.
|
|
19
|
+
*/
|
|
20
|
+
function envOverride() {
|
|
21
|
+
const raw = process.env["SANTREE_THEME"]?.toLowerCase().trim();
|
|
22
|
+
if (raw === "light")
|
|
23
|
+
return "light";
|
|
24
|
+
if (raw === "dark")
|
|
25
|
+
return "dark";
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compute relative luminance from sRGB components in the 0..1 range. Uses
|
|
30
|
+
* Rec. 709 coefficients — good enough to decide light vs dark.
|
|
31
|
+
*/
|
|
32
|
+
function luminance(r, g, b) {
|
|
33
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse an OSC 11 color response. Terminals reply with one of:
|
|
37
|
+
* \x1b]11;rgb:RRRR/GGGG/BBBB\x07
|
|
38
|
+
* \x1b]11;rgb:RR/GG/BB\x1b\\
|
|
39
|
+
* Component widths can be 2 or 4 hex digits. Returns luminance in 0..1, or
|
|
40
|
+
* null if the buffer doesn't contain a recognisable response.
|
|
41
|
+
*/
|
|
42
|
+
function parseOsc11(buf) {
|
|
43
|
+
const m = buf.match(/\x1b\]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/);
|
|
44
|
+
if (!m)
|
|
45
|
+
return null;
|
|
46
|
+
const conv = (hex) => parseInt(hex, 16) / (hex.length >= 4 ? 65535 : 255);
|
|
47
|
+
const r = conv(m[1]);
|
|
48
|
+
const g = conv(m[2]);
|
|
49
|
+
const b = conv(m[3]);
|
|
50
|
+
return luminance(r, g, b);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Query the terminal for its background color via OSC 11 and resolve the
|
|
54
|
+
* detected ThemeMode within `timeoutMs`. Falls back to `dark` on timeout or
|
|
55
|
+
* non-TTY stdin/stdout.
|
|
56
|
+
*
|
|
57
|
+
* The returned promise never rejects — failures resolve to `dark`.
|
|
58
|
+
*/
|
|
59
|
+
export function detectTerminalTheme(timeoutMs = 150) {
|
|
60
|
+
const override = envOverride();
|
|
61
|
+
if (override)
|
|
62
|
+
return Promise.resolve(override);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
65
|
+
resolve("dark");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const stdin = process.stdin;
|
|
69
|
+
const wasRaw = stdin.isRaw;
|
|
70
|
+
let buf = "";
|
|
71
|
+
let settled = false;
|
|
72
|
+
const finish = (mode) => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
stdin.removeListener("data", onData);
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (!wasRaw) {
|
|
79
|
+
try {
|
|
80
|
+
stdin.setRawMode(false);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
resolve(mode);
|
|
87
|
+
};
|
|
88
|
+
const onData = (chunk) => {
|
|
89
|
+
buf += chunk.toString("utf-8");
|
|
90
|
+
const lum = parseOsc11(buf);
|
|
91
|
+
if (lum !== null)
|
|
92
|
+
finish(lum > 0.5 ? "light" : "dark");
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
if (!wasRaw)
|
|
96
|
+
stdin.setRawMode(true);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
finish("dark");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
stdin.on("data", onData);
|
|
103
|
+
const timer = setTimeout(() => finish("dark"), timeoutMs);
|
|
104
|
+
// Send the OSC 11 query. The terminal's response is consumed by onData
|
|
105
|
+
// above and never reaches Ink's input handlers.
|
|
106
|
+
try {
|
|
107
|
+
process.stdout.write("\x1b]11;?\x07");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
finish("dark");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -23,6 +23,11 @@ export interface WorktreeInfo {
|
|
|
23
23
|
gitStatus: string;
|
|
24
24
|
sessionState: "waiting" | "idle" | "active" | null;
|
|
25
25
|
sessionMessage: string | null;
|
|
26
|
+
diffStats: {
|
|
27
|
+
filesChanged: number;
|
|
28
|
+
insertions: number;
|
|
29
|
+
deletions: number;
|
|
30
|
+
} | null;
|
|
26
31
|
}
|
|
27
32
|
export interface DashboardIssue {
|
|
28
33
|
issue: LinearAssignedIssue;
|
|
@@ -58,7 +63,13 @@ export interface EnrichedReviewPR {
|
|
|
58
63
|
worktree: WorktreeInfo | null;
|
|
59
64
|
}
|
|
60
65
|
export type DashboardTab = "issues" | "reviews";
|
|
61
|
-
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
66
|
+
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | null;
|
|
67
|
+
export type DiffFileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?";
|
|
68
|
+
export interface DiffFile {
|
|
69
|
+
path: string;
|
|
70
|
+
status: DiffFileStatus;
|
|
71
|
+
oldPath?: string;
|
|
72
|
+
}
|
|
62
73
|
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
63
74
|
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
|
|
64
75
|
export interface DashboardState {
|
|
@@ -103,6 +114,18 @@ export interface DashboardState {
|
|
|
103
114
|
contextInputValue: string;
|
|
104
115
|
contextInputMode: "plan" | "implement" | null;
|
|
105
116
|
contextInputPhase: "editing" | "review";
|
|
117
|
+
diffTicketId: string | null;
|
|
118
|
+
diffWorktreePath: string | null;
|
|
119
|
+
diffBaseBranch: string | null;
|
|
120
|
+
diffMergeBase: string | null;
|
|
121
|
+
diffFiles: DiffFile[];
|
|
122
|
+
diffFileIndex: number;
|
|
123
|
+
diffFileScrollOffset: number;
|
|
124
|
+
diffContent: string | null;
|
|
125
|
+
diffContentScrollOffset: number;
|
|
126
|
+
diffLoadingFiles: boolean;
|
|
127
|
+
diffLoadingContent: boolean;
|
|
128
|
+
diffError: string | null;
|
|
106
129
|
}
|
|
107
130
|
export type DashboardAction = {
|
|
108
131
|
type: "SET_DATA";
|
|
@@ -237,6 +260,34 @@ export type DashboardAction = {
|
|
|
237
260
|
type: "CONTEXT_INPUT_EDIT";
|
|
238
261
|
} | {
|
|
239
262
|
type: "CONTEXT_INPUT_DONE";
|
|
263
|
+
} | {
|
|
264
|
+
type: "DIFF_OPEN";
|
|
265
|
+
ticketId: string;
|
|
266
|
+
worktreePath: string;
|
|
267
|
+
baseBranch: string;
|
|
268
|
+
} | {
|
|
269
|
+
type: "DIFF_FILES_LOADED";
|
|
270
|
+
files: DiffFile[];
|
|
271
|
+
mergeBase: string;
|
|
272
|
+
} | {
|
|
273
|
+
type: "DIFF_FILES_ERROR";
|
|
274
|
+
error: string;
|
|
275
|
+
} | {
|
|
276
|
+
type: "DIFF_FILE_SELECT";
|
|
277
|
+
index: number;
|
|
278
|
+
} | {
|
|
279
|
+
type: "DIFF_FILE_SCROLL";
|
|
280
|
+
offset: number;
|
|
281
|
+
} | {
|
|
282
|
+
type: "DIFF_CONTENT_LOADING";
|
|
283
|
+
} | {
|
|
284
|
+
type: "DIFF_CONTENT_LOADED";
|
|
285
|
+
content: string;
|
|
286
|
+
} | {
|
|
287
|
+
type: "DIFF_CONTENT_SCROLL";
|
|
288
|
+
offset: number;
|
|
289
|
+
} | {
|
|
290
|
+
type: "DIFF_CLOSE";
|
|
240
291
|
};
|
|
241
292
|
export declare const initialState: DashboardState;
|
|
242
293
|
export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
|
|
@@ -41,6 +41,18 @@ export const initialState = {
|
|
|
41
41
|
contextInputValue: "",
|
|
42
42
|
contextInputMode: null,
|
|
43
43
|
contextInputPhase: "editing",
|
|
44
|
+
diffTicketId: null,
|
|
45
|
+
diffWorktreePath: null,
|
|
46
|
+
diffBaseBranch: null,
|
|
47
|
+
diffMergeBase: null,
|
|
48
|
+
diffFiles: [],
|
|
49
|
+
diffFileIndex: 0,
|
|
50
|
+
diffFileScrollOffset: 0,
|
|
51
|
+
diffContent: null,
|
|
52
|
+
diffContentScrollOffset: 0,
|
|
53
|
+
diffLoadingFiles: false,
|
|
54
|
+
diffLoadingContent: false,
|
|
55
|
+
diffError: null,
|
|
44
56
|
};
|
|
45
57
|
export function reducer(state, action) {
|
|
46
58
|
switch (action.type) {
|
|
@@ -273,6 +285,75 @@ export function reducer(state, action) {
|
|
|
273
285
|
contextInputValue: "",
|
|
274
286
|
contextInputPhase: "editing",
|
|
275
287
|
};
|
|
288
|
+
case "DIFF_OPEN":
|
|
289
|
+
return {
|
|
290
|
+
...state,
|
|
291
|
+
overlay: "diff",
|
|
292
|
+
diffTicketId: action.ticketId,
|
|
293
|
+
diffWorktreePath: action.worktreePath,
|
|
294
|
+
diffBaseBranch: action.baseBranch,
|
|
295
|
+
diffMergeBase: null,
|
|
296
|
+
diffFiles: [],
|
|
297
|
+
diffFileIndex: 0,
|
|
298
|
+
diffFileScrollOffset: 0,
|
|
299
|
+
diffContent: null,
|
|
300
|
+
diffContentScrollOffset: 0,
|
|
301
|
+
diffLoadingFiles: true,
|
|
302
|
+
diffLoadingContent: false,
|
|
303
|
+
diffError: null,
|
|
304
|
+
};
|
|
305
|
+
case "DIFF_FILES_LOADED":
|
|
306
|
+
return {
|
|
307
|
+
...state,
|
|
308
|
+
diffFiles: action.files,
|
|
309
|
+
diffMergeBase: action.mergeBase,
|
|
310
|
+
diffFileIndex: 0,
|
|
311
|
+
diffFileScrollOffset: 0,
|
|
312
|
+
diffLoadingFiles: false,
|
|
313
|
+
diffError: null,
|
|
314
|
+
};
|
|
315
|
+
case "DIFF_FILES_ERROR":
|
|
316
|
+
return {
|
|
317
|
+
...state,
|
|
318
|
+
diffLoadingFiles: false,
|
|
319
|
+
diffError: action.error,
|
|
320
|
+
};
|
|
321
|
+
case "DIFF_FILE_SELECT":
|
|
322
|
+
return {
|
|
323
|
+
...state,
|
|
324
|
+
diffFileIndex: action.index,
|
|
325
|
+
diffContentScrollOffset: 0,
|
|
326
|
+
};
|
|
327
|
+
case "DIFF_FILE_SCROLL":
|
|
328
|
+
return { ...state, diffFileScrollOffset: action.offset };
|
|
329
|
+
case "DIFF_CONTENT_LOADING":
|
|
330
|
+
return { ...state, diffLoadingContent: true, diffContent: null };
|
|
331
|
+
case "DIFF_CONTENT_LOADED":
|
|
332
|
+
return {
|
|
333
|
+
...state,
|
|
334
|
+
diffContent: action.content,
|
|
335
|
+
diffLoadingContent: false,
|
|
336
|
+
diffContentScrollOffset: 0,
|
|
337
|
+
};
|
|
338
|
+
case "DIFF_CONTENT_SCROLL":
|
|
339
|
+
return { ...state, diffContentScrollOffset: action.offset };
|
|
340
|
+
case "DIFF_CLOSE":
|
|
341
|
+
return {
|
|
342
|
+
...state,
|
|
343
|
+
overlay: null,
|
|
344
|
+
diffTicketId: null,
|
|
345
|
+
diffWorktreePath: null,
|
|
346
|
+
diffBaseBranch: null,
|
|
347
|
+
diffMergeBase: null,
|
|
348
|
+
diffFiles: [],
|
|
349
|
+
diffFileIndex: 0,
|
|
350
|
+
diffFileScrollOffset: 0,
|
|
351
|
+
diffContent: null,
|
|
352
|
+
diffContentScrollOffset: 0,
|
|
353
|
+
diffLoadingFiles: false,
|
|
354
|
+
diffLoadingContent: false,
|
|
355
|
+
diffError: null,
|
|
356
|
+
};
|
|
276
357
|
default:
|
|
277
358
|
return state;
|
|
278
359
|
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -190,6 +190,26 @@ export declare function getCommitsAhead(baseBranch: string): number;
|
|
|
190
190
|
* Returns 0 on failure.
|
|
191
191
|
*/
|
|
192
192
|
export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): Promise<number>;
|
|
193
|
+
/**
|
|
194
|
+
* Read the SANTREE_DIFF_TOOL env var, returning the configured pager command
|
|
195
|
+
* (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
|
|
196
|
+
*
|
|
197
|
+
* The value is restricted to a safe shell-token character set since it ends
|
|
198
|
+
* up in arguments passed to spawn() — even though we never use shell:true,
|
|
199
|
+
* keeping the surface tight defends against accidental misconfigurations.
|
|
200
|
+
*/
|
|
201
|
+
export declare function getDiffTool(): string | null;
|
|
202
|
+
export interface DiffShortstat {
|
|
203
|
+
filesChanged: number;
|
|
204
|
+
insertions: number;
|
|
205
|
+
deletions: number;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Compute branch-only diff stats vs base (uses merge-base, like a GitHub PR
|
|
209
|
+
* diff). Includes both committed and uncommitted changes. Returns zeros on
|
|
210
|
+
* failure or when there are no changes.
|
|
211
|
+
*/
|
|
212
|
+
export declare function getDiffShortstatAsync(cwd: string, baseBranch: string): Promise<DiffShortstat>;
|
|
193
213
|
/**
|
|
194
214
|
* Check if a branch exists on the remote (origin).
|
|
195
215
|
* Runs: `git ls-remote --heads origin <branchName>`
|
|
@@ -257,11 +277,13 @@ export declare function getDiffContent(baseBranch: string): string | null;
|
|
|
257
277
|
*/
|
|
258
278
|
export declare function readSessionState(repoRoot: string, ticketId: string): SessionState | null;
|
|
259
279
|
/**
|
|
260
|
-
* Check if a
|
|
261
|
-
*
|
|
262
|
-
*
|
|
280
|
+
* Check if a session for the given ticket is still alive in the active multiplexer.
|
|
281
|
+
* Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
|
|
282
|
+
* cmux: workspace lookup; none: always false). Callers should also consult the
|
|
283
|
+
* .santree/session-states/<ticketId>.json file — that's the authoritative signal
|
|
284
|
+
* written by Claude Code hooks, while this is the "is the terminal still up" backstop.
|
|
263
285
|
*/
|
|
264
|
-
export declare function
|
|
286
|
+
export declare function isSessionAlive(ticketId: string): boolean;
|
|
265
287
|
/**
|
|
266
288
|
* Delete the session state file for a given ticket.
|
|
267
289
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -3,6 +3,7 @@ import { promisify } from "util";
|
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import * as fs from "fs";
|
|
5
5
|
import { run, runAsync } from "./exec.js";
|
|
6
|
+
import { getMultiplexer } from "./multiplexer/index.js";
|
|
6
7
|
const execAsync = promisify(exec);
|
|
7
8
|
/**
|
|
8
9
|
* Find the toplevel directory of the current git repository.
|
|
@@ -467,6 +468,43 @@ export async function getCommitsAheadAsync(cwd, baseBranch) {
|
|
|
467
468
|
const output = await runAsync(`git -C "${cwd}" rev-list --count ${baseBranch}..HEAD`);
|
|
468
469
|
return output ? parseInt(output, 10) || 0 : 0;
|
|
469
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Read the SANTREE_DIFF_TOOL env var, returning the configured pager command
|
|
473
|
+
* (e.g. "delta", "diff-so-fancy") or null if unset/invalid.
|
|
474
|
+
*
|
|
475
|
+
* The value is restricted to a safe shell-token character set since it ends
|
|
476
|
+
* up in arguments passed to spawn() — even though we never use shell:true,
|
|
477
|
+
* keeping the surface tight defends against accidental misconfigurations.
|
|
478
|
+
*/
|
|
479
|
+
export function getDiffTool() {
|
|
480
|
+
const raw = process.env["SANTREE_DIFF_TOOL"];
|
|
481
|
+
if (!raw || !raw.trim())
|
|
482
|
+
return null;
|
|
483
|
+
const tool = raw.trim();
|
|
484
|
+
if (!/^[a-zA-Z0-9_\-/.+]+$/.test(tool))
|
|
485
|
+
return null;
|
|
486
|
+
return tool;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Compute branch-only diff stats vs base (uses merge-base, like a GitHub PR
|
|
490
|
+
* diff). Includes both committed and uncommitted changes. Returns zeros on
|
|
491
|
+
* failure or when there are no changes.
|
|
492
|
+
*/
|
|
493
|
+
export async function getDiffShortstatAsync(cwd, baseBranch) {
|
|
494
|
+
const mergeBase = await runAsync(`git -C "${cwd}" merge-base "${baseBranch}" HEAD`);
|
|
495
|
+
const ref = mergeBase ?? baseBranch;
|
|
496
|
+
const out = await runAsync(`git -C "${cwd}" diff --shortstat "${ref}"`);
|
|
497
|
+
if (!out)
|
|
498
|
+
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
499
|
+
const fm = out.match(/(\d+) files? changed/);
|
|
500
|
+
const im = out.match(/(\d+) insertions?\(\+\)/);
|
|
501
|
+
const dm = out.match(/(\d+) deletions?\(-\)/);
|
|
502
|
+
return {
|
|
503
|
+
filesChanged: fm ? parseInt(fm[1], 10) : 0,
|
|
504
|
+
insertions: im ? parseInt(im[1], 10) : 0,
|
|
505
|
+
deletions: dm ? parseInt(dm[1], 10) : 0,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
470
508
|
/**
|
|
471
509
|
* Check if a branch exists on the remote (origin).
|
|
472
510
|
* Runs: `git ls-remote --heads origin <branchName>`
|
|
@@ -610,40 +648,14 @@ export function readSessionState(repoRoot, ticketId) {
|
|
|
610
648
|
}
|
|
611
649
|
}
|
|
612
650
|
/**
|
|
613
|
-
* Check if a
|
|
614
|
-
*
|
|
615
|
-
*
|
|
651
|
+
* Check if a session for the given ticket is still alive in the active multiplexer.
|
|
652
|
+
* Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
|
|
653
|
+
* cmux: workspace lookup; none: always false). Callers should also consult the
|
|
654
|
+
* .santree/session-states/<ticketId>.json file — that's the authoritative signal
|
|
655
|
+
* written by Claude Code hooks, while this is the "is the terminal still up" backstop.
|
|
616
656
|
*/
|
|
617
|
-
export function
|
|
618
|
-
|
|
619
|
-
const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
|
|
620
|
-
encoding: "utf-8",
|
|
621
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
622
|
-
}).trim();
|
|
623
|
-
for (const line of output.split("\n")) {
|
|
624
|
-
const [name, pidStr] = line.split("\t");
|
|
625
|
-
if (!name?.startsWith(ticketId))
|
|
626
|
-
continue;
|
|
627
|
-
if (!pidStr)
|
|
628
|
-
return false;
|
|
629
|
-
// Check if any descendant of the shell PID is a claude process
|
|
630
|
-
try {
|
|
631
|
-
const ps = execSync(`pgrep -P ${pidStr} -a`, {
|
|
632
|
-
encoding: "utf-8",
|
|
633
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
634
|
-
}).trim();
|
|
635
|
-
return ps.split("\n").some((proc) => proc.includes("claude"));
|
|
636
|
-
}
|
|
637
|
-
catch {
|
|
638
|
-
// pgrep exits 1 when no matches — shell has no children
|
|
639
|
-
return false;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
catch {
|
|
644
|
-
// tmux not available or not in a tmux session
|
|
645
|
-
}
|
|
646
|
-
return false;
|
|
657
|
+
export function isSessionAlive(ticketId) {
|
|
658
|
+
return getMultiplexer().isSessionAlive(ticketId);
|
|
647
659
|
}
|
|
648
660
|
/**
|
|
649
661
|
* Delete the session state file for a given ticket.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { shellEscape } from "./types.js";
|
|
3
|
+
const CMUX_TIMEOUT_MS = 2000;
|
|
4
|
+
function cmuxRun(cmd) {
|
|
5
|
+
try {
|
|
6
|
+
const stdout = execSync(cmd, {
|
|
7
|
+
encoding: "utf-8",
|
|
8
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
9
|
+
timeout: CMUX_TIMEOUT_MS,
|
|
10
|
+
});
|
|
11
|
+
return { ok: true, stdout };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { ok: false };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function findWorkspaceByTitle(title) {
|
|
18
|
+
// `--json` is a global flag and must precede the subcommand.
|
|
19
|
+
const result = cmuxRun("cmux --json list-workspaces");
|
|
20
|
+
if (!result.ok)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(result.stdout);
|
|
24
|
+
const items = parsed.workspaces ?? [];
|
|
25
|
+
return items.find((w) => w.title === title) ?? null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const cmuxMultiplexer = {
|
|
32
|
+
kind: "cmux",
|
|
33
|
+
isActive() {
|
|
34
|
+
return !!process.env["CMUX_SURFACE_ID"];
|
|
35
|
+
},
|
|
36
|
+
async createWindow({ name, cwd, command }) {
|
|
37
|
+
// `new-workspace` accepts --name, --cwd, --command in a single atomic call.
|
|
38
|
+
// `--command` sends "<text>\n" to the new surface after creation. cmux #1472 means
|
|
39
|
+
// programmatically created workspaces have dead PTYs, so the command may not actually
|
|
40
|
+
// execute — but the workspace + name are created, which is the visible win.
|
|
41
|
+
const parts = [`cmux new-workspace --name ${shellEscape(name)} --cwd ${shellEscape(cwd)}`];
|
|
42
|
+
if (command)
|
|
43
|
+
parts.push(`--command ${shellEscape(command)}`);
|
|
44
|
+
const created = cmuxRun(parts.join(" "));
|
|
45
|
+
if (!created.ok) {
|
|
46
|
+
return { ok: false, reason: "failed", message: "cmux new-workspace failed" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true };
|
|
49
|
+
},
|
|
50
|
+
async selectWindow(name) {
|
|
51
|
+
const ws = findWorkspaceByTitle(name);
|
|
52
|
+
if (!ws?.ref) {
|
|
53
|
+
return { ok: false, reason: "failed", message: `no cmux workspace named ${name}` };
|
|
54
|
+
}
|
|
55
|
+
const result = cmuxRun(`cmux select-workspace --workspace ${shellEscape(ws.ref)}`);
|
|
56
|
+
return result.ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
57
|
+
},
|
|
58
|
+
renameWindow(currentName, newName) {
|
|
59
|
+
// `workspace-action --action rename --title <text>` defaults to the caller's
|
|
60
|
+
// workspace via $CMUX_WORKSPACE_ID. When `currentName` is provided we look up
|
|
61
|
+
// that specific workspace's ref instead.
|
|
62
|
+
let target = "";
|
|
63
|
+
if (currentName) {
|
|
64
|
+
const ws = findWorkspaceByTitle(currentName);
|
|
65
|
+
if (!ws?.ref) {
|
|
66
|
+
return { ok: false, reason: "failed", message: "cmux workspace not found" };
|
|
67
|
+
}
|
|
68
|
+
target = ` --workspace ${shellEscape(ws.ref)}`;
|
|
69
|
+
}
|
|
70
|
+
const result = cmuxRun(`cmux workspace-action --action rename --title ${shellEscape(newName)}${target}`);
|
|
71
|
+
return result.ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
72
|
+
},
|
|
73
|
+
sendCommand(_name, _command) {
|
|
74
|
+
// Blocked by manaflow-ai/cmux#1472 — programmatically created workspaces have
|
|
75
|
+
// dead PTYs, so post-creation `cmux send` / `send-key` silently drop input.
|
|
76
|
+
// Initial command-on-create works via `new-workspace --command`; this path is for
|
|
77
|
+
// follow-up sends to an existing workspace, which doesn't.
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: "unsupported",
|
|
81
|
+
message: "blocked by manaflow-ai/cmux#1472",
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
isSessionAlive(ticketId) {
|
|
85
|
+
const result = cmuxRun("cmux --json list-workspaces");
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(result.stdout);
|
|
90
|
+
const items = parsed.workspaces ?? [];
|
|
91
|
+
return items.some((w) => typeof w.title === "string" && w.title.startsWith(ticketId));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Multiplexer, MultiplexerKind } from "./types.js";
|
|
2
|
+
export type { CreateWindowOpts, Multiplexer, MultiplexerKind, SessionResult } from "./types.js";
|
|
3
|
+
export declare function getMultiplexer(): Multiplexer;
|
|
4
|
+
export declare function getMultiplexerKind(): MultiplexerKind;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cmuxMultiplexer } from "./cmux.js";
|
|
2
|
+
import { noneMultiplexer } from "./none.js";
|
|
3
|
+
import { tmuxMultiplexer } from "./tmux.js";
|
|
4
|
+
export function getMultiplexer() {
|
|
5
|
+
const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
|
|
6
|
+
if (explicit === "tmux")
|
|
7
|
+
return tmuxMultiplexer;
|
|
8
|
+
if (explicit === "cmux")
|
|
9
|
+
return cmuxMultiplexer;
|
|
10
|
+
if (explicit === "none")
|
|
11
|
+
return noneMultiplexer;
|
|
12
|
+
if (process.env["TMUX"])
|
|
13
|
+
return tmuxMultiplexer;
|
|
14
|
+
if (process.env["CMUX_SURFACE_ID"])
|
|
15
|
+
return cmuxMultiplexer;
|
|
16
|
+
return noneMultiplexer;
|
|
17
|
+
}
|
|
18
|
+
export function getMultiplexerKind() {
|
|
19
|
+
return getMultiplexer().kind;
|
|
20
|
+
}
|