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
@@ -11,6 +11,9 @@ const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
12
  import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.js";
13
13
  import { getAuthStatus, getValidTokens } from "../lib/linear.js";
14
+ import { getMultiplexer } from "../lib/multiplexer/index.js";
15
+ import { resolveClaudeBinary } from "../lib/ai.js";
16
+ import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, SANTREE_PACKAGE, getLatestVersionFor, isUpdateAvailable, detectPackageManager, getInstallCommandFor, } from "../lib/version.js";
14
17
  const execAsync = promisify(exec);
15
18
  export const description = "Check system requirements and integrations";
16
19
  /**
@@ -55,6 +58,106 @@ async function checkTool(name, description, required, versionCommand, hint) {
55
58
  path,
56
59
  };
57
60
  }
61
+ /**
62
+ * Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
63
+ * binary is reachable. Surfaces a hint when the configured multiplexer can't run.
64
+ */
65
+ async function checkMultiplexer() {
66
+ const mux = getMultiplexer();
67
+ const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
68
+ const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
69
+ if (mux.kind === "none") {
70
+ return {
71
+ name: "multiplexer",
72
+ description,
73
+ required: false,
74
+ installed: false,
75
+ hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
76
+ };
77
+ }
78
+ if (mux.kind === "tmux") {
79
+ const path = await getPath("tmux");
80
+ if (!path) {
81
+ return {
82
+ name: "tmux",
83
+ description,
84
+ required: false,
85
+ installed: false,
86
+ hint: "Install: brew install tmux",
87
+ };
88
+ }
89
+ const version = await tryExec("tmux -V");
90
+ return {
91
+ name: "tmux",
92
+ description,
93
+ required: false,
94
+ installed: true,
95
+ version: version || "unknown",
96
+ path,
97
+ };
98
+ }
99
+ // cmux
100
+ const path = await getPath("cmux");
101
+ if (!path) {
102
+ return {
103
+ name: "cmux",
104
+ description,
105
+ required: false,
106
+ installed: false,
107
+ hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
108
+ };
109
+ }
110
+ const version = await tryExec("cmux --version 2>/dev/null");
111
+ const ping = await tryExec("cmux ping 2>/dev/null");
112
+ // Note: cmux #1472 (programmatic workspaces with dead PTYs) is a real
113
+ // limitation but only surfaces when a specific dashboard flow tries to
114
+ // auto-execute a command in a freshly-created workspace. Showing it on
115
+ // every doctor run made cmux look broken when it isn't — the limitation
116
+ // is documented in CLAUDE.md and the README. We only flag a hint here
117
+ // when cmux is actually unreachable.
118
+ return {
119
+ name: "cmux",
120
+ description,
121
+ required: false,
122
+ installed: !!ping,
123
+ version: version || "unknown",
124
+ path,
125
+ hint: !ping
126
+ ? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux."
127
+ : undefined,
128
+ };
129
+ }
130
+ /**
131
+ * Checks the Claude CLI, preferring cmux's bundled binary when running inside
132
+ * cmux. The standard `checkTool` uses `which claude` which can't locate the
133
+ * cmux shim at /Applications/cmux.app/Contents/Resources/bin/claude — that
134
+ * binary isn't on PATH. See manaflow-ai/cmux#2048.
135
+ */
136
+ async function checkClaude() {
137
+ const resolved = resolveClaudeBinary();
138
+ const inCmux = getMultiplexer().kind === "cmux";
139
+ const description = inCmux ? "Claude Code CLI (cmux-bundled)" : "Claude Code CLI";
140
+ if (!resolved) {
141
+ return {
142
+ name: "claude",
143
+ description,
144
+ required: true,
145
+ installed: false,
146
+ hint: inCmux
147
+ ? "Open cmux.app to install its bundled Claude, or install standalone: npm install -g @anthropic-ai/claude-code"
148
+ : "Install: npm install -g @anthropic-ai/claude-code",
149
+ };
150
+ }
151
+ const version = await tryExec(`"${resolved}" --version 2>/dev/null | head -1`);
152
+ return {
153
+ name: "claude",
154
+ description,
155
+ required: true,
156
+ installed: true,
157
+ version: version || "unknown",
158
+ path: resolved,
159
+ };
160
+ }
58
161
  /**
59
162
  * Checks GitHub CLI auth status.
60
163
  * Uses `gh api user` which works across all gh versions.
@@ -346,7 +449,7 @@ function StatusIcon({ ok, required }) {
346
449
  return required ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "yellow", children: "\u25CB" });
347
450
  }
348
451
  function ToolRow({ tool }) {
349
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
452
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.latestVersion && tool.version && (_jsxs(Text, { color: isUpdateAvailable(tool.version, tool.latestVersion) ? "yellow" : undefined, dimColor: !isUpdateAvailable(tool.version, tool.latestVersion), children: ["Latest: ", tool.latestVersion, isUpdateAvailable(tool.version, tool.latestVersion) ? " ⬆ update available" : ""] })), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.updateHint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.updateHint] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
350
453
  }
351
454
  function LinearRow({ linear }) {
352
455
  const isOk = linear.authenticated && linear.tokenValid && linear.repoLinked;
@@ -396,30 +499,78 @@ export default function Doctor() {
396
499
  const [loading, setLoading] = useState(true);
397
500
  useEffect(() => {
398
501
  async function runChecks() {
399
- const results = await Promise.all([
400
- checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
401
- checkGhAuth(),
402
- checkTool("tmux", "Terminal multiplexer", false, "tmux -V", "Install: brew install tmux"),
403
- checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
502
+ const pm = detectPackageManager();
503
+ const [results, latestSantree, latestClaude] = await Promise.all([
504
+ Promise.all([
505
+ checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
506
+ checkGhAuth(),
507
+ checkMultiplexer(),
508
+ checkClaude(),
509
+ ]),
510
+ getLatestVersionFor(SANTREE_PACKAGE),
511
+ getLatestVersionFor(CLAUDE_CODE_PACKAGE),
404
512
  ]);
405
- // Check for either code or cursor (only need one)
513
+ // Synthetic row for santree itself surfaces update status.
514
+ const santreeRow = {
515
+ name: "santree",
516
+ description: "Santree CLI (this app)",
517
+ required: true,
518
+ installed: true,
519
+ version: CURRENT_VERSION,
520
+ latestVersion: latestSantree ?? undefined,
521
+ updateHint: latestSantree && isUpdateAvailable(CURRENT_VERSION, latestSantree)
522
+ ? "Run: santree update"
523
+ : undefined,
524
+ };
525
+ results.unshift(santreeRow);
526
+ // Augment the claude row with latest-version info from npm registry.
527
+ // When the resolved binary is the cmux-bundled one, npm install can't
528
+ // update it — the bundled binary is shipped inside cmux.app. Show a
529
+ // cmux-aware hint instead of the generic npm command.
530
+ const claudeRow = results.find((r) => r.name === "claude");
531
+ if (claudeRow && claudeRow.installed && latestClaude) {
532
+ claudeRow.latestVersion = latestClaude;
533
+ if (claudeRow.version && isUpdateAvailable(claudeRow.version, latestClaude)) {
534
+ const isCmuxBundled = !!claudeRow.path?.includes("/cmux.app/");
535
+ if (isCmuxBundled) {
536
+ claudeRow.updateHint = "Bundled with cmux — update cmux.app to get the latest Claude.";
537
+ }
538
+ else {
539
+ const cmd = getInstallCommandFor(pm, `${CLAUDE_CODE_PACKAGE}@latest`);
540
+ claudeRow.updateHint = `Run: ${cmd.display}`;
541
+ }
542
+ }
543
+ }
544
+ // Optional: a syntax-highlighted diff pager — used by `st worktree diff`
545
+ // and the dashboard `v` overlay when SANTREE_DIFF_TOOL is set. Any
546
+ // pager works (delta, diff-so-fancy, …); without one set, git's
547
+ // default pager runs. Delta is the most popular choice so we check
548
+ // for it as a convenience, but it is never a hard dependency.
549
+ const deltaCheck = await checkTool("delta", "Recommended diff pager — any pager works", false, "delta --version | head -1", "Optional — git's default pager works too. Set SANTREE_DIFF_TOOL or `git config core.pager <tool>`. To install delta: brew install git-delta");
550
+ results.push(deltaCheck);
551
+ // Optional: a `.code-workspace`-aware editor (VSCode or Cursor).
552
+ // Santree itself works with any editor via $SANTREE_EDITOR — this
553
+ // check exists only because the dashboard's `E workspace` shortcut
554
+ // needs an editor that understands `.code-workspace` files. Missing
555
+ // here just means the shortcut is hidden; everything else still works.
556
+ const workspaceEditorDesc = "Workspace editor (`E workspace` shortcut)";
406
557
  const [codeCheck, cursorCheck] = await Promise.all([
407
- checkTool("code", "VSCode editor", false, "code --version | head -1", ""),
408
- checkTool("cursor", "Cursor editor", false, "cursor --version | head -1", ""),
558
+ checkTool("code", workspaceEditorDesc, false, "code --version | head -1", ""),
559
+ checkTool("cursor", workspaceEditorDesc, false, "cursor --version | head -1", ""),
409
560
  ]);
410
561
  if (codeCheck.installed) {
411
- results.push({ ...codeCheck, description: "Editor (VSCode)" });
562
+ results.push(codeCheck);
412
563
  }
413
564
  else if (cursorCheck.installed) {
414
- results.push({ ...cursorCheck, description: "Editor (Cursor)" });
565
+ results.push(cursorCheck);
415
566
  }
416
567
  else {
417
568
  results.push({
418
569
  name: "code/cursor",
419
- description: "Editor (VSCode or Cursor)",
570
+ description: workspaceEditorDesc,
420
571
  required: false,
421
572
  installed: false,
422
- hint: "Install VSCode (https://code.visualstudio.com) or Cursor (https://cursor.sh)",
573
+ hint: "Optional santree works with any $SANTREE_EDITOR. Only needed for the dashboard's `.code-workspace` shortcut.",
423
574
  });
424
575
  }
425
576
  const linearResult = await checkLinearAuth();
@@ -132,9 +132,17 @@ function getGitChanges(cwd) {
132
132
  untracked: countLines(git(cwd, "ls-files --others --exclude-standard")),
133
133
  };
134
134
  }
135
- // Build a progress bar for context usage
135
+ // Build a progress bar for context usage.
136
+ //
137
+ // We deliberately inflate the displayed percentage by 20% (clamped to 100) so
138
+ // the bar fills up faster than the model's actual context window. The point is
139
+ // to nudge toward more-frequent /compact: the color thresholds (60%/80%) trip
140
+ // earlier, so the yellow/red warnings show up while there's still real headroom
141
+ // left to compact gracefully instead of after the model has already started
142
+ // dropping content.
143
+ const CONTEXT_DISPLAY_MULTIPLIER = 1.2;
136
144
  function formatContextUsage(usedPercentage) {
137
- const used = Math.round(usedPercentage);
145
+ const used = Math.min(100, Math.round(usedPercentage * CONTEXT_DISPLAY_MULTIPLIER));
138
146
  const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
139
147
  const width = 20;
140
148
  const filled = Math.round((used * width) / 100);
@@ -0,0 +1,13 @@
1
+ import { z } from "zod/v4";
2
+ export declare const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
3
+ export declare const options: z.ZodObject<{
4
+ initial: z.ZodOptional<z.ZodString>;
5
+ from: z.ZodOptional<z.ZodString>;
6
+ ext: z.ZodDefault<z.ZodString>;
7
+ editor: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>;
9
+ type Props = {
10
+ options: z.infer<typeof options>;
11
+ };
12
+ export default function TextEditor({ options: opts }: Props): null;
13
+ export {};
@@ -0,0 +1,118 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useApp } from "ink";
3
+ import { option } from "pastel";
4
+ import { z } from "zod/v4";
5
+ import { spawnSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ export const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
10
+ export const options = z.object({
11
+ initial: z
12
+ .string()
13
+ .optional()
14
+ .describe(option({ description: "Pre-fill the editor buffer with this text" })),
15
+ from: z
16
+ .string()
17
+ .optional()
18
+ .describe(option({ description: "Pre-fill the editor buffer with the contents of this file" })),
19
+ ext: z
20
+ .string()
21
+ .default("md")
22
+ .describe(option({ description: "Temp file extension (default: md)" })),
23
+ editor: z
24
+ .string()
25
+ .optional()
26
+ .describe(option({ description: "Override the editor command (default: $VISUAL || $EDITOR || vim)" })),
27
+ });
28
+ function resolveEditor(override) {
29
+ const raw = override ?? process.env["VISUAL"] ?? process.env["EDITOR"] ?? "vim";
30
+ const parts = raw.split(/\s+/).filter(Boolean);
31
+ const cmd = parts[0] ?? "vim";
32
+ return { cmd, args: parts.slice(1) };
33
+ }
34
+ // Render null and write all UI feedback to stderr so stdout stays clean for
35
+ // shell capture: `file=$(st helpers text-editor) && st worktree work --context-file "$file"`.
36
+ export default function TextEditor({ options: opts }) {
37
+ const { exit } = useApp();
38
+ const hasRun = useRef(false);
39
+ useEffect(() => {
40
+ if (hasRun.current)
41
+ return;
42
+ hasRun.current = true;
43
+ const ext = opts.ext.replace(/^\./, "");
44
+ const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext}`);
45
+ const seed = (() => {
46
+ if (opts.from) {
47
+ try {
48
+ return fs.readFileSync(opts.from, "utf-8");
49
+ }
50
+ catch {
51
+ return opts.initial ?? "";
52
+ }
53
+ }
54
+ return opts.initial ?? "";
55
+ })();
56
+ try {
57
+ fs.writeFileSync(filePath, seed);
58
+ }
59
+ catch (err) {
60
+ process.stderr.write(`Failed to create temp file: ${err.message}\n`);
61
+ process.exitCode = 1;
62
+ exit();
63
+ return;
64
+ }
65
+ // Ink put stdin in raw mode on mount; release it for the editor.
66
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
67
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
68
+ try {
69
+ process.stdin.setRawMode(false);
70
+ }
71
+ catch { }
72
+ }
73
+ const { cmd, args } = resolveEditor(opts.editor);
74
+ const result = spawnSync(cmd, [...args, filePath], { stdio: "inherit" });
75
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
76
+ try {
77
+ process.stdin.setRawMode(wasRaw);
78
+ }
79
+ catch { }
80
+ }
81
+ if (result.error || result.status !== 0) {
82
+ process.stderr.write(result.error
83
+ ? `Failed to launch editor '${cmd}': ${result.error.message}\n`
84
+ : `Editor '${cmd}' exited with status ${result.status}\n`);
85
+ try {
86
+ fs.unlinkSync(filePath);
87
+ }
88
+ catch { }
89
+ process.exitCode = 1;
90
+ exit();
91
+ return;
92
+ }
93
+ let content = "";
94
+ try {
95
+ content = fs.readFileSync(filePath, "utf-8");
96
+ }
97
+ catch (err) {
98
+ process.stderr.write(`Failed to read temp file: ${err.message}\n`);
99
+ process.exitCode = 1;
100
+ exit();
101
+ return;
102
+ }
103
+ // Empty buffer => treat as cancel (matches `git commit` behavior)
104
+ if (content.trim().length === 0) {
105
+ try {
106
+ fs.unlinkSync(filePath);
107
+ }
108
+ catch { }
109
+ process.stderr.write("Cancelled (empty buffer)\n");
110
+ process.exitCode = 1;
111
+ exit();
112
+ return;
113
+ }
114
+ process.stdout.write(`${filePath}\n`);
115
+ exit();
116
+ }, [opts, exit]);
117
+ return null;
118
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Update santree to the latest version on npm";
3
+ export declare const options: z.ZodObject<{
4
+ force: z.ZodOptional<z.ZodBoolean>;
5
+ pm: z.ZodOptional<z.ZodEnum<{
6
+ npm: "npm";
7
+ pnpm: "pnpm";
8
+ yarn: "yarn";
9
+ }>>;
10
+ }, z.core.$strip>;
11
+ type Props = {
12
+ options: z.infer<typeof options>;
13
+ };
14
+ export default function Update({ options }: Props): import("react/jsx-runtime").JSX.Element;
15
+ export {};
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box, useApp } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { z } from "zod";
6
+ import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getLatestVersionFor, getInstalledClaudeVersion, isUpdateAvailable, detectPackageManager, getInstallCommand, getInstallCommandFor, } from "../lib/version.js";
7
+ import { spawnAsync } from "../lib/exec.js";
8
+ export const description = "Update santree to the latest version on npm";
9
+ export const options = z.object({
10
+ force: z.boolean().optional().describe("Reinstall even if already on the latest version"),
11
+ pm: z
12
+ .enum(["npm", "pnpm", "yarn"])
13
+ .optional()
14
+ .describe("Override package manager auto-detection"),
15
+ });
16
+ const TAIL_LINES = 8;
17
+ function tail(text, n) {
18
+ return text
19
+ .split("\n")
20
+ .filter((l) => l.length > 0)
21
+ .slice(-n);
22
+ }
23
+ export default function Update({ options }) {
24
+ const { exit } = useApp();
25
+ const [status, setStatus] = useState("checking");
26
+ const [latest, setLatest] = useState(null);
27
+ const [pm, setPm] = useState("npm");
28
+ const [installCmd, setInstallCmd] = useState("");
29
+ const [output, setOutput] = useState("");
30
+ const [error, setError] = useState(null);
31
+ const [claude, setClaude] = useState(null);
32
+ useEffect(() => {
33
+ (async () => {
34
+ await new Promise((r) => setTimeout(r, 80));
35
+ // Check santree + claude versions in parallel.
36
+ const [latestVersion, latestClaude] = await Promise.all([
37
+ getLatestVersion({ force: true }),
38
+ getLatestVersionFor(CLAUDE_CODE_PACKAGE, { force: true }),
39
+ ]);
40
+ setLatest(latestVersion);
41
+ setClaude({ installed: getInstalledClaudeVersion(), latest: latestClaude });
42
+ const detectedPm = options.pm ?? detectPackageManager();
43
+ setPm(detectedPm);
44
+ const cmd = getInstallCommand(detectedPm);
45
+ setInstallCmd(cmd.display);
46
+ if (!latestVersion) {
47
+ setStatus("error");
48
+ setError("Could not reach the npm registry. Check your connection.");
49
+ setTimeout(() => exit(), 100);
50
+ return;
51
+ }
52
+ if (!options.force && !isUpdateAvailable(CURRENT_VERSION, latestVersion)) {
53
+ setStatus("up-to-date");
54
+ setTimeout(() => exit(), 100);
55
+ return;
56
+ }
57
+ setStatus("installing");
58
+ const result = await spawnAsync(cmd.cmd, cmd.args, {
59
+ onOutput: (data) => setOutput(data),
60
+ });
61
+ if (result.code === 0) {
62
+ setStatus("done");
63
+ }
64
+ else {
65
+ setStatus("error");
66
+ setError(`${cmd.display} exited with code ${result.code}`);
67
+ }
68
+ setTimeout(() => exit(), 100);
69
+ })();
70
+ }, []);
71
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Santree Update" }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "current:" }), _jsxs(Text, { children: ["v", CURRENT_VERSION] })] }), latest && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "latest: " }), _jsxs(Text, { color: isUpdateAvailable(CURRENT_VERSION, latest) ? "yellow" : "green", children: ["v", latest] })] })), installCmd && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "via: " }), _jsxs(Text, { children: [installCmd, " ", _jsxs(Text, { dimColor: true, children: ["(detected: ", pm, ")"] })] })] }))] }), _jsxs(Box, { marginTop: 1, children: [status === "checking" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "Checking npm registry..." })] })), status === "installing" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: ["Running ", installCmd, "..."] })] })), status === "up-to-date" && (_jsx(Text, { color: "green", bold: true, children: "\u2713 Already on the latest version" })), status === "done" && latest && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Updated to v", latest] })), status === "error" && error && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] }), (status === "installing" || status === "error") && output && (_jsx(Box, { marginTop: 1, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: tail(output, TAIL_LINES).map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) })), claude && claude.installed && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: claude.latest && isUpdateAvailable(claude.installed, claude.latest) ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", children: ["\u2B06 Claude Code ", claude.installed, " \u2192 ", claude.latest, " available"] }), _jsxs(Text, { dimColor: true, children: ["Run: ", getInstallCommandFor(pm, `${CLAUDE_CODE_PACKAGE}@latest`).display] })] })) : claude.latest ? (_jsxs(Text, { dimColor: true, children: ["\u2713 Claude Code ", claude.installed, " is up to date"] })) : null }))] }));
72
+ }
@@ -5,6 +5,7 @@ export declare const options: z.ZodObject<{
5
5
  work: z.ZodOptional<z.ZodBoolean>;
6
6
  plan: z.ZodOptional<z.ZodBoolean>;
7
7
  "no-pull": z.ZodOptional<z.ZodBoolean>;
8
+ window: z.ZodOptional<z.ZodBoolean>;
8
9
  tmux: z.ZodOptional<z.ZodBoolean>;
9
10
  name: z.ZodOptional<z.ZodString>;
10
11
  }, z.core.$strip>;
@@ -3,36 +3,21 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { execSync } from "child_process";
7
6
  import * as fs from "fs";
8
7
  import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../../lib/git.js";
9
8
  import { spawnAsync } from "../../lib/exec.js";
9
+ import { getMultiplexer } from "../../lib/multiplexer/index.js";
10
10
  export const description = "Create a new worktree from a branch";
11
11
  export const options = z.object({
12
12
  base: z.string().optional().describe("Base branch to create from"),
13
13
  work: z.boolean().optional().describe("Launch Claude after creating"),
14
14
  plan: z.boolean().optional().describe("With --work, only plan"),
15
15
  "no-pull": z.boolean().optional().describe("Skip pulling latest changes"),
16
- tmux: z.boolean().optional().describe("Create a new tmux window"),
17
- name: z.string().optional().describe("Custom tmux window name"),
16
+ window: z.boolean().optional().describe("Create a new multiplexer window/workspace (tmux/cmux)"),
17
+ tmux: z.boolean().optional().describe("Alias for --window (deprecated)"),
18
+ name: z.string().optional().describe("Custom window/workspace name"),
18
19
  });
19
20
  export const args = z.tuple([z.string().optional().describe("Branch name")]);
20
- function isInTmux() {
21
- return !!process.env.TMUX;
22
- }
23
- function createTmuxWindow(name, path, runCommand) {
24
- try {
25
- execSync(`tmux new-window -n "${name}" -c "${path}"`, { stdio: "ignore" });
26
- // If a command is provided, send it to the new window
27
- if (runCommand) {
28
- execSync(`tmux send-keys -t "${name}" "${runCommand}" Enter`, { stdio: "ignore" });
29
- }
30
- return true;
31
- }
32
- catch {
33
- return false;
34
- }
35
- }
36
21
  function getWindowName(branchName, customName) {
37
22
  if (customName)
38
23
  return customName;
@@ -50,35 +35,38 @@ export default function Create({ options, args }) {
50
35
  const [message, setMessage] = useState("");
51
36
  const [worktreePath, setWorktreePath] = useState("");
52
37
  const [baseBranch, setBaseBranch] = useState(null);
53
- const [tmuxWindowName, setTmuxWindowName] = useState(null);
54
- function finalize(path, branch) {
55
- // Handle tmux window creation
56
- if (options.tmux) {
57
- if (!isInTmux()) {
58
- setMessage("Worktree created, but not in tmux session");
38
+ const [muxWindowName, setMuxWindowName] = useState(null);
39
+ const [muxKind, setMuxKind] = useState(null);
40
+ async function finalize(path, branch) {
41
+ const wantsWindow = options.window || options.tmux;
42
+ if (wantsWindow) {
43
+ const mux = getMultiplexer();
44
+ if (!mux.isActive()) {
45
+ setMessage("Worktree created, but no active multiplexer");
59
46
  setStatus("done");
60
47
  console.log(`SANTREE_CD:${path}`);
61
48
  return;
62
49
  }
63
- setStatus("tmux");
64
- setMessage("Creating tmux window...");
50
+ setStatus("spawning-window");
51
+ setMessage(`Creating ${mux.kind} window...`);
65
52
  const windowName = getWindowName(branch, options.name);
66
- setTmuxWindowName(windowName);
67
- // Build command to run in new window (if --work is set)
53
+ setMuxWindowName(windowName);
54
+ setMuxKind(mux.kind);
68
55
  let runCommand;
69
56
  if (options.work) {
70
57
  runCommand = options.plan ? "st worktree work --plan" : "st worktree work";
71
58
  }
72
- if (!createTmuxWindow(windowName, path, runCommand)) {
73
- setMessage("Worktree created, but failed to create tmux window");
59
+ const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
60
+ if (!result.ok) {
61
+ setMessage(`Worktree created, but failed to create ${mux.kind} window${result.message ? `: ${result.message}` : ""}`);
74
62
  setStatus("done");
75
63
  console.log(`SANTREE_CD:${path}`);
76
64
  return;
77
65
  }
78
66
  setStatus("done");
79
67
  const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
80
- setMessage(`Worktree and tmux window created!${workInfo}`);
81
- // Don't output SANTREE_CD when tmux window is created - user is already in new window
68
+ setMessage(`Worktree and ${mux.kind} window created!${workInfo}`);
69
+ // Don't output SANTREE_CD when a window is created user is already in the new window
82
70
  return;
83
71
  }
84
72
  setStatus("done");
@@ -133,7 +121,7 @@ export default function Create({ options, args }) {
133
121
  }
134
122
  catch {
135
123
  setMessage("Warning: Init script exists but is not executable");
136
- finalize(result.path, branch);
124
+ await finalize(result.path, branch);
137
125
  return;
138
126
  }
139
127
  const initResult = await spawnAsync(initScript, [], {
@@ -147,10 +135,10 @@ export default function Create({ options, args }) {
147
135
  if (initResult.code !== 0) {
148
136
  setMessage(`Warning: Init script exited with code ${initResult.code}`);
149
137
  }
150
- finalize(result.path, branch);
138
+ await finalize(result.path, branch);
151
139
  }
152
140
  else {
153
- finalize(result.path, branch);
141
+ await finalize(result.path, branch);
154
142
  }
155
143
  }
156
144
  else {
@@ -165,9 +153,13 @@ export default function Create({ options, args }) {
165
153
  options.work,
166
154
  options.plan,
167
155
  options["no-pull"],
156
+ options.window,
168
157
  options.tmux,
169
158
  options.name,
170
159
  ]);
171
- const isLoading = status === "pulling" || status === "creating" || status === "init-script" || status === "tmux";
172
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), options.tmux && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "tmux:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), tmuxWindowName && _jsxs(Text, { dimColor: true, children: [" tmux window: ", tmuxWindowName] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
160
+ const isLoading = status === "pulling" ||
161
+ status === "creating" ||
162
+ status === "init-script" ||
163
+ status === "spawning-window";
164
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && (_jsxs(Text, { dimColor: true, children: [" ", muxKind ?? "tmux", " window: ", muxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
173
165
  }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ export declare const description = "View worktree diff against its base branch (uses delta if installed)";
3
+ export declare const options: z.ZodObject<{
4
+ staged: z.ZodOptional<z.ZodBoolean>;
5
+ unstaged: z.ZodOptional<z.ZodBoolean>;
6
+ commits: z.ZodOptional<z.ZodBoolean>;
7
+ base: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>;
9
+ type Props = {
10
+ options: z.infer<typeof options>;
11
+ };
12
+ export default function Diff({ options: opts }: Props): import("react/jsx-runtime").JSX.Element | null;
13
+ export {};