santree 0.2.1 → 0.2.2

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
@@ -39,6 +39,7 @@ eval "$(santree helpers shell-init bash)" # for bash
39
39
  This enables automatic directory switching after `worktree create` and `worktree switch` commands.
40
40
 
41
41
  The shell integration also provides:
42
+
42
43
  - `st` - Alias for `santree`
43
44
  - `stw` - Alias for `santree worktree` (e.g., `stw list`, `stw create`)
44
45
  - `stn` - Quick create worktree with `--work --plan --tmux` (prompts for branch name)
@@ -88,55 +89,56 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
88
89
 
89
90
  ### Worktree (`santree worktree`)
90
91
 
91
- | Command | Description |
92
- |---------|-------------|
93
- | `santree worktree create <branch>` | Create a new worktree from base branch |
94
- | `santree worktree list` | List all worktrees with PR status and commits ahead |
95
- | `santree worktree switch <branch>` | Switch to another worktree |
96
- | `santree worktree remove <branch>` | Remove a worktree and its branch |
97
- | `santree worktree clean` | Remove worktrees with merged/closed PRs |
98
- | `santree worktree sync` | Sync current worktree with base branch |
99
- | `santree worktree work` | Launch Claude AI to work on the current ticket |
100
- | `santree worktree open` | Open workspace in VSCode or Cursor |
101
- | `santree worktree setup` | Run the init script (`.santree/init.sh`) |
102
- | `santree worktree commit` | Stage and commit changes |
92
+ | Command | Description |
93
+ | ---------------------------------- | --------------------------------------------------- |
94
+ | `santree worktree create <branch>` | Create a new worktree from base branch |
95
+ | `santree worktree list` | List all worktrees with PR status and commits ahead |
96
+ | `santree worktree switch <branch>` | Switch to another worktree |
97
+ | `santree worktree remove <branch>` | Remove a worktree and its branch |
98
+ | `santree worktree clean` | Remove worktrees with merged/closed PRs |
99
+ | `santree worktree sync` | Sync current worktree with base branch |
100
+ | `santree worktree work` | Launch Claude AI to work on the current ticket |
101
+ | `santree worktree open` | Open workspace in VSCode or Cursor |
102
+ | `santree worktree setup` | Run the init script (`.santree/init.sh`) |
103
+ | `santree worktree commit` | Stage and commit changes |
103
104
 
104
105
  ### Pull Requests (`santree pr`)
105
106
 
106
- | Command | Description |
107
- |---------|-------------|
108
- | `santree pr create` | Create a GitHub pull request |
109
- | `santree pr open` | Open the current PR in the browser |
110
- | `santree pr fix` | Fix PR review comments with AI |
107
+ | Command | Description |
108
+ | ------------------- | ------------------------------------- |
109
+ | `santree pr create` | Create a GitHub pull request |
110
+ | `santree pr open` | Open the current PR in the browser |
111
+ | `santree pr fix` | Fix PR review comments with AI |
111
112
  | `santree pr review` | Review changes against ticket with AI |
112
113
 
113
114
  ### Linear (`santree linear`)
114
115
 
115
- | Command | Description |
116
- |---------|-------------|
117
- | `santree linear auth` | Authenticate with Linear (OAuth) |
118
- | `santree linear switch` | Switch Linear workspace for this repo |
119
- | `santree linear open` | Open the current Linear ticket in the browser |
116
+ | Command | Description |
117
+ | ----------------------- | --------------------------------------------- |
118
+ | `santree linear auth` | Authenticate with Linear (OAuth) |
119
+ | `santree linear switch` | Switch Linear workspace for this repo |
120
+ | `santree linear open` | Open the current Linear ticket in the browser |
120
121
 
121
122
  ### Helpers (`santree helpers`)
122
123
 
123
- | Command | Description |
124
- |---------|-------------|
125
- | `santree helpers shell-init` | Output shell integration script |
124
+ | Command | Description |
125
+ | ---------------------------- | --------------------------------- |
126
+ | `santree helpers shell-init` | Output shell integration script |
126
127
  | `santree helpers statusline` | Custom statusline for Claude Code |
127
128
 
128
129
  ### Top-level
129
130
 
130
- | Command | Description |
131
- |---------|-------------|
131
+ | Command | Description |
132
+ | ------------------- | ----------------------------------------------- |
132
133
  | `santree dashboard` | Interactive dashboard of all your Linear issues |
133
- | `santree doctor` | Check system requirements and integrations |
134
+ | `santree doctor` | Check system requirements and integrations |
134
135
 
135
136
  ---
136
137
 
137
138
  ## Features
138
139
 
139
140
  ### Interactive Dashboard
141
+
140
142
  `santree dashboard` opens a full-screen TUI to manage all your work in one place. It shows your Linear issues grouped by project, with live status for worktrees, PRs, CI checks, and reviews.
