santree 0.7.5 → 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 (109) 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 +165 -61
  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 +6 -1
  13. package/dist/commands/worktree/open.js +3 -2
  14. package/dist/lib/ai.d.ts +11 -0
  15. package/dist/lib/ai.js +71 -2
  16. package/dist/lib/claude-config.d.ts +18 -4
  17. package/dist/lib/claude-config.js +58 -23
  18. package/dist/lib/config/TrackerPicker.d.ts +10 -0
  19. package/dist/lib/config/TrackerPicker.js +113 -0
  20. package/dist/lib/config/diagnostics.d.ts +37 -0
  21. package/dist/lib/config/diagnostics.js +190 -0
  22. package/dist/lib/config-store.d.ts +23 -0
  23. package/dist/lib/config-store.js +68 -0
  24. package/dist/lib/dashboard/DetailPanel.d.ts +4 -2
  25. package/dist/lib/dashboard/DetailPanel.js +44 -37
  26. package/dist/lib/dashboard/IssueList.d.ts +5 -1
  27. package/dist/lib/dashboard/IssueList.js +24 -5
  28. package/dist/lib/dashboard/MultilineTextArea.js +1 -1
  29. package/dist/lib/dashboard/Overlays.js +3 -4
  30. package/dist/lib/dashboard/ReviewDetailPanel.js +11 -2
  31. package/dist/lib/dashboard/ReviewList.js +6 -3
  32. package/dist/lib/dashboard/data.js +7 -34
  33. package/dist/lib/dashboard/external-editor.d.ts +1 -1
  34. package/dist/lib/dashboard/external-editor.js +3 -2
  35. package/dist/lib/dashboard/types.d.ts +13 -2
  36. package/dist/lib/dashboard/types.js +33 -2
  37. package/dist/lib/exec.d.ts +13 -0
  38. package/dist/lib/exec.js +11 -1
  39. package/dist/lib/fix-loop.d.ts +45 -0
  40. package/dist/lib/fix-loop.js +83 -0
  41. package/dist/lib/git.d.ts +3 -29
  42. package/dist/lib/git.js +6 -62
  43. package/dist/lib/github.d.ts +70 -0
  44. package/dist/lib/github.js +128 -0
  45. package/dist/lib/multiplexer/cmux.js +143 -17
  46. package/dist/lib/multiplexer/none.js +3 -3
  47. package/dist/lib/multiplexer/tmux.js +5 -29
  48. package/dist/lib/multiplexer/types.d.ts +34 -1
  49. package/dist/lib/open-url.d.ts +3 -2
  50. package/dist/lib/open-url.js +5 -4
  51. package/dist/lib/prompts.d.ts +35 -1
  52. package/dist/lib/prompts.js +7 -0
  53. package/dist/lib/setup/apply.d.ts +2 -2
  54. package/dist/lib/setup/apply.js +2 -2
  55. package/dist/lib/setup/gitignore.d.ts +7 -0
  56. package/dist/lib/setup/gitignore.js +39 -1
  57. package/dist/lib/setup/steps.d.ts +6 -2
  58. package/dist/lib/setup/steps.js +65 -60
  59. package/dist/lib/setup/tools.d.ts +1 -1
  60. package/dist/lib/setup/tools.js +1 -1
  61. package/dist/lib/trackers/github/index.js +2 -2
  62. package/dist/lib/trackers/local/store.js +1 -1
  63. package/package.json +1 -1
  64. package/prompts/fix-context.njk +79 -0
  65. package/prompts/fix-loop.njk +18 -0
  66. package/prompts/pr.njk +1 -1
  67. package/dist/commands/doctor.d.ts +0 -2
  68. package/dist/commands/doctor.js +0 -545
  69. package/dist/commands/github/auth.d.ts +0 -2
  70. package/dist/commands/github/auth.js +0 -56
  71. package/dist/commands/github/index.d.ts +0 -1
  72. package/dist/commands/github/index.js +0 -1
  73. package/dist/commands/helpers/english-tutor/index.d.ts +0 -1
  74. package/dist/commands/helpers/english-tutor/index.js +0 -1
  75. package/dist/commands/helpers/english-tutor/install.d.ts +0 -8
  76. package/dist/commands/helpers/english-tutor/install.js +0 -24
  77. package/dist/commands/helpers/english-tutor/prompt.d.ts +0 -2
  78. package/dist/commands/helpers/english-tutor/prompt.js +0 -16
  79. package/dist/commands/helpers/english-tutor/session-start.d.ts +0 -2
  80. package/dist/commands/helpers/english-tutor/session-start.js +0 -34
  81. package/dist/commands/helpers/english-tutor/uninstall.d.ts +0 -2
  82. package/dist/commands/helpers/english-tutor/uninstall.js +0 -15
  83. package/dist/commands/helpers/session-signal/end.d.ts +0 -2
  84. package/dist/commands/helpers/session-signal/end.js +0 -13
  85. package/dist/commands/helpers/session-signal/index.d.ts +0 -1
  86. package/dist/commands/helpers/session-signal/index.js +0 -1
  87. package/dist/commands/helpers/session-signal/install.d.ts +0 -8
  88. package/dist/commands/helpers/session-signal/install.js +0 -24
  89. package/dist/commands/helpers/session-signal/notification.d.ts +0 -2
  90. package/dist/commands/helpers/session-signal/notification.js +0 -13
  91. package/dist/commands/helpers/session-signal/prompt.d.ts +0 -2
  92. package/dist/commands/helpers/session-signal/prompt.js +0 -13
  93. package/dist/commands/helpers/session-signal/stop.d.ts +0 -2
  94. package/dist/commands/helpers/session-signal/stop.js +0 -13
  95. package/dist/commands/issue/setup.d.ts +0 -2
  96. package/dist/commands/issue/setup.js +0 -108
  97. package/dist/commands/issue/switch.d.ts +0 -12
  98. package/dist/commands/issue/switch.js +0 -38
  99. package/dist/commands/linear/switch.d.ts +0 -2
  100. package/dist/commands/linear/switch.js +0 -75
  101. package/dist/commands/setup.d.ts +0 -11
  102. package/dist/commands/setup.js +0 -194
  103. package/dist/lib/english-tutor.d.ts +0 -13
  104. package/dist/lib/english-tutor.js +0 -125
  105. package/dist/lib/session-signal.d.ts +0 -16
  106. package/dist/lib/session-signal.js +0 -104
  107. package/dist/lib/setup/shell-config.d.ts +0 -38
  108. package/dist/lib/setup/shell-config.js +0 -131
  109. package/prompts/english-tutor-prompt.njk +0 -15
