santree 0.2.15 → 0.4.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 CHANGED
@@ -317,6 +317,15 @@ echo "$(date): $SANTREE_TICKET_ID waiting — $SANTREE_MESSAGE" >> /tmp/santree-
317
317
 
318
318
  Make it executable: `chmod +x .santree/hooks/on-waiting.sh`
319
319
 
320
+ ### Environment Variables
321
+
322
+ | Variable | Effect |
323
+ |---|---|
324
+ | `SANTREE_EDITOR` | Editor used by `e` (open in editor) actions in the dashboard. Defaults to `code`. Examples: `cursor`, `zed`, `code`, `nvim`. |
325
+ | `SANTREE_MULTIPLEXER` | Terminal multiplexer used by the dashboard and `worktree create --window`. One of `tmux`, `cmux`, `none`. If unset, auto-detects from `$TMUX` / `$CMUX_SURFACE_ID`. cmux is macOS-only and limited by [manaflow-ai/cmux#1472](https://github.com/manaflow-ai/cmux/issues/1472). |
326
+
327
+ Santree always launches Claude with `--permission-mode auto` (Claude Code's auto mode), or `plan` when invoked in plan mode. Worktree-scoped automation is the default — there is no opt-in flag.
328
+
320
329
  ---
321
330
 
322
331
  ## Command Options
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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";
@@ -13,6 +13,7 @@ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasI
13
13
  import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
15
  import { extractTicketId } from "../lib/git.js";
16
+ import { getMultiplexer } from "../lib/multiplexer/index.js";
16
17
  import { getPRTemplate } from "../lib/github.js";
17
18
  import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
18
19
  import { getTicketContent } from "../lib/linear.js";
@@ -39,9 +40,6 @@ const CLAUDE_VERSION = (() => {
39
40
  }
40
41
  })();
41
42
  // ── Helpers ───────────────────────────────────────────────────────────
42
- function isInTmux() {
43
- return !!process.env.TMUX;
44
- }
45
43
  function slugify(title) {
46
44
  return title
47
45
  .toLowerCase()
@@ -137,12 +135,7 @@ function ensureAltScreen() {
137
135
  if (altScreenEntered)
138
136
  return;
139
137
  altScreenEntered = true;
140
- if (isInTmux()) {
141
- try {
142
- execSync('tmux rename-window "santree"', { stdio: "ignore" });
143
- }
144
- catch { }
145
- }
138
+ getMultiplexer().renameWindow("", "santree");
146
139
  process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
147
140
  process.stdout.write("\x1b[?25l"); // Hide cursor
148
141
  }
@@ -371,77 +364,95 @@ export default function Dashboard() {
371
364
  // ── Mouse tracking pause ─────────────────────────────────────────
372
365
  // The MultilineTextArea captures ESC for cancel. With SGR mouse tracking on,
373
366
  // every click emits `\x1b[<btn;col;rowM` — Ink reads the leading ESC and fires
374
- // key.escape, dismissing the overlay. Disable mouse tracking while the
375
- // context-input overlay is mounted; restore on unmount.
367
+ // key.escape, dismissing the overlay. Disable tracking while any overlay
368
+ // phase mounts a MultilineTextArea (context-input editing OR pr-create
369
+ // review); restore when that phase ends.
376
370
  useEffect(() => {
377
- if (state.overlay !== "context-input")
371
+ const needsMouseOff = (state.overlay === "context-input" && state.contextInputPhase === "editing") ||
372
+ (state.overlay === "pr-create" && state.prCreatePhase === "review");
373
+ if (!needsMouseOff)
378
374
  return;
379
375
  process.stdout.write("\x1b[?1002l\x1b[?1006l");
380
376
  return () => {
381
377
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
382
378
  };
383
- }, [state.overlay]);
379
+ }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
384
380
  // ── Actions ───────────────────────────────────────────────────────
385
- const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
381
+ const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
386
382
  const windowName = di.issue.identifier;
387
383
  const sessionId = di.worktree?.sessionId;
388
384
  const bin = resolveAgentBinary();
389
385
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
390
386
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
391
387
  const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
392
- try {
393
- // Switch to existing window if it exists
394
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
395
- const cmd = resumeCmd ?? workCmd;
396
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
397
- dispatch({
398
- type: "SET_ACTION_MESSAGE",
399
- message: resumeCmd
400
- ? `Resumed session in: ${windowName}`
401
- : `Launched ${mode} in: ${windowName}`,
402
- });
388
+ const cmd = resumeCmd ?? workCmd;
389
+ const mux = getMultiplexer();
390
+ const selected = await mux.selectWindow(windowName);
391
+ if (selected.ok) {
392
+ const sent = mux.sendCommand(windowName, cmd);
393
+ if (sent.ok) {
394
+ dispatch({
395
+ type: "SET_ACTION_MESSAGE",
396
+ message: resumeCmd
397
+ ? `Resumed session in: ${windowName}`
398
+ : `Launched ${mode} in: ${windowName}`,
399
+ });
400
+ }
401
+ else {
402
+ dispatch({
403
+ type: "SET_ACTION_MESSAGE",
404
+ message: `Focused ${windowName} — run \`${cmd}\` manually (${sent.reason})`,
405
+ });
406
+ }
403
407
  }
404
- catch {
405
- // Window doesn't exist — create it
406
- try {
407
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
408
- // Small delay so the new shell can start reading input before we send keys,
409
- // otherwise buffered keystrokes from the dashboard pane can leak in.
410
- execSync("sleep 0.1", { stdio: "ignore" });
411
- const cmd = resumeCmd ?? workCmd;
412
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
408
+ else {
409
+ const created = await mux.createWindow({
410
+ name: windowName,
411
+ cwd: worktreePath,
412
+ command: cmd,
413
+ });
414
+ if (created.ok) {
413
415
  dispatch({
414
416
  type: "SET_ACTION_MESSAGE",
415
417
  message: resumeCmd
416
418
  ? `Resumed session in new window: ${windowName}`
417
- : `Launched ${mode} in tmux window: ${windowName}`,
419
+ : `Launched ${mode} in ${mux.kind} window: ${windowName}`,
418
420
  });
419
421
  }
420
- catch {
421
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to create tmux window" });
422
+ else {
423
+ dispatch({
424
+ type: "SET_ACTION_MESSAGE",
425
+ message: `Failed to create ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
426
+ });
422
427
  }
423
428
  }
424
429
  // Delayed refresh to pick up session ID created by `st worktree work`
425
430
  setTimeout(() => refresh(), 3000);
426
431
  }, [refresh]);
427
- const launchAfterCreation = useCallback((mode, worktreePath, ticketId, contextFile) => {
428
- if (isInTmux()) {
432
+ const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
433
+ const mux = getMultiplexer();
434
+ if (mux.isActive()) {
429
435
  const windowName = ticketId;
430
436
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
431
437
  const workCmd = mode === "plan"
432
438
  ? `st worktree work --plan${contextArg}`
433
439
  : `st worktree work${contextArg}`;
434
- try {
435
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
436
- execSync("sleep 0.1", { stdio: "ignore" });
437
- execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
440
+ const created = await mux.createWindow({
441
+ name: windowName,
442
+ cwd: worktreePath,
443
+ command: workCmd,
444
+ });
445
+ if (created.ok) {
438
446
  dispatch({
439
447
  type: "SET_ACTION_MESSAGE",
440
448
  message: `Created worktree + launched ${mode} in: ${windowName}`,
441
449
  });
442
450
  }
443
- catch {
444
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree created, but tmux failed" });
451
+ else {
452
+ dispatch({
453
+ type: "SET_ACTION_MESSAGE",
454
+ message: `Worktree created, but ${mux.kind} failed${created.message ? `: ${created.message}` : ""}`,
455
+ });
445
456
  }
446
457
  setTimeout(() => refresh(), 3000);
447
458
  }
@@ -587,8 +598,8 @@ export default function Dashboard() {
587
598
  const contextFile = writeContextFile(customContext);
588
599
  if (di.worktree) {
589
600
  // Worktree exists — launch work
590
- if (isInTmux()) {
591
- launchWorkInTmux(di, mode, di.worktree.path, contextFile);
601
+ if (getMultiplexer().isActive()) {
602
+ void launchWorkInTmux(di, mode, di.worktree.path, contextFile);
592
603
  }
593
604
  else {
594
605
  leaveAltScreen();
@@ -890,6 +901,9 @@ export default function Dashboard() {
890
901
  }
891
902
  // PR create overlay
892
903
  if (state.overlay === "pr-create") {
904
+ // Review phase — MultilineTextArea owns the keyboard
905
+ if (state.prCreatePhase === "review")
906
+ return;
893
907
  if (key.escape) {
894
908
  dispatch({ type: "PR_CREATE_CANCEL" });
895
909
  return;
@@ -904,21 +918,17 @@ export default function Dashboard() {
904
918
  return;
905
919
  }
906
920
  }
907
- if (state.prCreatePhase === "review") {
921
+ if (state.prCreatePhase === "confirm") {
908
922
  if (input === "y" || key.return) {
909
923
  confirmPrCreate();
910
924
  return;
911
925
  }
912
- if (input === "w") {
913
- openPrInWeb();
914
- return;
915
- }
916
- if (key.shift && key.downArrow) {
917
- dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
926
+ if (input === "e") {
927
+ dispatch({ type: "PR_CREATE_EDIT" });
918
928
  return;
919
929
  }
920
- if (key.shift && key.upArrow) {
921
- dispatch({ type: "SCROLL_DETAIL", offset: Math.max(0, state.detailScrollOffset - 3) });
930
+ if (input === "w") {
931
+ openPrInWeb();
922
932
  return;
923
933
  }
924
934
  }
@@ -1001,9 +1011,29 @@ export default function Dashboard() {
1001
1011
  }
1002
1012
  return;
1003
1013
  }
1004
- // Context-input overlay — MultilineTextArea owns its own useInput;
1005
- // outer useInput is disabled for this overlay via isActive below.
1014
+ // Context-input overlay.
1015
+ // Editing phase: MultilineTextArea owns useInput (outer is disabled
1016
+ // via isActive below).
1017
+ // Review phase: outer handles y/n/e/ESC.
1006
1018
  if (state.overlay === "context-input") {
1019
+ if (state.contextInputPhase === "review") {
1020
+ if (input === "y" || key.return) {
1021
+ const mode = state.contextInputMode;
1022
+ const ctx = state.contextInputValue;
1023
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
1024
+ if (mode)
1025
+ doWork(mode, ctx);
1026
+ return;
1027
+ }
1028
+ if (input === "n" || input === "e") {
1029
+ dispatch({ type: "CONTEXT_INPUT_EDIT" });
1030
+ return;
1031
+ }
1032
+ if (key.escape) {
1033
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
1034
+ return;
1035
+ }
1036
+ }
1007
1037
  return;
1008
1038
  }
1009
1039
  // Confirm delete overlay
@@ -1203,7 +1233,7 @@ export default function Dashboard() {
1203
1233
  dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
1204
1234
  return;
1205
1235
  }
1206
- // AI Review in tmux
1236
+ // AI Review in multiplexer
1207
1237
  if (input === "r") {
1208
1238
  if (!ri.worktree) {
1209
1239
  dispatch({
@@ -1212,21 +1242,23 @@ export default function Dashboard() {
1212
1242
  });
1213
1243
  return;
1214
1244
  }
1215
- if (isInTmux()) {
1245
+ const mux = getMultiplexer();
1246
+ if (mux.isActive()) {
1216
1247
  const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
1217
- try {
1218
- execSync(`tmux new-window -n "${windowName}" -c "${ri.worktree.path}"`, {
1219
- stdio: "ignore",
1248
+ const cwd = ri.worktree.path;
1249
+ void (async () => {
1250
+ const created = await mux.createWindow({
1251
+ name: windowName,
1252
+ cwd,
1253
+ command: "st pr review",
1220
1254
  });
1221
- execSync("sleep 0.1", { stdio: "ignore" });
1222
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, {
1223
- stdio: "ignore",
1255
+ dispatch({
1256
+ type: "SET_ACTION_MESSAGE",
1257
+ message: created.ok
1258
+ ? `Launched AI review in ${mux.kind}`
1259
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1224
1260
  });
1225
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched AI review in tmux" });
1226
- }
1227
- catch {
1228
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1229
- }
1261
+ })();
1230
1262
  }
1231
1263
  else {
1232
1264
  leaveAltScreen();
@@ -1308,30 +1340,30 @@ export default function Dashboard() {
1308
1340
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
1309
1341
  return;
1310
1342
  }
1311
- if (isInTmux()) {
1343
+ const mux = getMultiplexer();
1344
+ if (mux.isActive()) {
1312
1345
  const windowName = di.issue.identifier;
1313
1346
  const sessionId = di.worktree.sessionId;
1314
1347
  const bin = resolveAgentBinary();
1315
1348
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
1316
- try {
1317
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
1318
- }
1319
- catch {
1320
- // Window doesn't exist — create one and resume/launch
1321
- try {
1322
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1323
- stdio: "ignore",
1324
- });
1325
- execSync("sleep 0.1", { stdio: "ignore" });
1326
- const cmd = resumeCmd ?? "st worktree work";
1327
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, {
1328
- stdio: "ignore",
1349
+ const worktreePath = di.worktree.path;
1350
+ void (async () => {
1351
+ const selected = await mux.selectWindow(windowName);
1352
+ if (selected.ok)
1353
+ return;
1354
+ const cmd = resumeCmd ?? "st worktree work";
1355
+ const created = await mux.createWindow({
1356
+ name: windowName,
1357
+ cwd: worktreePath,
1358
+ command: cmd,
1359
+ });
1360
+ if (!created.ok) {
1361
+ dispatch({
1362
+ type: "SET_ACTION_MESSAGE",
1363
+ message: `Failed to switch ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
1329
1364
  });
1330
1365
  }
1331
- catch {
1332
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
1333
- }
1334
- }
1366
+ })();
1335
1367
  }
1336
1368
  else {
1337
1369
  leaveAltScreen();
@@ -1386,18 +1418,23 @@ export default function Dashboard() {
1386
1418
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
1387
1419
  return;
1388
1420
  }
1389
- if (isInTmux()) {
1421
+ const mux = getMultiplexer();
1422
+ if (mux.isActive()) {
1390
1423
  const windowName = `review-${di.issue.identifier}`;
1391
- try {
1392
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1393
- stdio: "ignore",
1424
+ const cwd = di.worktree.path;
1425
+ void (async () => {
1426
+ const created = await mux.createWindow({
1427
+ name: windowName,
1428
+ cwd,
1429
+ command: "st pr review",
1394
1430
  });
1395
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, { stdio: "ignore" });
1396
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched review in tmux" });
1397
- }
1398
- catch {
1399
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1400
- }
1431
+ dispatch({
1432
+ type: "SET_ACTION_MESSAGE",
1433
+ message: created.ok
1434
+ ? `Launched review in ${mux.kind}`
1435
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1436
+ });
1437
+ })();
1401
1438
  }
1402
1439
  else {
1403
1440
  leaveAltScreen();
@@ -1445,18 +1482,23 @@ export default function Dashboard() {
1445
1482
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
1446
1483
  return;
1447
1484
  }
1448
- if (isInTmux()) {
1485
+ const mux = getMultiplexer();
1486
+ if (mux.isActive()) {
1449
1487
  const windowName = `fix-${di.issue.identifier}`;
1450
- try {
1451
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1452
- stdio: "ignore",
1488
+ const cwd = di.worktree.path;
1489
+ void (async () => {
1490
+ const created = await mux.createWindow({
1491
+ name: windowName,
1492
+ cwd,
1493
+ command: "st pr fix",
1453
1494
  });
1454
- execSync(`tmux send-keys -t "${windowName}" "st pr fix" Enter`, { stdio: "ignore" });
1455
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched PR fix in tmux" });
1456
- }
1457
- catch {
1458
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch PR fix" });
1459
- }
1495
+ dispatch({
1496
+ type: "SET_ACTION_MESSAGE",
1497
+ message: created.ok
1498
+ ? `Launched PR fix in ${mux.kind}`
1499
+ : `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
1500
+ });
1501
+ })();
1460
1502
  }
1461
1503
  else {
1462
1504
  leaveAltScreen();
@@ -1475,7 +1517,8 @@ export default function Dashboard() {
1475
1517
  return;
1476
1518
  }
1477
1519
  }, {
1478
- isActive: state.overlay !== "context-input" &&
1520
+ isActive: (state.overlay !== "context-input" || state.contextInputPhase === "review") &&
1521
+ (state.overlay !== "pr-create" || state.prCreatePhase !== "review") &&
1479
1522
  (state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
1480
1523
  });
1481
1524
  // ── Render ─────────────────────────────────────────────────────────
@@ -1487,13 +1530,10 @@ export default function Dashboard() {
1487
1530
  }
1488
1531
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1489
1532
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1490
- return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, 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: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
1491
- const mode = state.contextInputMode;
1492
- const ctx = state.contextInputValue;
1493
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1494
- if (mode)
1495
- doWork(mode, ctx);
1496
- }, 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" }), " ", "launch", " ", _jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " ", "newline", " ", _jsx(Text, { color: "cyan", 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) => {
1533
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1534
+ .split("\n")
1535
+ .slice(0, 12)
1536
+ .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] }))] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
1497
1537
  const selected = idx === state.baseSelectIndex;
1498
1538
  const defaultBranch = getDefaultBranch();
1499
1539
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
@@ -1501,5 +1541,5 @@ export default function Dashboard() {
1501
1541
  }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
1502
1542
  .split("\n")
1503
1543
  .slice(-(contentHeight - 1))
1504
- .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, scrollOffset: state.detailScrollOffset })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1544
+ .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1505
1545
  }
@@ -11,6 +11,7 @@ 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";
14
15
  const execAsync = promisify(exec);
15
16
  export const description = "Check system requirements and integrations";
16
17
  /**
@@ -55,6 +56,70 @@ async function checkTool(name, description, required, versionCommand, hint) {
55
56
  path,
56
57
  };
57
58
  }
59
+ /**
60
+ * Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
61
+ * binary is reachable. Surfaces a hint when the configured multiplexer can't run.
62
+ */
63
+ async function checkMultiplexer() {
64
+ const mux = getMultiplexer();
65
+ const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
66
+ const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
67
+ if (mux.kind === "none") {
68
+ return {
69
+ name: "multiplexer",
70
+ description,
71
+ required: false,
72
+ installed: false,
73
+ hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
74
+ };
75
+ }
76
+ if (mux.kind === "tmux") {
77
+ const path = await getPath("tmux");
78
+ if (!path) {
79
+ return {
80
+ name: "tmux",
81
+ description,
82
+ required: false,
83
+ installed: false,
84
+ hint: "Install: brew install tmux",
85
+ };
86
+ }
87
+ const version = await tryExec("tmux -V");
88
+ return {
89
+ name: "tmux",
90
+ description,
91
+ required: false,
92
+ installed: true,
93
+ version: version || "unknown",
94
+ path,
95
+ };
96
+ }
97
+ // cmux
98
+ const path = await getPath("cmux");
99
+ if (!path) {
100
+ return {
101
+ name: "cmux",
102
+ description,
103
+ required: false,
104
+ installed: false,
105
+ hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
106
+ };
107
+ }
108
+ const version = await tryExec("cmux --version 2>/dev/null");
109
+ const ping = await tryExec("cmux ping 2>/dev/null");
110
+ const hint = !ping
111
+ ? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux. NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472)."
112
+ : "NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472).";
113
+ return {
114
+ name: "cmux",
115
+ description,
116
+ required: false,
117
+ installed: !!ping,
118
+ version: version || "unknown",
119
+ path,
120
+ hint,
121
+ };
122
+ }
58
123
  /**
59
124
  * Checks GitHub CLI auth status.
60
125
  * Uses `gh api user` which works across all gh versions.
@@ -399,7 +464,7 @@ export default function Doctor() {
399
464
  const results = await Promise.all([
400
465
  checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
401
466
  checkGhAuth(),
402
- checkTool("tmux", "Terminal multiplexer", false, "tmux -V", "Install: brew install tmux"),
467
+ checkMultiplexer(),
403
468
  checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
404
469
  ]);
405
470
  // Check for either code or cursor (only need one)
@@ -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 {};