141
143
 
142
144
  **Left pane** — issue list with columns for priority, session ID, PR number, and CI status. Click to select, scroll wheel to navigate, drag the divider to resize panes.
@@ -158,22 +160,28 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
158
160
  Commit and PR creation happen inline without leaving the dashboard. Work, fix, and review open in new tmux windows.
159
161
 
160
162
  ### Worktree Management
163
+
161
164
  Create isolated worktrees for each feature branch. No more stashing or committing WIP code just to switch tasks.
162
165
 
163
166
  ### GitHub Integration
167
+
164
168
  See PR status directly in your worktree list. Clean up worktrees automatically when PRs are merged or closed.
165
169
 
166
170
  ### Linear Integration
171
+
167
172
  Santree fetches Linear ticket data (title, description, comments, images) and injects it into prompts when running `santree worktree work`. See [Linear Integration](#linear-integration-1) for setup.
168
173
 
169
174
  ### Claude AI Integration
175
+
170
176
  Launch Claude with full context about your current ticket. Supports different modes:
177
+
171
178
  - `santree worktree work` - Implement the ticket
172
179
  - `santree worktree work --plan` - Create an implementation plan only
173
180
  - `santree pr review` - Review changes against ticket requirements
174
181
  - `santree pr fix` - Address PR review comments
175
182
 
176
183
  ### Init Scripts
184
+
177
185
  Run custom setup scripts when creating worktrees. Perfect for copying `.env` files, installing dependencies, or any project-specific setup.
178
186
 
179
187
  ---
@@ -226,81 +234,95 @@ If you have multiple workspaces authenticated, running `santree linear auth` in
226
234
  Santree provides a custom statusline for Claude Code showing git info, model, context usage, and cost.
227
235
 
228
236
  Add to `~/.claude/settings.json`:
237
+
229
238
  ```json
230
239
  {
231
- "statusLine": {
232
- "type": "command",
233
- "command": "santree helpers statusline"
234
- }
240
+ "statusLine": {
241
+ "type": "command",
242
+ "command": "santree helpers statusline"
243
+ }
235
244
  }
236
245
  ```
237
246
 
238
247
  The statusline displays: `repo | branch | S: staged | U: unstaged | A: untracked | Model | Context% | $Cost`
239
248
 
249
+ ### Claude Code Remote Control (Optional)
250
+
251
+ Enable [Remote Control](https://code.claude.com/docs/en/remote-control) to continue local Claude Code sessions from your phone, tablet, or any browser. This lets you kick off work with `santree worktree work` and monitor or steer the session remotely.
252
+
253
+ Enable it for all sessions by running `/config` inside Claude Code and setting **Enable Remote Control for all sessions** to `true`. This writes `remoteControlAtStartup: true` to `~/.claude.json`. Run `santree doctor` to verify.
254
+
240
255
  ---
241
256
 
242
257
  ## Command Options
243
258
 
244
259
  ### worktree create
245
- | Option | Description |
246
- |--------|-------------|
260
+
261
+ | Option | Description |
262
+ | ----------------- | ------------------------------------------------- |
247
263
  | `--base <branch>` | Base branch to create from (default: main/master) |
248
- | `--work` | Launch Claude after creating |
249
- | `--plan` | With --work, only create implementation plan |
250
- | `--no-pull` | Skip pulling latest changes |
251
- | `--tmux` | Open worktree in new tmux window |
252
- | `--name <name>` | Custom tmux window name |
264
+ | `--work` | Launch Claude after creating |
265
+ | `--plan` | With --work, only create implementation plan |
266
+ | `--no-pull` | Skip pulling latest changes |
267
+ | `--tmux` | Open worktree in new tmux window |
268
+ | `--name <name>` | Custom tmux window name |
253
269
 
254
270
  ### worktree sync
255
- | Option | Description |
256
- |--------|-------------|
271
+
272
+ | Option | Description |
273
+ | ---------- | --------------------------- |
257
274
  | `--rebase` | Use rebase instead of merge |
258
275
 
259
276
  ### worktree remove
277
+
260
278
  Removes the worktree and deletes the branch. Uses force mode by default (removes even with uncommitted changes).
261
279
 
262
280
  ### worktree clean
281
+
263
282
  Shows worktrees with merged/closed PRs and prompts for confirmation before removing.
264
283
 
265
284
  ### worktree open
266
- | Option | Description |
267
- |--------|-------------|
285
+
286
+ | Option | Description |
287
+ | ---------------- | --------------------------------------------------------------------------------------- |
268
288
  | `--editor <cmd>` | Editor command to use (default: `code`). Also configurable via `SANTREE_EDITOR` env var |
269
289
 
270
290
  ### worktree work
271
- | Option | Description |
272
- |--------|-------------|
291
+
292
+ | Option | Description |
293
+ | -------- | ------------------------------- |
273
294
  | `--plan` | Only create implementation plan |
274
295
 
275
296
  Automatically fetches Linear ticket data if authenticated. Degrades gracefully if not.
276
297
 
277
298
  ### pr create
278
- | Option | Description |
279
- |--------|-------------|
299
+
300
+ | Option | Description |
301
+ | -------- | --------------------------------------------- |
280
302
  | `--fill` | Use AI to fill the PR template before opening |
281
303
 
282
304
  Automatically pushes, detects existing PRs, and uses the first commit message as the title. If a closed PR exists for the branch, prompts before creating a new one.
283
305
 
284
306
  ### linear auth
285
- | Option | Description |
286
- |--------|-------------|
287
- | `--status` | Show current auth status (org, token expiry) |
307
+
308
+ | Option | Description |
309
+ | ------------- | ------------------------------------------------ |
310
+ | `--status` | Show current auth status (org, token expiry) |
288
311
  | `--test <id>` | Fetch a ticket by ID to verify integration works |
289
- | `--logout` | Revoke tokens and log out |
312
+ | `--logout` | Revoke tokens and log out |
290
313
 
291
314
  ---
292
315
 
293
316
  ## Requirements
294
317
 
295
- | Tool | Purpose |
296
- |------|---------|
297
- | Node.js >= 20 | Runtime |
298
- | Git | Worktree operations |
299
- | GitHub CLI (`gh`) | PR integration |
300
- | Claude Code (`claude`) | AI agent for `work`, `fix`, `review` |
301
- | happy (`happy`) | Optional: used over `claude` if installed |
302
- | tmux | Optional: new window support |
303
- | VSCode (`code`) or Cursor (`cursor`) | Optional: workspace editor |
318
+ | Tool | Purpose |
319
+ | ------------------------------------ | ------------------------------------ |
320
+ | Node.js >= 20 | Runtime |
321
+ | Git | Worktree operations |
322
+ | GitHub CLI (`gh`) | PR integration |
323
+ | Claude Code (`claude`) | AI agent for `work`, `fix`, `review` |
324
+ | tmux | Optional: new window support |
325
+ | VSCode (`code`) or Cursor (`cursor`) | Optional: workspace editor |
304
326
 
305
327
  ---
306
328
 
@@ -10,8 +10,12 @@ import * as path from "path";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
12
  import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, } from "../lib/git.js";
13
- import { spawnAsync } from "../lib/exec.js";
13
+ import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
+ import { extractTicketId } from "../lib/git.js";
16
+ import { getPRTemplate } from "../lib/github.js";
17
+ import { renderPrompt, renderDiff } from "../lib/prompts.js";
18
+ import * as os from "os";
15
19
  import { initialState, reducer } from "../lib/dashboard/types.js";
16
20
  import { loadDashboardData } from "../lib/dashboard/data.js";
17
21
  import IssueList from "../lib/dashboard/IssueList.js";
@@ -563,29 +567,110 @@ export default function Dashboard() {
563
567
  if (!s.prCreateWorktreePath || !s.prCreateBranch)
564
568
  return;
565
569
  const base = getBaseBranch(s.prCreateBranch);
570
+ const cwd = s.prCreateWorktreePath;
566
571
  // Push first
567
572
  dispatch({ type: "PR_CREATE_PHASE", phase: "pushing" });
568
573
  try {
569
- await execAsync(`git -C "${s.prCreateWorktreePath}" push -u origin "${s.prCreateBranch}"`);
574
+ await execAsync(`git -C "${cwd}" push -u origin "${s.prCreateBranch}"`);
570
575
  }
571
576
  catch (e) {
572
577
  const msg = e?.stderr?.trim() || e?.message || "Push failed";
573
578
  dispatch({ type: "PR_CREATE_ERROR", error: msg });
574
579
  return;
575
580
  }
576
- dispatch({ type: "PR_CREATE_PHASE", phase: "creating" });
577
- try {
578
- if (fill) {
579
- const { stdout } = await execAsync(`gh pr create --fill --base "${base}" --head "${s.prCreateBranch}"`, { cwd: s.prCreateWorktreePath });
580
- const url = stdout.trim();
581
- dispatch({ type: "PR_CREATE_DONE", url });
582
- }
583
- else {
581
+ if (!fill) {
582
+ // Web mode — open in browser directly
583
+ try {
584
+ dispatch({ type: "PR_CREATE_PHASE", phase: "creating" });
584
585
  await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, {
585
- cwd: s.prCreateWorktreePath,
586
+ cwd,
586
587
  });
587
588
  dispatch({ type: "PR_CREATE_DONE", url: "" });
589
+ setTimeout(() => {
590
+ dispatch({ type: "PR_CREATE_CANCEL" });
591
+ refresh();
592
+ }, 2500);
593
+ }
594
+ catch (e) {
595
+ const msg = e?.stderr?.trim() || e?.message || "PR creation failed";
596
+ dispatch({ type: "PR_CREATE_ERROR", error: msg });
597
+ }
598
+ return;
599
+ }
600
+ // Fill mode — use AI to generate body, then review
601
+ try {
602
+ const prTemplate = getPRTemplate();
603
+ if (!prTemplate) {
604
+ dispatch({
605
+ type: "PR_CREATE_ERROR",
606
+ error: "No PR template found at .github/pull_request_template.md",
607
+ });
608
+ return;
609
+ }
610
+ const bin = resolveAgentBinary();
611
+ if (!bin) {
612
+ dispatch({
613
+ type: "PR_CREATE_ERROR",
614
+ error: "Claude CLI not found (npm i -g @anthropic-ai/claude-code)",
615
+ });
616
+ return;
588
617
  }
618
+ dispatch({ type: "PR_CREATE_PHASE", phase: "filling" });
619
+ const ticketId = extractTicketId(s.prCreateBranch) ?? "";
620
+ const commitLog = run(`git log ${base}..HEAD --format="- %s"`, { cwd }) || null;
621
+ const diffStat = run(`git diff ${base}..HEAD --stat`, { cwd }) || null;
622
+ const diff = run(`git diff ${base}..HEAD`, { cwd, maxBuffer: 10 * 1024 * 1024 }) || null;
623
+ const diffContent = renderDiff({
624
+ base_branch: base,
625
+ commit_log: commitLog,
626
+ diff_stat: diffStat,
627
+ diff: diff,
628
+ });
629
+ const prompt = renderPrompt("fill-pr", {
630
+ pr_template: prTemplate,
631
+ diff_content: diffContent,
632
+ ticket_id: ticketId,
633
+ branch_name: s.prCreateBranch,
634
+ });
635
+ // Pass prompt via stdin instead of temp file
636
+ const agentResult = await spawnAsync(bin, ["-p", "--output-format", "text"], {
637
+ stdin: prompt,
638
+ });
639
+ const body = agentResult.output.trim();
640
+ if (agentResult.code !== 0 || !body || body.toLowerCase().startsWith("error")) {
641
+ dispatch({
642
+ type: "PR_CREATE_ERROR",
643
+ error: body || "Failed to generate PR body with AI",
644
+ });
645
+ return;
646
+ }
647
+ // Get title from first commit
648
+ const title = run(`git log ${base}..HEAD --reverse --format=%s`, { cwd })?.split("\n")[0] ??
649
+ s.prCreateBranch;
650
+ // Show review instead of creating immediately
651
+ dispatch({ type: "PR_CREATE_REVIEW", body, title });
652
+ }
653
+ catch (e) {
654
+ const msg = e?.stderr?.trim() || e?.message || "PR creation failed";
655
+ dispatch({ type: "PR_CREATE_ERROR", error: msg });
656
+ }
657
+ }, [refresh]);
658
+ const confirmPrCreate = useCallback(async () => {
659
+ const s = stateRef.current;
660
+ if (!s.prCreateWorktreePath || !s.prCreateBranch || !s.prCreateBody || !s.prCreateTitle)
661
+ return;
662
+ const base = getBaseBranch(s.prCreateBranch);
663
+ const cwd = s.prCreateWorktreePath;
664
+ dispatch({ type: "PR_CREATE_PHASE", phase: "creating" });
665
+ try {
666
+ const bodyFile = path.join(os.tmpdir(), `santree-pr-${Date.now()}.md`);
667
+ fs.writeFileSync(bodyFile, s.prCreateBody);
668
+ const { stdout } = await execAsync(`gh pr create --title "${s.prCreateTitle.replace(/"/g, '\\"')}" --base "${base}" --head "${s.prCreateBranch}" --body-file "${bodyFile}"`, { cwd });
669
+ try {
670
+ fs.unlinkSync(bodyFile);
671
+ }
672
+ catch { }
673
+ dispatch({ type: "PR_CREATE_DONE", url: stdout.trim() });
589
674
  setTimeout(() => {
590
675
  dispatch({ type: "PR_CREATE_CANCEL" });
591
676
  refresh();
@@ -596,6 +681,25 @@ export default function Dashboard() {
596
681
  dispatch({ type: "PR_CREATE_ERROR", error: msg });
597
682
  }
598
683
  }, [refresh]);
684
+ const openPrInWeb = useCallback(async () => {
685
+ const s = stateRef.current;
686
+ if (!s.prCreateWorktreePath || !s.prCreateBranch)
687
+ return;
688
+ const base = getBaseBranch(s.prCreateBranch);
689
+ const cwd = s.prCreateWorktreePath;
690
+ try {
691
+ await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, { cwd });
692
+ dispatch({ type: "PR_CREATE_DONE", url: "" });
693
+ setTimeout(() => {
694
+ dispatch({ type: "PR_CREATE_CANCEL" });
695
+ refresh();
696
+ }, 2500);
697
+ }
698
+ catch (e) {
699
+ const msg = e?.stderr?.trim() || e?.message || "Failed to open in browser";
700
+ dispatch({ type: "PR_CREATE_ERROR", error: msg });
701
+ }
702
+ }, [refresh]);
599
703
  // ── Keyboard ──────────────────────────────────────────────────────
600
704
  useInput((input, key) => {
601
705
  // Clear action messages on any keypress
@@ -639,6 +743,30 @@ export default function Dashboard() {
639
743
  return;
640
744
  }
641
745
  }
746
+ if (state.prCreatePhase === "review") {
747
+ if (input === "y" || key.return) {
748
+ confirmPrCreate();
749
+ return;
750
+ }
751
+ if (input === "w") {
752
+ openPrInWeb();
753
+ return;
754
+ }
755
+ if (key.shift && key.downArrow) {
756
+ dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
757
+ return;
758
+ }
759
+ if (key.shift && key.upArrow) {
760
+ dispatch({ type: "SCROLL_DETAIL", offset: Math.max(0, state.detailScrollOffset - 3) });
761
+ return;
762
+ }
763
+ }
764
+ if (state.prCreatePhase === "error") {
765
+ if (input === "w") {
766
+ openPrInWeb();
767
+ return;
768
+ }
769
+ }
642
770
  return;
643
771
  }
644
772
  // Confirm setup overlay
@@ -941,5 +1069,5 @@ export default function Dashboard() {
941
1069
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "No active issues assigned to you" }), _jsx(Text, { dimColor: true, children: "Press R to refresh or q to quit" })] }) }));
942
1070
  }