@@ -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;
@@ -11,6 +11,8 @@ interface Props {
11
11
  /** True while a just-created PR is shown optimistically, before a refresh
12
12
  * confirms it (renders a "syncing…" hint next to the PR). */
13
13
  prSyncing?: boolean;
14
+ /** Active/recent auto-fix loop for the selected ticket, shown as a ⟳ line. */
15
+ fixLoop?: FixLoopRuntime;
14
16
  /** Triage mode: hide worktree/PR/checks sections (they never apply to an
15
17
  * inbox issue) and show the discussion instead. */
16
18
  triage?: boolean;
@@ -42,5 +44,5 @@ export declare function buildIssueActions(di: DashboardIssue, trackerName: strin
42
44
  canMutate?: boolean;
43
45
  triageInvestigateConfigured?: boolean;
44
46
  }): IssueActionItem[];
45
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, prSyncing, 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;
46
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, prSyncing = false, 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: [
@@ -577,18 +546,51 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
577
546
  lines.push(sectionHeader("◉", "Pull Request"));
578
547
  lines.push({ text: " no PR yet", dim: true });
579
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
+ }
580
577
  // ── Checks ────────────────────────────────────────────────────────
581
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.
582
583
  const passing = checks.filter((c) => c.bucket === "pass");
583
584
  const failing = checks.filter((c) => c.bucket === "fail");
584
- 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");
585
587
  const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
586
588
  lines.push(ruleLine);
587
589
  const headerSegs = [
588
590
  { text: "✓ ", color: "cyan", bold: true },
589
591
  { text: "Checks", bold: true },
590
592
  { text: " " },
591
- { text: `${passing.length}/${checks.length} passing`, color: headerColor },
593
+ { text: `${passing.length} passed`, color: headerColor },
592
594
  ];
