santree 0.7.4 → 0.7.6

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 (114) hide show
  1. package/README.md +2 -3
  2. package/dist/commands/config.d.ts +12 -0
  3. package/dist/commands/config.js +444 -0
  4. package/dist/commands/dashboard.js +187 -80
  5. package/dist/commands/helpers/statusline.js +0 -30
  6. package/dist/commands/issue/index.d.ts +1 -1
  7. package/dist/commands/issue/index.js +1 -1
  8. package/dist/commands/pr/context.d.ts +7 -0
  9. package/dist/commands/pr/context.js +36 -0
  10. package/dist/commands/pr/fix.d.ts +11 -2
  11. package/dist/commands/pr/fix.js +64 -12
  12. package/dist/commands/worktree/create.js +17 -11
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/commands/worktree/switch.js +3 -11
  15. package/dist/lib/ai.d.ts +11 -0
  16. package/dist/lib/ai.js +71 -2
  17. package/dist/lib/cd-hint.d.ts +18 -0
  18. package/dist/lib/cd-hint.js +27 -0
  19. package/dist/lib/claude-config.d.ts +26 -0
  20. package/dist/lib/claude-config.js +121 -0
  21. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  22. package/dist/lib/config/TrackerPicker.js +113 -0
  23. package/dist/lib/config/diagnostics.d.ts +37 -0
  24. package/dist/lib/config/diagnostics.js +190 -0
  25. package/dist/lib/config-store.d.ts +23 -0
  26. package/dist/lib/config-store.js +68 -0
  27. package/dist/lib/dashboard/DetailPanel.d.ts +7 -2
  28. package/dist/lib/dashboard/DetailPanel.js +54 -37
  29. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  30. package/dist/lib/dashboard/IssueList.js +24 -5
  31. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  32. package/dist/lib/dashboard/Overlays.js +3 -4
  33. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  34. package/dist/lib/dashboard/ReviewList.js +6 -3
  35. package/dist/lib/dashboard/data.js +7 -34
  36. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  37. package/dist/lib/dashboard/external-editor.js +3 -2
  38. package/dist/lib/dashboard/types.d.ts +19 -2
  39. package/dist/lib/dashboard/types.js +68 -2
  40. package/dist/lib/exec.d.ts +13 -0
  41. package/dist/lib/exec.js +11 -1
  42. package/dist/lib/fix-loop.d.ts +45 -0
  43. package/dist/lib/fix-loop.js +83 -0
  44. package/dist/lib/git.d.ts +3 -29
  45. package/dist/lib/git.js +6 -62
  46. package/dist/lib/github.d.ts +70 -0
  47. package/dist/lib/github.js +128 -0
  48. package/dist/lib/multiplexer/cmux.js +143 -17
  49. package/dist/lib/multiplexer/none.js +3 -3
  50. package/dist/lib/multiplexer/tmux.js +5 -29
  51. package/dist/lib/multiplexer/types.d.ts +34 -1
  52. package/dist/lib/open-url.d.ts +3 -2
  53. package/dist/lib/open-url.js +5 -4
  54. package/dist/lib/prompts.d.ts +35 -1
  55. package/dist/lib/prompts.js +7 -0
  56. package/dist/lib/setup/apply.d.ts +16 -0
  57. package/dist/lib/setup/apply.js +35 -0
  58. package/dist/lib/setup/gitignore.d.ts +24 -0
  59. package/dist/lib/setup/gitignore.js +95 -0
  60. package/dist/lib/setup/steps.d.ts +39 -0
  61. package/dist/lib/setup/steps.js +333 -0
  62. package/dist/lib/setup/tools.d.ts +23 -0
  63. package/dist/lib/setup/tools.js +47 -0
  64. package/dist/lib/squirrel-loader.d.ts +3 -1
  65. package/dist/lib/squirrel-loader.js +2 -2
  66. package/dist/lib/trackers/github/index.js +2 -2
  67. package/dist/lib/trackers/local/store.js +1 -1
  68. package/package.json +1 -2
  69. package/prompts/fix-context.njk +79 -0
  70. package/prompts/fix-loop.njk +18 -0
  71. package/prompts/pr.njk +1 -1
  72. package/dist/commands/doctor.d.ts +0 -2
  73. package/dist/commands/doctor.js +0 -618
  74. package/dist/commands/github/auth.d.ts +0 -2
  75. package/dist/commands/github/auth.js +0 -56
  76. package/dist/commands/github/index.d.ts +0 -1
  77. package/dist/commands/github/index.js +0 -1
  78. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  79. package/dist/commands/helpers/english-tutor/index.js +0 -1
  80. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  81. package/dist/commands/helpers/english-tutor/install.js +0 -24
  82. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  83. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  84. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  85. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  86. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  87. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  88. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  89. package/dist/commands/helpers/session-signal/end.js +0 -13
  90. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  91. package/dist/commands/helpers/session-signal/index.js +0 -1
  92. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  93. package/dist/commands/helpers/session-signal/install.js +0 -24
  94. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  95. package/dist/commands/helpers/session-signal/notification.js +0 -13
  96. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  97. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  98. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  99. package/dist/commands/helpers/session-signal/stop.js +0 -13
  100. package/dist/commands/helpers/shell-init.d.ts +0 -11
  101. package/dist/commands/helpers/shell-init.js +0 -122
  102. package/dist/commands/issue/setup.d.ts +0 -2
  103. package/dist/commands/issue/setup.js +0 -108
  104. package/dist/commands/issue/switch.d.ts +0 -12
  105. package/dist/commands/issue/switch.js +0 -38
  106. package/dist/commands/linear/switch.d.ts +0 -2
  107. package/dist/commands/linear/switch.js +0 -75
  108. package/dist/lib/english-tutor.d.ts +0 -13
  109. package/dist/lib/english-tutor.js +0 -125
  110. package/dist/lib/session-signal.d.ts +0 -16
  111. package/dist/lib/session-signal.js +0 -104
  112. package/prompts/english-tutor-prompt.njk +0 -15
  113. package/shell/init.bash.njk +0 -129
  114. package/shell/init.zsh.njk +0 -204
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Text, Box, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { setRepoTracker, getIssueTracker } from "../trackers/index.js";
6
+ import { setRepoLinearOrg, startOAuthFlow } from "../trackers/linear/index.js";
7
+ import { readLinearAuthStore } from "../trackers/auth-store.js";
8
+ import { getAuthenticatedUser, getCurrentRepoNwo } from "../trackers/github/auth.js";
9
+ const AUTH_NEW = "__auth_new__";
10
+ const TRACKERS = [
11
+ { kind: "local", label: "Local", hint: "built-in, file-based — no account needed" },
12
+ { kind: "linear", label: "Linear", hint: "OAuth workspace" },
13
+ { kind: "github", label: "GitHub", hint: "GitHub Issues via gh CLI" },
14
+ ];
15
+ export default function TrackerPicker({ repoRoot, activeKind, activeOrg, onApplied, onCancel, }) {
16
+ const [phase, setPhase] = useState("pick-tracker");
17
+ const [error, setError] = useState(null);
18
+ const startIdx = Math.max(0, TRACKERS.findIndex((t) => t.kind === activeKind));
19
+ const [trackerIdx, setTrackerIdx] = useState(startIdx);
20
+ const [orgs, setOrgs] = useState([]);
21
+ const [orgIdx, setOrgIdx] = useState(0);
22
+ function authenticateNewLinear() {
23
+ setPhase("authenticating");
24
+ startOAuthFlow().then((result) => {
25
+ if (!result) {
26
+ setError("Authentication failed or timed out. Please try again.");
27
+ setPhase("error");
28
+ return;
29
+ }
30
+ setRepoLinearOrg(repoRoot, result.orgSlug);
31
+ setRepoTracker(repoRoot, "linear");
32
+ onApplied(`Authenticated as ${result.orgName} (${result.orgSlug})`);
33
+ });
34
+ }
35
+ async function chooseTracker(kind) {
36
+ if (kind === "local") {
37
+ setRepoTracker(repoRoot, "local");
38
+ onApplied(`Active tracker: ${getIssueTracker(repoRoot).displayName}`);
39
+ return;
40
+ }
41
+ if (kind === "linear") {
42
+ const store = readLinearAuthStore();
43
+ const list = Object.entries(store).map(([slug, tokens]) => ({
44
+ slug,
45
+ name: tokens.org_name,
46
+ }));
47
+ if (list.length === 0) {
48
+ authenticateNewLinear();
49
+ return;
50
+ }
51
+ setOrgs(list);
52
+ const cur = list.findIndex((o) => o.slug === activeOrg);
53
+ setOrgIdx(cur >= 0 ? cur : 0);
54
+ setPhase("pick-org");
55
+ return;
56
+ }
57
+ // github
58
+ setPhase("checking");
59
+ const user = await getAuthenticatedUser();
60
+ if (!user) {
61
+ setError("GitHub CLI not authenticated. Run: gh auth login");
62
+ setPhase("error");
63
+ return;
64
+ }
65
+ setRepoTracker(repoRoot, "github");
66
+ const nwo = await getCurrentRepoNwo(repoRoot);
67
+ onApplied(`Active tracker: GitHub (@${user.login}${nwo ? ` · ${nwo}` : ""})`);
68
+ }
69
+ useInput((_input, key) => {
70
+ if (phase === "error") {
71
+ // Any key returns to the tracker menu.
72
+ setError(null);
73
+ setPhase("pick-tracker");
74
+ return;
75
+ }
76
+ if (phase === "pick-tracker") {
77
+ if (key.escape || key.leftArrow)
78
+ onCancel();
79
+ else if (key.upArrow)
80
+ setTrackerIdx((i) => Math.max(0, i - 1));
81
+ else if (key.downArrow)
82
+ setTrackerIdx((i) => Math.min(TRACKERS.length - 1, i + 1));
83
+ else if (key.return)
84
+ void chooseTracker(TRACKERS[trackerIdx].kind);
85
+ }
86
+ else if (phase === "pick-org") {
87
+ const max = orgs.length; // index === orgs.length selects AUTH_NEW
88
+ if (key.leftArrow || key.escape)
89
+ setPhase("pick-tracker");
90
+ else if (key.upArrow)
91
+ setOrgIdx((i) => Math.max(0, i - 1));
92
+ else if (key.downArrow)
93
+ setOrgIdx((i) => Math.min(max, i + 1));
94
+ else if (key.return) {
95
+ if (orgIdx === orgs.length) {
96
+ authenticateNewLinear();
97
+ return;
98
+ }
99
+ const org = orgs[orgIdx];
100
+ setRepoLinearOrg(repoRoot, org.slug);
101
+ setRepoTracker(repoRoot, "linear");
102
+ onApplied(`Linked to ${org.name} (${org.slug})`);
103
+ }
104
+ }
105
+ });
106
+ return (_jsxs(Box, { flexDirection: "column", children: [phase === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking\u2026" })] })), phase === "authenticating" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Authenticating with Linear \u2014 follow the browser prompt\u2026" })] })), phase === "pick-tracker" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select the issue tracker for this repo:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: TRACKERS.map((t, i) => {
107
+ const active = t.kind === activeKind;
108
+ return (_jsxs(Text, { children: [i === trackerIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), t.label, active ? _jsx(Text, { color: "green", children: " \u25CF in use" }) : null, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", t.hint] })] }, t.kind));
109
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 Enter confirm \u00B7 \u2190 / Esc back" }) })] })), phase === "pick-org" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a Linear workspace to link:" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [orgs.map((org, i) => {
110
+ const active = org.slug === activeOrg && activeKind === "linear";
111
+ return (_jsxs(Text, { children: [i === orgIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), org.name, " (", org.slug, ")", active ? _jsx(Text, { color: "green", children: " \u25CF in use" }) : null] }, org.slug));
112
+ }), _jsxs(Text, { children: [orgIdx === orgs.length ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), _jsx(Text, { dimColor: true, children: "+ Authenticate a new workspace\u2026" })] }, AUTH_NEW)] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 \u2190 back \u00B7 Enter confirm" }) })] })), phase === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsx(Text, { dimColor: true, children: "Press any key to go back." })] }))] }));
113
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read-only diagnostics for `santree config`. These are the facts santree can
3
+ * *report* but not change from the panel (tool versions, update availability,
4
+ * auth state). The panel renders them as non-selectable info rows; `--check`
5
+ * prints them flat. Configurable settings live in `lib/setup/steps.ts` instead.
6
+ */
7
+ /** A read-only status row — santree reports it, you can't toggle it here. */
8
+ export interface InfoRow {
9
+ id: string;
10
+ title: string;
11
+ description: string;
12
+ scope: "system" | "global" | "repo";
13
+ required: boolean;
14
+ ok: boolean;
15
+ /** Dim sub-lines (version, path, account, …). */
16
+ lines: string[];
17
+ /** Yellow call-to-action when not ok. */
18
+ hint?: string;
19
+ }
20
+ /**
21
+ * Extra detail attached to a configurable step row by id (version / auth / a
22
+ * yellow update hint). Lets an already-configured step render doctor-style
23
+ * without the step catalog itself doing network I/O.
24
+ */
25
+ export interface StepDetail {
26
+ lines: string[];
27
+ hint?: string;
28
+ }
29
+ export interface DiagnosticsData {
30
+ infoRows: InfoRow[];
31
+ /** Keyed by SetupStep id (gh, claude, tracker). */
32
+ stepDetail: Map<string, StepDetail>;
33
+ /** True when an active multiplexer was detected (tmux/cmux). */
34
+ muxActive: boolean;
35
+ }
36
+ /** Run every read-only check. Network calls (npm registry, gh, tracker) run in parallel. */
37
+ export declare function loadDiagnostics(): Promise<DiagnosticsData>;
@@ -0,0 +1,190 @@
1
+ import { run } from "../exec.js";
2
+ import { which } from "../setup/tools.js";
3
+ import { getMultiplexer } from "../multiplexer/index.js";
4
+ import { resolveClaudeBinary } from "../ai.js";
5
+ import { findMainRepoRoot } from "../git.js";
6
+ import { getIssueTracker } from "../trackers/index.js";
7
+ import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, SANTREE_PACKAGE, getLatestVersionFor, isUpdateAvailable, detectPackageManager, getInstallCommandFor, } from "../version.js";
8
+ function tryRun(cmd) {
9
+ return run(`${cmd} 2>/dev/null`);
10
+ }
11
+ function checkGit() {
12
+ const p = which("git");
13
+ if (!p) {
14
+ return {
15
+ id: "git",
16
+ title: "git",
17
+ description: "Version control",
18
+ scope: "system",
19
+ required: true,
20
+ ok: false,
21
+ lines: [],
22
+ hint: "Install: brew install git",
23
+ };
24
+ }
25
+ const version = tryRun("git --version | head -1");
26
+ return {
27
+ id: "git",
28
+ title: "git",
29
+ description: "Version control",
30
+ scope: "system",
31
+ required: true,
32
+ ok: true,
33
+ lines: [`Version: ${version || "unknown"}`, `Path: ${p}`],
34
+ };
35
+ }
36
+ function checkNode() {
37
+ const version = process.versions.node;
38
+ const major = Number(version.split(".")[0] || "0");
39
+ const ok = major >= 20;
40
+ return {
41
+ id: "node",
42
+ title: "node",
43
+ description: "JavaScript runtime (>= 20 required)",
44
+ scope: "system",
45
+ required: true,
46
+ ok,
47
+ lines: [`Version: v${version}`, `Path: ${process.execPath}`],
48
+ hint: ok ? undefined : "Santree needs Node.js >= 20. Upgrade: https://nodejs.org",
49
+ };
50
+ }
51
+ async function checkSantreeSelf() {
52
+ const latest = await getLatestVersionFor(SANTREE_PACKAGE);
53
+ const updatable = !!latest && isUpdateAvailable(CURRENT_VERSION, latest);
54
+ const lines = [`Version: ${CURRENT_VERSION}`];
55
+ if (latest)
56
+ lines.push(`Latest: ${latest}${updatable ? " ⬆ update available" : ""}`);
57
+ return {
58
+ id: "santree",
59
+ title: "santree",
60
+ description: "Santree CLI (this app)",
61
+ scope: "system",
62
+ required: true,
63
+ ok: true,
64
+ lines,
65
+ hint: updatable ? "Run: santree update" : undefined,
66
+ };
67
+ }
68
+ function checkMultiplexer() {
69
+ const mux = getMultiplexer();
70
+ if (mux.kind === "tmux") {
71
+ const version = tryRun("tmux -V");
72
+ return {
73
+ id: "multiplexer",
74
+ title: "tmux",
75
+ description: "Active multiplexer — new-window flows enabled",
76
+ scope: "system",
77
+ required: false,
78
+ ok: true,
79
+ lines: [`Version: ${version || "unknown"}`, `Path: ${which("tmux") || "unknown"}`],
80
+ };
81
+ }
82
+ // cmux
83
+ const version = tryRun("cmux --version");
84
+ const ping = tryRun("cmux ping");
85
+ return {
86
+ id: "multiplexer",
87
+ title: "cmux",
88
+ description: "Active multiplexer — new-window flows enabled",
89
+ scope: "system",
90
+ required: false,
91
+ ok: !!ping,
92
+ lines: [`Version: ${version || "unknown"}`, `Path: ${which("cmux") || "unknown"}`],
93
+ hint: ping ? undefined : "cmux app not reachable — open cmux.app.",
94
+ };
95
+ }
96
+ function checkWorkspaceEditor() {
97
+ const code = which("code");
98
+ const cursor = which("cursor");
99
+ const found = code ? "code" : cursor ? "cursor" : null;
100
+ if (found) {
101
+ const version = tryRun(`${found} --version | head -1`);
102
+ return {
103
+ id: "workspace-editor",
104
+ title: "Workspace editor",
105
+ description: "code/cursor — powers the dashboard's `E workspace` shortcut",
106
+ scope: "system",
107
+ required: false,
108
+ ok: true,
109
+ lines: [`Editor: ${found}`, `Version: ${version || "unknown"}`],
110
+ };
111
+ }
112
+ return {
113
+ id: "workspace-editor",
114
+ title: "Workspace editor",
115
+ description: "code/cursor — powers the dashboard's `E workspace` shortcut",
116
+ scope: "system",
117
+ required: false,
118
+ ok: false,
119
+ lines: [],
120
+ hint: "Optional — only the `.code-workspace` shortcut needs code/cursor. Everything else uses your configured editor.",
121
+ };
122
+ }
123
+ /** Version + auth detail for the gh and claude step rows. */
124
+ async function loadStepDetail() {
125
+ const detail = new Map();
126
+ const pm = detectPackageManager();
127
+ // claude — version + latest + cmux-aware update hint
128
+ const resolved = resolveClaudeBinary();
129
+ if (resolved) {
130
+ const version = tryRun(`"${resolved}" --version | head -1`);
131
+ const latest = await getLatestVersionFor(CLAUDE_CODE_PACKAGE);
132
+ const lines = [`Version: ${version || "unknown"}`, `Path: ${resolved}`];
133
+ let hint;
134
+ if (latest && version && isUpdateAvailable(version, latest)) {
135
+ lines.push(`Latest: ${latest} ⬆ update available`);
136
+ if (resolved.includes("/cmux.app/")) {
137
+ hint = "Bundled with cmux — update cmux.app to get the latest Claude.";
138
+ }
139
+ else {
140
+ hint = `Run: ${getInstallCommandFor(pm, `${CLAUDE_CODE_PACKAGE}@latest`).display}`;
141
+ }
142
+ }
143
+ detail.set("claude", { lines, hint });
144
+ }
145
+ // gh — version + authenticated login
146
+ if (which("gh")) {
147
+ const version = tryRun("gh --version | head -1");
148
+ const login = tryRun("gh api user --jq .login");
149
+ const lines = [`Version: ${version || "unknown"}`];
150
+ if (login)
151
+ lines.push(`Auth: authenticated as ${login}`);
152
+ detail.set("gh", { lines, hint: login ? undefined : "Run: gh auth login" });
153
+ }
154
+ return detail;
155
+ }
156
+ /** Active issue tracker auth detail for the tracker row. */
157
+ async function loadTrackerDetail() {
158
+ const repoRoot = findMainRepoRoot();
159
+ if (!repoRoot)
160
+ return null;
161
+ try {
162
+ const tracker = getIssueTracker(repoRoot);
163
+ const status = await tracker.getAuthStatus(repoRoot);
164
+ const lines = [`Tracker: ${tracker.displayName}`];
165
+ if (status.accountLabel)
166
+ lines.push(`Account: ${status.accountLabel}`);
167
+ if (status.repoLinked !== undefined)
168
+ lines.push(`Repo linked: ${status.repoLinked ? "yes" : "no"}`);
169
+ return { lines, hint: status.authenticated ? undefined : status.hint };
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ /** Run every read-only check. Network calls (npm registry, gh, tracker) run in parallel. */
176
+ export async function loadDiagnostics() {
177
+ const muxActive = getMultiplexer().kind !== "none";
178
+ const [santree, stepDetail, trackerDetail] = await Promise.all([
179
+ checkSantreeSelf(),
180
+ loadStepDetail(),
181
+ loadTrackerDetail(),
182
+ ]);
183
+ if (trackerDetail)
184
+ stepDetail.set("tracker", trackerDetail);
185
+ const infoRows = [santree, checkNode(), checkGit()];
186
+ if (muxActive)
187
+ infoRows.push(checkMultiplexer());
188
+ infoRows.push(checkWorkspaceEditor());
189
+ return { infoRows, stepDetail, muxActive };
190
+ }
@@ -0,0 +1,23 @@
1
+ export interface SantreeConfig {
2
+ /** Editor launched by the dashboard's [e] action and Ctrl+O in text areas. */
3
+ editor?: string;
4
+ /** Unified-diff pager for `worktree diff` and the dashboard [v] overlay. */
5
+ diffTool?: string;
6
+ }
7
+ export declare function configStorePath(): string;
8
+ export declare function readConfigStore(): SantreeConfig;
9
+ export declare function setConfigValue<K extends keyof SantreeConfig>(key: K, value: SantreeConfig[K]): void;
10
+ export declare function unsetConfigValue(key: keyof SantreeConfig): void;
11
+ /**
12
+ * Resolved editor command: `SANTREE_EDITOR` env override → config file → none.
13
+ * Call sites keep their own final fallback (e.g. `"code"` for GUI-open flows,
14
+ * the `VISUAL`/`EDITOR`/`vim` chain for in-terminal editing).
15
+ */
16
+ export declare function getConfiguredEditor(): string | undefined;
17
+ /**
18
+ * Resolved diff pager: `SANTREE_DIFF_TOOL` env override → config file → none.
19
+ * Restricted to a safe shell-token character set since it ends up in spawn()
20
+ * arguments — even though we never use shell:true, a tight surface defends
21
+ * against accidental misconfigurations.
22
+ */
23
+ export declare function getConfiguredDiffTool(): string | undefined;
@@ -0,0 +1,68 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ /**
5
+ * Santree's global config file — `$XDG_CONFIG_HOME/santree/config.json`
6
+ * (defaults to `~/.config/santree/config.json`), sitting next to `auth.json`.
7
+ *
8
+ * This is where preferences santree owns live (editor, diff tool) instead of
9
+ * exporting them into the user's shell rc. Writing here takes effect on the
10
+ * next santree run with no shell restart. The matching `SANTREE_EDITOR` /
11
+ * `SANTREE_DIFF_TOOL` env vars still work as a one-off override that wins over
12
+ * the file, so existing shell exports and CI overrides keep behaving.
13
+ */
14
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
15
+ const CONFIG_PATH = path.join(CONFIG_DIR, "santree", "config.json");
16
+ export function configStorePath() {
17
+ return CONFIG_PATH;
18
+ }
19
+ export function readConfigStore() {
20
+ try {
21
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
22
+ return parsed && typeof parsed === "object" ? parsed : {};
23
+ }
24
+ catch {
25
+ return {};
26
+ }
27
+ }
28
+ function writeConfigStore(cfg) {
29
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
30
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
31
+ }
32
+ export function setConfigValue(key, value) {
33
+ const cfg = readConfigStore();
34
+ cfg[key] = value;
35
+ writeConfigStore(cfg);
36
+ }
37
+ export function unsetConfigValue(key) {
38
+ const cfg = readConfigStore();
39
+ if (!(key in cfg))
40
+ return;
41
+ delete cfg[key];
42
+ writeConfigStore(cfg);
43
+ }
44
+ /**
45
+ * Resolved editor command: `SANTREE_EDITOR` env override → config file → none.
46
+ * Call sites keep their own final fallback (e.g. `"code"` for GUI-open flows,
47
+ * the `VISUAL`/`EDITOR`/`vim` chain for in-terminal editing).
48
+ */
49
+ export function getConfiguredEditor() {
50
+ const env = process.env.SANTREE_EDITOR;
51
+ if (env && env.trim())
52
+ return env.trim();
53
+ const stored = readConfigStore().editor;
54
+ return stored && stored.trim() ? stored.trim() : undefined;
55
+ }
56
+ /**
57
+ * Resolved diff pager: `SANTREE_DIFF_TOOL` env override → config file → none.
58
+ * Restricted to a safe shell-token character set since it ends up in spawn()
59
+ * arguments — even though we never use shell:true, a tight surface defends
60
+ * against accidental misconfigurations.
61
+ */
62
+ export function getConfiguredDiffTool() {
63
+ const raw = process.env.SANTREE_DIFF_TOOL ?? readConfigStore().diffTool;
64
+ if (!raw || !raw.trim())
65
+ return undefined;
66
+ const tool = raw.trim();
67
+ return /^[a-zA-Z0-9_\-/.+]+$/.test(tool) ? tool : undefined;
68
+ }
@@ -1,4 +1,4 @@
1
- import type { Comment, DashboardIssue, DashboardTab, DeleteStatus } from "./types.js";
1
+ import type { Comment, DashboardIssue, DashboardTab, DeleteStatus, FixLoopRuntime } from "./types.js";
2
2
  interface Props {
3
3
  issue: DashboardIssue | null;
4
4
  scrollOffset: number;
@@ -8,6 +8,11 @@ interface Props {
8
8
  creationLogs: string;
9
9
  /** Deletion progress for the selected worktree, when one is being removed. */
10
10
  deleteStatus?: DeleteStatus;
11
+ /** True while a just-created PR is shown optimistically, before a refresh
12
+ * confirms it (renders a "syncing…" hint next to the PR). */
13
+ prSyncing?: boolean;
14
+ /** Active/recent auto-fix loop for the selected ticket, shown as a ⟳ line. */
15
+ fixLoop?: FixLoopRuntime;
11
16
  /** Triage mode: hide worktree/PR/checks sections (they never apply to an
12
17
  * inbox issue) and show the discussion instead. */
13
18
  triage?: boolean;
@@ -39,5 +44,5 @@ export declare function buildIssueActions(di: DashboardIssue, trackerName: strin
39
44
  canMutate?: boolean;
40
45
  triageInvestigateConfigured?: boolean;
41
46
  }): IssueActionItem[];
42
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
47
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing, fixLoop, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
43
48
  export {};
@@ -134,7 +134,7 @@ export function buildIssueActions(di, trackerName, opts) {
134
134
  items.push({ key: "c", label: "Create PR", color: "cyan" });
135
135
  }
136
136
  if (pr) {
137
- items.push({ key: "f", label: "Fix PR", color: "cyan" });
137
+ items.push({ key: "f", label: "Fix loop", color: "cyan" });
138
138
  items.push({ key: "r", label: "Review", color: "cyan" });
139
139
  }
140
140
  if (issue.url) {
@@ -158,7 +158,7 @@ function sectionHeader(icon, label, iconColor = "cyan") {
158
158
  ],
159
159
  };
160
160
  }
161
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage = false, comments, onCall, }) {
161
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing = false, fixLoop, triage = false, comments, onCall, }) {
162
162
  // Show deletion progress when the selected worktree is being removed.
163
163
  if (issue && deleteStatus) {
164
164
  const logLines = deleteStatus.logs.split("\n");
@@ -417,39 +417,8 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
417
417
  lines.push({ text: ` +${gs.files.length - maxFiles} more`, dim: true });
418
418
  }
419
419
  }
420
- // Session state single line, color reflects state.
421
- if (worktree.sessionState === "waiting") {
422
- const msg = worktree.sessionMessage
423
- ? `NEEDS INPUT: ${worktree.sessionMessage}`
424
- : "NEEDS INPUT";
425
- lines.push({
426
- text: "",
427
- segments: [
428
- { text: " ◆ ", color: "red" },
429
- { text: msg, color: "red", bold: true },
430
- ],
431
- });
432
- }
433
- else if (worktree.sessionState === "active") {
434
- lines.push({
435
- text: "",
436
- segments: [
437
- { text: " ◆ ", color: "green" },
438
- { text: "session active", color: "green" },
439
- ],
440
- });
441
- }
442
- else if (worktree.sessionState === "idle") {
443
- lines.push({
444
- text: "",
445
- segments: [
446
- { text: " ◆ ", color: "yellow" },
447
- { text: "session idle", color: "yellow" },
448
- { text: " (waiting for prompt)", dim: true },
449
- ],
450
- });
451
- }
452
- else if (worktree.sessionId) {
420
+ // Resumable Claude session (the stored session id), or none.
421
+ if (worktree.sessionId) {
453
422
  lines.push({
454
423
  text: "",
455
424
  segments: [
@@ -557,28 +526,71 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
557
526
  { text: " " },
558
527
  { text: pr.state, color: prColor },
559
528
  { text: draft, dim: true },
529
+ ...(prSyncing ? [{ text: " · syncing…", color: "yellow" }] : []),
560
530
  ],
561
531
  });
562
532
  if (pr.url) {
563
533
  lines.push({ text: ` ${pr.url}`, dim: true });
564
534
  }
535
+ if (prSyncing) {
536
+ // Just created from the dashboard — number/url are known, but checks
537
+ // and reviews aren't fetched yet. Show a clear loading line for this
538
+ // one PR (other PRs keep their already-loaded data).
539
+ lines.push({
540
+ text: "",
541
+ segments: [{ text: " ⟳ loading checks & reviews…", color: "yellow" }],
542
+ });
543
+ }
565
544
  }
566
545
  else if (!isMain) {
567
546
  lines.push(sectionHeader("◉", "Pull Request"));
568
547
  lines.push({ text: " no PR yet", dim: true });
569
548
  }
549
+ // ── Fix loop ──────────────────────────────────────────────────────
550
+ if (!isMain && fixLoop) {
551
+ const { phase } = fixLoop;
552
+ const color = phase === "running"
553
+ ? "cyan"
554
+ : phase === "stalled"
555
+ ? "yellow"
556
+ : phase === "stopped-clean"
557
+ ? "green"
558
+ : "red";
559
+ const label = phase === "running"
560
+ ? `fix loop · ${fixLoop.status}`
561
+ : phase === "stalled"
562
+ ? "fix loop · stalled (no recent activity)"
563
+ : phase === "stopped-clean"
564
+ ? "fix loop · stopped — all clear"
565
+ : "fix loop · stopped — needs a human";
566
+ const ageMin = Math.max(0, Math.round(fixLoop.ageMs / 60000));
567
+ lines.push(ruleLine);
568
+ lines.push({
569
+ text: "",
570
+ segments: [
571
+ { text: "⟳ ", color, bold: true },
572
+ { text: label, color },
573
+ { text: ` updated ${ageMin}m ago`, dim: true },
574
+ ],
575
+ });
576
+ }
570
577
  // ── Checks ────────────────────────────────────────────────────────
571
578
  if (!isMain && checks && checks.length > 0) {
579
+ // `gh` buckets: pass | fail | pending | skipping | cancel. Skipped/cancelled
580
+ // checks don't run under the current conditions and don't block the PR —
581
+ // GitHub counts them as "skipped" while still reporting "all checks passed".
582
+ // Treat them as a non-blocking aside, never as pending.
572
583
  const passing = checks.filter((c) => c.bucket === "pass");
573
584
  const failing = checks.filter((c) => c.bucket === "fail");
574
- const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
585
+ const pending = checks.filter((c) => c.bucket === "pending");
586
+ const skipped = checks.filter((c) => c.bucket === "skipping" || c.bucket === "cancel");
575
587
  const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
576
588
  lines.push(ruleLine);
577
589
  const headerSegs = [
578
590
  { text: "✓ ", color: "cyan", bold: true },
579
591
  { text: "Checks", bold: true },
580
592
  { text: " " },
581
- { text: `${passing.length}/${checks.length} passing`, color: headerColor },
593
+ { text: `${passing.length} passed`, color: headerColor },
582
594
  ];
583
595
  if (failing.length > 0) {
584
596
  headerSegs.push({ text: " · ", dim: true });
@@ -588,8 +600,13 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
588
600
  headerSegs.push({ text: " · ", dim: true });
589
601
  headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
590
602
  }
603
+ if (skipped.length > 0) {
604
+ headerSegs.push({ text: " · ", dim: true });
605
+ headerSegs.push({ text: `${skipped.length} skipped`, dim: true });
606
+ }
591
607
  lines.push({ text: "", segments: headerSegs });
592
608
  // Order: failing first (most important), then pending, then passing.
609
+ // Skipped checks aren't listed individually — just summarized above.
593
610
  for (const check of failing) {
594
611
  const desc = check.description ? ` — ${check.description}` : "";
595
612
  lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
@@ -1,3 +1,4 @@
1
+ import type { FixLoopRuntime } from "../fix-loop.js";
1
2
  import type { ProjectGroup, DashboardIssue } from "./types.js";
2
3
  interface Props {
3
4
  groups: ProjectGroup[];
@@ -16,6 +17,9 @@ interface Props {
16
17
  /** Ticket ids whose worktree is currently being removed — shown with a
17
18
  * distinct WT-column glyph so concurrent deletions are visible in the list. */
18
19
  deletingIds?: Set<string>;
20
+ /** Active/recent auto-fix loops by ticket id — a running/stalled loop shows a
21
+ * ⟳ in place of the CI glyph (the loop is the CI story while it runs). */
22
+ fixLoops?: Record<string, FixLoopRuntime>;
19
23
  }
20
24
  export type ListRow = {
21
25
  kind: "spacer";
@@ -36,5 +40,5 @@ export type ListRow = {
36
40
  depth: number;
37
41
  };
38
42
  export declare function buildIssueListRows(groups: ProjectGroup[], flatIssues: DashboardIssue[]): ListRow[];
39
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, variant, deletingIds, }: Props): import("react/jsx-runtime").JSX.Element;
43
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, variant, deletingIds, fixLoops, }: Props): import("react/jsx-runtime").JSX.Element;
40
44
  export {};