943
1071
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
944
- 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] }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), 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 === "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: _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.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 })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1072
+ 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] }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), 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 === "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: _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.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 })) })] }))] }));
945
1073
  }
@@ -143,6 +143,34 @@ function checkShellIntegration() {
143
143
  const configured = process.env.SANTREE_SHELL_INTEGRATION === "1";
144
144
  return { configured, shell: shellName };
145
145
  }
146
+ /**
147
+ * Checks if Claude Code Remote Control is enabled for all sessions.
148
+ * Remote Control lets you continue local sessions from any device.
149
+ *
150
+ * This reads from ~/.claude.json (the "global config" / application state file),
151
+ * which is separate from ~/.claude/settings.json (the declarative settings file).
152
+ * See: https://code.claude.com/docs/en/settings#settings-files
153
+ */
154
+ function checkRemoteControl() {
155
+ const home = process.env.HOME || "";
156
+ const configPath = path.join(home, ".claude.json");
157
+ try {
158
+ if (fs.existsSync(configPath)) {
159
+ const content = fs.readFileSync(configPath, "utf-8");
160
+ const config = JSON.parse(content);
161
+ if (config.remoteControlAtStartup === true) {
162
+ return { enabled: true };
163
+ }
164
+ }
165
+ }
166
+ catch {
167
+ // JSON parse error or file read error
168
+ }
169
+ return {
170
+ enabled: false,
171
+ hint: 'Run /config in Claude Code and enable "Enable Remote Control for all sessions"',
172
+ };
173
+ }
146
174
  /**
147
175
  * Checks statusline configuration:
148
176
  * If ~/.claude/settings.json has statusLine pointing to santree
@@ -270,6 +298,9 @@ function ShellRow({ configured, shell }) {
270
298
  function StatuslineRow({ status }) {
271
299
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.claudeSettingsConfigured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Claude Statusline" }), _jsx(Text, { dimColor: true, children: " - Custom statusline in Claude Code" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [status.currentCommand ? (_jsxs(Text, { dimColor: true, children: ["Command: ", status.currentCommand] })) : (_jsx(Text, { dimColor: true, children: "Command: not configured" })), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
272
300
  }
301
+ function RemoteControlRow({ status }) {
302
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.enabled, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Remote Control" }), _jsx(Text, { dimColor: true, children: " - Continue sessions from any device" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Enabled: ", status.enabled ? "yes" : "no"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
303
+ }
273
304
  function SantreeSetupRow({ status }) {
274
305
  const isOk = status.santreeFolderExists &&
275
306
  status.initShExists &&
@@ -289,6 +320,7 @@ export default function Doctor() {
289
320
  const [tools, setTools] = useState([]);
290
321
  const [linear, setLinear] = useState(null);
291
322
  const [shellStatus, setShellStatus] = useState(null);
323
+ const [remoteControl, setRemoteControl] = useState(null);
292
324
  const [statusline, setStatusline] = useState(null);
293
325
  const [santreeSetup, setSantreeSetup] = useState(null);
294
326
  const [loading, setLoading] = useState(true);
@@ -299,7 +331,6 @@ export default function Doctor() {
299
331
  checkGhAuth(),
300
332
  checkTool("tmux", "Terminal multiplexer", false, "tmux -V", "Install: brew install tmux"),
301
333
  checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
302
- checkTool("happy", "Claude CLI wrapper (used over claude if installed)", false, "happy --version 2>/dev/null || echo 'installed'", "Install: npm install -g happy-coder"),
303
334
  ]);
304
335
  // Check for either code or cursor (only need one)
305
336
  const [codeCheck, cursorCheck] = await Promise.all([
@@ -326,6 +357,7 @@ export default function Doctor() {
326
357
  setTools(results);
327
358
  setLinear(linearResult);
328
359
  setShellStatus(checkShellIntegration());
360
+ setRemoteControl(checkRemoteControl());
329
361
  setStatusline(statuslineResult);
330
362
  setSantreeSetup(checkSantreeSetup());
331
363
  setLoading(false);
@@ -339,5 +371,5 @@ export default function Doctor() {
339
371
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
340
372
  const linearOk = linear?.authenticated && linear?.tokenValid && linear?.repoLinked;
341
373
  const allRequired = requiredMissing.length === 0 && linearOk && shellStatus?.configured;
342
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), linear && _jsx(LinearRow, { linear: linear }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Aesthetics" }) }), statusline && _jsx(StatuslineRow, { status: statusline }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (linearOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
374
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), linear && _jsx(LinearRow, { linear: linear }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (linearOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
343
375
  }
@@ -2,10 +2,10 @@ import { z } from "zod/v4";
2
2
  export declare const description = "Render a template to stdout";
3
3
  export declare const args: z.ZodTuple<[z.ZodEnum<{
4
4
  linear: "linear";
5
+ review: "review";
5
6
  pr: "pr";
6
7
  "git-changes": "git-changes";
7
8
  "fix-pr": "fix-pr";
8
- review: "review";
9
9
  }>], null>;
10
10
  type Props = {
11
11
  args: z.infer<typeof args>;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- import { resolveAIContext, renderAIPrompt, launchAgent, resolveAgentBinary, cleanupImages, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
5
+ import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
6
6
  export const description = "Fix PR review comments";
7
7
  export default function Fix() {
8
8
  const [status, setStatus] = useState("loading");
@@ -54,5 +54,5 @@ export default function Fix() {
54
54
  }
55
55
  init();
56
56
  }, []);
57
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", bold: true, children: " fix PR " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Launching ", resolveAgentBinary() === "happy" ? "Claude (through Happy)" : "Claude", "..."] }), _jsxs(Text, { dimColor: true, children: [" ", resolveAgentBinary(), " ", `"<fix-pr prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
57
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", bold: true, children: " fix PR " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" claude ", `"<fix-pr prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
58
58
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- import { resolveAIContext, renderAIPrompt, launchAgent, resolveAgentBinary, cleanupImages, fetchAndRenderDiff, } from "../../lib/ai.js";
5
+ import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, fetchAndRenderDiff, } from "../../lib/ai.js";
6
6
  export const description = "Review changes against ticket requirements";
7
7
  export default function Review() {
8
8
  const [status, setStatus] = useState("loading");
@@ -47,5 +47,5 @@ export default function Review() {
47
47
  }
48
48
  init();
49
49
  }, []);
50
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Review" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "yellow", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "yellow", color: "white", bold: true, children: " review " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket, diff, and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Launching ", resolveAgentBinary() === "happy" ? "Claude (through Happy)" : "Claude", "..."] }), _jsxs(Text, { dimColor: true, children: [" ", resolveAgentBinary(), " ", `"<review prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
50
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Review" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "yellow", paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: "yellow", color: "white", bold: true, children: " review " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket, diff, and PR feedback..."] })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" claude ", `"<review prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
51
51
  }
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { resolveAIContext, renderAIPrompt, launchAgent, resolveAgentBinary, cleanupImages, } from "../../lib/ai.js";
6
+ import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, } from "../../lib/ai.js";
7
7
  import { randomUUID } from "crypto";
8
8
  import { getSessionId, setSessionId } from "../../lib/git.js";
9
9
  export const description = "Launch Claude to work on current ticket";
@@ -78,5 +78,5 @@ export default function Work({ options }) {
78
78
  setError(err instanceof Error ? err.message : "Failed to launch agent");
79
79
  }
80
80
  }, [status, aiContext, mode]);
81
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching ticket from Linear..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Launching ", resolveAgentBinary() === "happy" ? "Claude (through Happy)" : "Claude", "..."] }), _jsxs(Text, { dimColor: true, children: [" ", resolveAgentBinary(), mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
81
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching ticket from Linear..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
82
82
  }
package/dist/lib/ai.d.ts CHANGED
@@ -37,15 +37,14 @@ export declare function fetchAndRenderPR(branch: string): Promise<string | null>
37
37
  */