593
595
  if (failing.length > 0) {
594
596
  headerSegs.push({ text: " · ", dim: true });
@@ -598,8 +600,13 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
598
600
  headerSegs.push({ text: " · ", dim: true });
599
601
  headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
600
602
  }
603
+ if (skipped.length > 0) {
604
+ headerSegs.push({ text: " · ", dim: true });
605
+ headerSegs.push({ text: `${skipped.length} skipped`, dim: true });
606
+ }
601
607
  lines.push({ text: "", segments: headerSegs });
602
608
  // Order: failing first (most important), then pending, then passing.
609
+ // Skipped checks aren't listed individually — just summarized above.
603
610
  for (const check of failing) {
604
611
  const desc = check.description ? ` — ${check.description}` : "";
605
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 {};
@@ -45,11 +45,30 @@ function workIndicator(wt) {
45
45
  function ciIndicator(checks) {
46
46
  if (!checks || checks.length === 0)
47
47
  return { glyph: "·", color: "gray" };
48
+ // `gh` buckets: pass | fail | pending | skipping | cancel. Only `fail` blocks
49
+ // (red) and only `pending` is genuinely still running (yellow). `skipping` /
50
+ // `cancel` don't run under the current conditions and don't block — GitHub
51
+ // reports them as "skipped" and still shows "all checks passed", so they must
52
+ // not turn the glyph yellow.
48
53
  if (checks.some((c) => c.bucket === "fail"))
49
54
  return { glyph: "✗", color: "red" };
50
- if (checks.every((c) => c.bucket === "pass"))
51
- return { glyph: "", color: "green" };
52
- return { glyph: "", color: "yellow" };
55
+ if (checks.some((c) => c.bucket === "pending"))
56
+ return { glyph: "", color: "yellow" };
57
+ return { glyph: "", color: "green" };
58
+ }
59
+ /**
60
+ * While a fix loop is actively running (or stalled), it owns the CI column — a ⟳
61
+ * conveys "being auto-fixed" better than the underlying check state. Stopped loops
62
+ * fall back to the real CI glyph (CI green ≈ stopped clean, CI red ≈ stopped stuck).
63
+ */
64
+ function fixLoopOverride(loop) {
65
+ if (!loop)
66
+ return null;
67
+ if (loop.phase === "running")
68
+ return { glyph: "⟳", color: "cyan" };
69
+ if (loop.phase === "stalled")
70
+ return { glyph: "⟳", color: "yellow" };
71
+ return null;
53
72
  }
54
73
  export function buildIssueListRows(groups, flatIssues) {
55
74
  const rows = [];
@@ -109,7 +128,7 @@ function readinessGlyph(di) {
109
128
  return { glyph: "·", color: "gray" };
110
129
  }
111
130
  }
112
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
131
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, fixLoops, }) {
113
132
  const isTriage = variant === "triage";
114
133
  const isIssues = variant === "issues";
115
134
  const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
@@ -146,7 +165,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
146
165
  const prio = snoozed ? { glyph: " ", color: "gray" } : priorityMarker(di.issue.priority);
147
166
  const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
148
167
  const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
149
- const ci = ciIndicator(di.checks);
168
+ const ci = fixLoopOverride(fixLoops?.[di.issue.identifier]) ?? ciIndicator(di.checks);
150
169
  const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
151
170
  const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
152
171
  const title = di.issue.title.length > adjustedTitleWidth
@@ -162,7 +162,7 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
162
162
  onSubmit();
163
163
  return;
164
164
  }
165
- // Ctrl+O: escalate to $SANTREE_EDITOR / $VISUAL / $EDITOR. On save+close
165
+ // Ctrl+O: escalate to the configured editor / $VISUAL / $EDITOR. On save+close
166
166
  // the buffer is replaced and control returns to the textbox so the
167
167
  // user can keep editing or submit with Ctrl+D.