38
38
  export declare function fetchAndRenderDiff(branch: string): Promise<string>;
39
39
  /**
40
- * Resolve which agent binary to use (happy if installed, otherwise claude).
41
- * Returns the binary name, or null if neither is installed.
40
+ * Check if claude CLI is available on PATH.
41
+ * Returns "claude" or null if not installed.
42
42
  */
43
43
  export declare function resolveAgentBinary(): string | null;
44
44
  /**
45
45
  * Launch an interactive agent session with a prompt.
46
- * Resolves the agent binary (happy > claude), passes prompt directly
47
- * or via temp file if too large for OS arg limit.
48
- * Throws if no agent binary is found.
46
+ * Passes prompt directly or via temp file if too large for OS arg limit.
47
+ * Throws if claude CLI is not found.
49
48
  */
50
49
  export declare function launchAgent(prompt: string, opts?: {
51
50
  planMode?: boolean;
@@ -58,9 +57,8 @@ export interface RunAgentResult {
58
57
  }
59
58
  /**
60
59
  * Run an agent in non-interactive print mode and capture output.
61
- * Resolves the agent binary (happy > claude), passes prompt directly
62
- * or via temp file if too large for OS arg limit.
63
- * Throws if no agent binary is found.
60
+ * Passes prompt directly or via temp file if too large for OS arg limit.
61
+ * Throws if claude CLI is not found.
64
62
  */
65
63
  export declare function runAgent(prompt: string): RunAgentResult;
66
64
  /**
package/dist/lib/ai.js CHANGED
@@ -105,20 +105,17 @@ export async function fetchAndRenderDiff(branch) {
105
105
  });
106
106
  }
107
107
  /**
108
- * Resolve which agent binary to use (happy if installed, otherwise claude).
109
- * Returns the binary name, or null if neither is installed.
108
+ * Check if claude CLI is available on PATH.
109
+ * Returns "claude" or null if not installed.
110
110
  */
111
111
  export function resolveAgentBinary() {
112
- for (const bin of ["happy", "claude"]) {
113
- try {
114
- execSync(`which ${bin}`, { stdio: "ignore" });
115
- return bin;
116
- }
117
- catch {
118
- continue;
119
- }
112
+ try {
113
+ execSync("which claude", { stdio: "ignore" });
114
+ return "claude";
115
+ }
116
+ catch {
117
+ return null;
120
118
  }
121
- return null;
122
119
  }
123
120
  // Conservative limit: 200KB leaves room for env vars within macOS 256KB ARG_MAX
124
121
  const ARG_MAX_SAFE = 200 * 1024;
@@ -137,14 +134,13 @@ function promptArg(prompt) {
137
134
  }
138
135
  /**
139
136
  * Launch an interactive agent session with a prompt.
140
- * Resolves the agent binary (happy > claude), passes prompt directly
141
- * or via temp file if too large for OS arg limit.
142
- * Throws if no agent binary is found.
137
+ * Passes prompt directly or via temp file if too large for OS arg limit.
138
+ * Throws if claude CLI is not found.
143
139
  */
144
140
  export function launchAgent(prompt, opts) {
145
141
  const bin = resolveAgentBinary();
146
142
  if (!bin) {
147
- throw new Error("No agent found. Install happy (npm i -g happy-coder) or claude (npm i -g @anthropic-ai/claude-code).");
143
+ throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
148
144
  }
149
145
  const args = [];
150
146
  if (opts?.planMode) {
@@ -163,14 +159,13 @@ export function launchAgent(prompt, opts) {
163
159
  }
164
160
  /**
165
161
  * Run an agent in non-interactive print mode and capture output.
166
- * Resolves the agent binary (happy > claude), passes prompt directly
167
- * or via temp file if too large for OS arg limit.
168
- * Throws if no agent binary is found.
162
+ * Passes prompt directly or via temp file if too large for OS arg limit.
163
+ * Throws if claude CLI is not found.
169
164
  */
170
165
  export function runAgent(prompt) {
171
166
  const bin = resolveAgentBinary();
172
167
  if (!bin) {
173
- throw new Error("No agent found. Install happy (npm i -g happy-coder) or claude (npm i -g @anthropic-ai/claude-code).");
168
+ throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
174
169
  }
175
170
  const result = spawnSync(bin, ["-p", "--output-format", "text", "--", promptArg(prompt)], {
176
171
  encoding: "utf-8",
@@ -20,6 +20,9 @@ interface PrCreateOverlayProps {
20
20
  phase: PrCreatePhase;
21
21
  error: string | null;
22
22
  url: string | null;
23
+ body: string | null;
24
+ title: string | null;
25
+ scrollOffset: number;
23
26
  }
24
- export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
27
+ export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, scrollOffset, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
25
28
  export {};
@@ -20,6 +20,9 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
20
20
  return (_jsxs(Text, { color: color, children: [" ", line] }, i));
21
21
  }), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
22
22
  }
23
- export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, }) {
24
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 auto-fill title & body from commits"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
23
+ export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, scrollOffset, }) {
24
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Review PR" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), body
25
+ ?.split("\n")
26
+ .slice(scrollOffset, scrollOffset + height - 11)
27
+ .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line }, i))), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "y" }), "/Enter create", " ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel Shift+arrows scroll"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
25
28
  }
@@ -41,7 +41,7 @@ export interface ProjectGroup {
41
41
  }
42
42
  export type ActionOverlay = "mode-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
43
43
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
44
- export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "creating" | "done" | "error";
44
+ export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
45
45
  export interface DashboardState {
46
46
  groups: ProjectGroup[];
47
47
  flatIssues: DashboardIssue[];
@@ -70,6 +70,8 @@ export interface DashboardState {
70
70
  prCreateBranch: string | null;
71
71
  prCreateError: string | null;
72
72
  prCreateUrl: string | null;
73
+ prCreateBody: string | null;
74
+ prCreateTitle: string | null;
73
75
  setupMode: "plan" | "implement" | null;
74
76
  }
75
77
  export type DashboardAction = {
@@ -146,6 +148,10 @@ export type DashboardAction = {
146
148
  } | {
147
149
  type: "PR_CREATE_ERROR";
148
150
  error: string;
151
+ } | {
152
+ type: "PR_CREATE_REVIEW";
153
+ body: string;
154
+ title: string;
149
155
  } | {
150
156
  type: "PR_CREATE_DONE";
151
157
  url: string;
@@ -27,6 +27,8 @@ export const initialState = {
27
27
  prCreateBranch: null,
28
28
  prCreateError: null,
29
29
  prCreateUrl: null,
30
+ prCreateBody: null,
31
+ prCreateTitle: null,
30
32
  setupMode: null,
31
33
  };
32
34
  export function reducer(state, action) {
@@ -133,8 +135,22 @@ export function reducer(state, action) {
133
135
  return { ...state, prCreatePhase: action.phase };
134
136
  case "PR_CREATE_ERROR":
135
137
  return { ...state, prCreatePhase: "error", prCreateError: action.error };
138
+ case "PR_CREATE_REVIEW":
139
+ return {
140
+ ...state,
141
+ prCreatePhase: "review",
142
+ prCreateBody: action.body,
143
+ prCreateTitle: action.title,
144
+ detailScrollOffset: 0,
145
+ };
136
146
  case "PR_CREATE_DONE":
137
- return { ...state, prCreatePhase: "done", prCreateUrl: action.url };
147
+ return {
148
+ ...state,
149
+ prCreatePhase: "done",
150
+ prCreateUrl: action.url,
151
+ prCreateBody: null,
152
+ prCreateTitle: null,
153
+ };
138
154
  case "PR_CREATE_CANCEL":
139
155
  return {
140
156
  ...state,
@@ -145,6 +161,8 @@ export function reducer(state, action) {
145
161
  prCreateBranch: null,
146
162
  prCreateError: null,
147
163
  prCreateUrl: null,
164
+ prCreateBody: null,
165
+ prCreateTitle: null,
148
166
  };
149
167
  case "SETUP_CONFIRM_SHOW":
150
168
  return {
@@ -20,6 +20,7 @@ export declare function spawnAsync(cmd: string, args: string[], options?: {
20
20
  cwd?: string;
21
21
  env?: NodeJS.ProcessEnv;
22
22
  onOutput?: (data: string) => void;
23
+ stdin?: string;
23
24
  }): Promise<{
24
25
  code: number;
25
26
  output: string;
package/dist/lib/exec.js CHANGED
@@ -33,8 +33,12 @@ export function spawnAsync(cmd, args, options) {
33
33
  const child = spawn(cmd, args, {
34
34
  cwd: options?.cwd,
35
35
  env: options?.env,
36
- stdio: "pipe",
36
+ stdio: [options?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
37
37
  });
38
+ if (options?.stdin !== undefined) {
39
+ child.stdin.write(options.stdin);
40
+ child.stdin.end();
41
+ }
38
42
  let output = "";
39
43
  child.stdout?.on("data", (data) => {
40
44
  output += data.toString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",