168
168
  if (key.ctrl && input === "o") {
@@ -68,6 +68,8 @@ const LEGEND = [
68
68
  { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
69
69
  { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
70
70
  { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
71
+ { glyph: "⟳", color: "cyan", meaning: "CI column: auto-fix loop running (every 5 min)" },
72
+ { glyph: "⟳", color: "yellow", meaning: "CI column: fix loop stalled (no recent activity)" },
71
73
  { glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
72
74
  { glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
73
75
  { glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
@@ -86,10 +88,7 @@ const LEGEND = [
86
88
  { glyph: "✓ clean", color: "green", meaning: "Working tree clean" },
87
89
  { glyph: "↑ N", color: "cyan", meaning: "N commits ahead of base" },
88
90
  { glyph: "↓ N behind", color: "yellow", meaning: "Main repo: N commits to pull from origin" },
89
- { glyph: "", color: "red", meaning: "Session needs input (permission prompt)" },
90
- { glyph: "◆", color: "green", meaning: "Session active (Claude is working)" },
91
- { glyph: "◆", color: "yellow", meaning: "Session idle (waiting for prompt)" },
92
- { glyph: "◇", color: "cyan", meaning: "Session id stored, no live signal" },
91
+ { glyph: "", color: "cyan", meaning: "Resumable Claude session id stored" },
93
92
  { glyph: "◇", color: "gray", meaning: "No session" },
94
93
  ],
95
94
  },
@@ -223,16 +223,20 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
223
223
  }
224
224
  // ── Checks ────────────────────────────────────────────────────────
225
225
  if (checks && checks.length > 0) {
226
+ // `skipping`/`cancel` checks don't run under the current conditions and
227
+ // don't block — GitHub counts them as "skipped" while still reporting all
228
+ // checks passed. Treat them as a non-blocking aside, never as pending.
226
229
  const passing = checks.filter((c) => c.bucket === "pass");
227
230
  const failing = checks.filter((c) => c.bucket === "fail");
228
- const pending = checks.filter((c) => c.bucket !== "pass" && c.bucket !== "fail");
231
+ const pending = checks.filter((c) => c.bucket === "pending");
232
+ const skipped = checks.filter((c) => c.bucket === "skipping" || c.bucket === "cancel");
229
233
  const headerColor = failing.length > 0 ? "red" : pending.length > 0 ? "yellow" : "green";
230
234
  lines.push(ruleLine);
231
235
  const headerSegs = [
232
236
  { text: "✓ ", color: "cyan", bold: true },
233
237
  { text: "Checks", bold: true },
234
238
  { text: " " },
235
- { text: `${passing.length}/${checks.length} passing`, color: headerColor },
239
+ { text: `${passing.length} passed`, color: headerColor },
236
240
  ];
237
241
  if (failing.length > 0) {
238
242
  headerSegs.push({ text: " · ", dim: true });
@@ -242,8 +246,13 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
242
246
  headerSegs.push({ text: " · ", dim: true });
243
247
  headerSegs.push({ text: `${pending.length} pending`, color: "yellow" });
244
248
  }
249
+ if (skipped.length > 0) {
250
+ headerSegs.push({ text: " · ", dim: true });
251
+ headerSegs.push({ text: `${skipped.length} skipped`, dim: true });
252
+ }
245
253
  lines.push({ text: "", segments: headerSegs });
246
254
  // Order: failing first (most important), then pending, then passing.
255
+ // Skipped checks aren't listed individually — just summarized above.
247
256
  for (const check of failing) {
248
257
  const desc = check.description ? ` — ${check.description}` : "";
249
258
  lines.push({ text: ` ✗ ${check.name}${desc}`, color: "red" });
@@ -3,11 +3,14 @@ import { Box, Text } from "ink";
3
3
  function checksIndicator(checks) {
4
4
  if (!checks || checks.length === 0)
5
5
  return { text: "-", color: "gray" };
6
+ // Only `fail` blocks (red) and only `pending` is genuinely running (yellow);
7
+ // `skipping`/`cancel` don't run under the current conditions and don't block,
8
+ // so they must not turn the glyph yellow (matches GitHub's "all checks passed").
6
9
  if (checks.some((c) => c.bucket === "fail"))
7
10
  return { text: "✗", color: "red" };
8
- if (checks.every((c) => c.bucket === "pass"))
9
- return { text: "", color: "green" };
10
- return { text: "", color: "yellow" };
11
+ if (checks.some((c) => c.bucket === "pending"))
12
+ return { text: "", color: "yellow" };
13
+ return { text: "", color: "green" };
11
14
  }
12
15
  const HEADER_ROWS = 1;
13
16
  export function getReviewListRowCount(flatReviews) {