santree 0.5.2 → 0.5.4

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 (67) hide show
  1. package/README.md +145 -52
  2. package/dist/commands/dashboard.d.ts +1 -1
  3. package/dist/commands/dashboard.js +95 -27
  4. package/dist/commands/doctor.js +33 -71
  5. package/dist/commands/github/auth.d.ts +2 -0
  6. package/dist/commands/github/auth.js +56 -0
  7. package/dist/commands/github/index.d.ts +1 -0
  8. package/dist/commands/github/index.js +1 -0
  9. package/dist/commands/helpers/template.d.ts +1 -0
  10. package/dist/commands/helpers/template.js +13 -10
  11. package/dist/commands/issue/index.d.ts +1 -0
  12. package/dist/commands/issue/index.js +1 -0
  13. package/dist/commands/issue/open.d.ts +2 -0
  14. package/dist/commands/{linear → issue}/open.js +13 -11
  15. package/dist/commands/issue/switch.d.ts +11 -0
  16. package/dist/commands/issue/switch.js +38 -0
  17. package/dist/commands/linear/auth.js +23 -10
  18. package/dist/commands/linear/switch.js +7 -3
  19. package/dist/commands/pr/create.js +7 -5
  20. package/dist/commands/worktree/create.js +4 -6
  21. package/dist/commands/worktree/work.js +1 -1
  22. package/dist/lib/ai.d.ts +8 -6
  23. package/dist/lib/ai.js +29 -15
  24. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  25. package/dist/lib/dashboard/DetailPanel.js +6 -3
  26. package/dist/lib/dashboard/DiffOverlay.js +8 -1
  27. package/dist/lib/dashboard/data.js +17 -9
  28. package/dist/lib/dashboard/types.d.ts +3 -16
  29. package/dist/lib/git.d.ts +16 -33
  30. package/dist/lib/git.js +20 -74
  31. package/dist/lib/metadata.d.ts +3 -0
  32. package/dist/lib/metadata.js +27 -0
  33. package/dist/lib/multiplexer/cmux.js +1 -1
  34. package/dist/lib/multiplexer/types.d.ts +1 -1
  35. package/dist/lib/prompts.d.ts +4 -3
  36. package/dist/lib/prompts.js +4 -3
  37. package/dist/lib/session-signal.d.ts +2 -3
  38. package/dist/lib/session-signal.js +3 -29
  39. package/dist/lib/trackers/auth-store.d.ts +16 -0
  40. package/dist/lib/trackers/auth-store.js +57 -0
  41. package/dist/lib/trackers/config.d.ts +8 -0
  42. package/dist/lib/trackers/config.js +21 -0
  43. package/dist/lib/trackers/github/api.d.ts +3 -0
  44. package/dist/lib/trackers/github/api.js +90 -0
  45. package/dist/lib/trackers/github/auth.d.ts +5 -0
  46. package/dist/lib/trackers/github/auth.js +27 -0
  47. package/dist/lib/trackers/github/images.d.ts +2 -0
  48. package/dist/lib/trackers/github/images.js +42 -0
  49. package/dist/lib/trackers/github/index.d.ts +2 -0
  50. package/dist/lib/trackers/github/index.js +78 -0
  51. package/dist/lib/trackers/index.d.ts +12 -0
  52. package/dist/lib/trackers/index.js +34 -0
  53. package/dist/lib/trackers/linear/api.d.ts +4 -0
  54. package/dist/lib/trackers/linear/api.js +128 -0
  55. package/dist/lib/trackers/linear/auth.d.ts +11 -0
  56. package/dist/lib/trackers/linear/auth.js +206 -0
  57. package/dist/lib/trackers/linear/images.d.ts +2 -0
  58. package/dist/lib/trackers/linear/images.js +44 -0
  59. package/dist/lib/trackers/linear/index.d.ts +3 -0
  60. package/dist/lib/trackers/linear/index.js +100 -0
  61. package/dist/lib/trackers/types.d.ts +52 -0
  62. package/dist/lib/trackers/types.js +1 -0
  63. package/package.json +1 -1
  64. package/prompts/ticket.njk +3 -3
  65. package/dist/commands/linear/open.d.ts +0 -2
  66. package/dist/lib/linear.d.ts +0 -83
  67. package/dist/lib/linear.js +0 -482
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  <p align="center">
18
18
  Create, switch, and manage Git worktrees with ease.<br/>
19
- Integrates with GitHub PRs and Linear tickets via Claude AI.
19
+ Integrates with GitHub PRs and pluggable issue trackers (Linear, GitHub Issues) via Claude AI.
20
20
  </p>
21
21
 
22
22
  ---
@@ -112,13 +112,29 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
112
112
  | `santree pr fix` | Fix PR review comments with AI |
113
113
  | `santree pr review` | Review changes against ticket with AI |
114
114
 
115
- ### Linear (`santree linear`)
115
+ ### Issue trackers
116
+
117
+ Santree supports Linear and GitHub Issues behind a single interface. Each repo picks one. Use the generic `santree issue` commands for tracker-agnostic actions; use `santree linear` / `santree github` for backend-specific auth.
118
+
119
+ #### Generic — `santree issue`
120
+
121
+ | Command | Description |
122
+ | -------------------------------------- | ------------------------------------------------------------ |
123
+ | `santree issue switch <linear\|github>` | Pick the active tracker for this repo |
124
+ | `santree issue open` | Open the current branch's issue in the browser |
125
+
126
+ #### Linear (`santree linear`)
116
127
 
117
128
  | Command | Description |
118
129
  | ----------------------- | --------------------------------------------- |
119
130
  | `santree linear auth` | Authenticate with Linear (OAuth) |
120
131
  | `santree linear switch` | Switch Linear workspace for this repo |
121
- | `santree linear open` | Open the current Linear ticket in the browser |
132
+
133
+ #### GitHub Issues (`santree github`)
134
+
135
+ | Command | Description |
136
+ | --------------------- | ---------------------------------------------------------------------- |
137
+ | `santree github auth` | Verify `gh auth status`, run `gh auth login` if needed, set tracker=github |
122
138
 
123
139
  ### Helpers (`santree helpers`)
124
140
 
@@ -137,16 +153,75 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
137
153
 
138
154
  | Command | Description |
139
155
  | ------------------- | ----------------------------------------------- |
140
- | `santree dashboard` | Interactive dashboard of all your Linear issues |
156
+ | `santree dashboard` | Interactive dashboard of all your assigned issues |
141
157
  | `santree doctor` | Check system requirements and integrations |
142
158
 
143
159
  ---
144
160
 
145
161
  ## Features
146
162
 
163
+ ### Workflow
164
+
165
+ End-to-end flow, from picking an issue to merging the PR. Everything runs inside the terminal multiplexer; the dashboard orchestrates and the AI agent does the heavy lifting at two distinct stages.
166
+
167
+ ```mermaid
168
+ flowchart TB
169
+ subgraph Term["Terminal · tmux / cmux"]
170
+ direction TB
171
+ Dashboard(["① Dashboard<br/>pick issue · add context"]):::action
172
+ Editor["Editor<br/><sub>zed · cursor · vscode · nvim · jetbrains</sub>"]:::tool
173
+ AI(("② / ⑤ AI agent<br/>Claude Code")):::agent
174
+ Pager["③ Diff pager<br/><sub>delta · diff-so-fancy · built-in</sub>"]:::tool
175
+ Decision{good?}:::decision
176
+ Ship(["④ git commit + PR"]):::action
177
+ GitHub[("GitHub<br/>PRs · CI")]:::external
178
+
179
+ Dashboard ==>|implement| AI
180
+ Dashboard -. write context .-> Editor
181
+ Editor -. context · hand-edit .-> AI
182
+ AI ==> Pager
183
+ Pager ==> Decision
184
+ Decision -->|hand-edit| Editor
185
+ Decision -->|resume| AI
186
+ Decision ==>|ship| Ship
187
+ Ship ==> GitHub
188
+ GitHub -->|reviews · CI| AI
189
+ AI -->|push fix| Ship
190
+ end
191
+
192
+ classDef action fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
193
+ classDef tool fill:#f3f4f6,stroke:#9ca3af,color:#374151
194
+ classDef agent fill:#ede9fe,stroke:#8b5cf6,stroke-width:2px,color:#4c1d95
195
+ classDef external fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#78350f
196
+ classDef decision fill:#fee2e2,stroke:#ef4444,stroke-width:2px,color:#7f1d1d
197
+ ```
198
+
199
+ Bold edges (`==>`) follow the happy path; thin edges (`-->`) are branches/loops; dotted edges (`-.->`) are optional editor side-trips. Colors group nodes by role: blue actions, purple agent, gray tools, yellow external service, red decision.
200
+
201
+ **Stages, in order:**
202
+
203
+ 1. **Pick + add context** — open the dashboard, browse assigned issues, pick one. The inline context box lets you add custom instructions to the prompt; `Ctrl+O` drops into your editor for longer prose.
204
+ 2. **AI agent (Claude)** — runs in the worktree's mux window with the rendered ticket + your context. Implements or plans.
205
+ 3. **Diff pager (review)** — `[v]` opens the diff overlay. From here you iterate: hand-edit in your editor, or resume the Claude session to keep going.
206
+ 4. **Ship** — once the diff looks right, `[C]` for inline commit + push, then `[c]` to create the PR (`--fill` runs Claude non-interactively against the PR template).
207
+ 5. **PR feedback loop** — `[f]` (fix) and `[r]` (self-review) re-launch the AI with PR comments + CI failures as input. Patches are pushed; CI re-runs; loop until merged.
208
+
209
+ **Supported tools per stage:**
210
+
211
+ | Stage | Supported today | Planned |
212
+ |---|---|---|
213
+ | **Issue tracker** (① Dashboard) | Linear, GitHub Issues | Jira, Shortcut, etc. |
214
+ | **AI agent** (②, ⑤) | Claude Code (`claude` CLI) | OpenAI Codex, OpenCode, Cursor agent |
215
+ | **Diff pager** (③) | delta, diff-so-fancy, any unified-diff pager — built-in colorizer when none set | — |
216
+ | **Editor** (side branch) | Anything taking a path: zed, nvim, jetbrains, cursor, vscode. cursor + vscode also handle `.code-workspace` files via `[E]` workspace shortcut | — |
217
+ | **Forge** (④) | GitHub via `gh` CLI | GitLab, Bitbucket, Gitea |
218
+ | **Terminal multiplexer** (outer frame) | tmux, cmux *(macOS-only — see [#1472](https://github.com/manaflow-ai/cmux/issues/1472))*, none | zellij |
219
+
220
+ The AI agent and issue tracker are already behind interfaces (`lib/trackers/`, plus the `claude` CLI shim in `lib/ai.ts`). Adding a new option in either category means writing one new module and wiring it into the factory — no changes to UI/dashboard code.
221
+
147
222
  ### Interactive Dashboard
148
223
 
149
- `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.
224
+ `santree dashboard` opens a full-screen TUI to manage all your work in one place. It shows your assigned issues from the active tracker (Linear or GitHub Issues) grouped by project, with live status for worktrees, PRs, CI checks, and reviews.
150
225
 
151
226
  **Left pane** — issue list. Issues without worktrees show as a single row (priority + ID + title). Issues with worktrees expand into nested sub-rows below the title showing `· diff` (files/adds/deletes/commits-ahead), `· pr` (number, state, CI, review count), and `· session` (state + Claude session ID). Click any row (main or sub) to select; scroll wheel navigates; drag the divider to resize panes.
152
227
 
@@ -162,7 +237,7 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
162
237
  | `c` | Create PR (fill from commits or open in browser) |
163
238
  | `v` | Inline diff overlay — file tree + diff content (mouse + keyboard) |
164
239
  | `f` / `r` | Fix PR / Review PR (launches in tmux) |
165
- | `o` / `p` | Open Linear ticket / PR in browser |
240
+ | `o` / `p` | Open issue (in Linear or GitHub) / PR in browser |
166
241
  | `d` | Remove worktree |
167
242
 
168
243
  Commit, PR creation, and diff review happen inline without leaving the dashboard. Work, fix, and review open in new tmux windows.
@@ -177,9 +252,9 @@ Create isolated worktrees for each feature branch. No more stashing or committin
177
252
 
178
253
  See PR status directly in your worktree list. Clean up worktrees automatically when PRs are merged or closed.
179
254
 
180
- ### Linear Integration
255
+ ### Issue Tracker Integration
181
256
 
182
- 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.
257
+ Santree fetches issue data (title, description, comments, images) from the active tracker — Linear or GitHub Issues — and injects it into prompts when running `santree worktree work`. See [Issue Tracker Integration](#issue-tracker-integration-1) for setup.
183
258
 
184
259
  ### Claude AI Integration
185
260
 
@@ -210,16 +285,46 @@ npm install
210
285
 
211
286
  ### Branch Naming
212
287
 
213
- For Linear integration, use branch names with ticket IDs:
288
+ Branch names need to encode the issue ID so santree can link the worktree back to the right ticket. The accepted format depends on the active tracker:
214
289
 
215
290
  ```
291
+ # Linear (uppercased letter prefix + dash + digits)
216
292
  user/TEAM-123-feature-description
217
293
  feature/PROJ-456-add-auth
294
+
295
+ # GitHub Issues (explicit prefix required, to avoid `fix-typo-1` matching)
296
+ feature/issue-42-add-auth
297
+ gh-42-add-auth
298
+ 42-add-auth
299
+ ```
300
+
301
+ GitHub's parser is strict on purpose: a commit-style branch like `fix-typo-1` will *not* match (no explicit prefix, no slash-led number).
302
+
303
+ ### Issue Tracker Integration
304
+
305
+ Each repo picks one tracker. The active tracker is resolved in this order: `SANTREE_TRACKER` env var → per-repo `_tracker.kind` (in `.santree/metadata.json`) → legacy `_linear.org` (treated as Linear) → auto-detect (any Linear creds → Linear, else GitHub).
306
+
307
+ #### Choosing a tracker
308
+
309
+ ```bash
310
+ # Explicitly pick the active tracker for this repo
311
+ santree issue switch linear
312
+ santree issue switch github
313
+
314
+ # Or let an auth command set it as a side effect:
315
+ santree linear auth # Sets _tracker.kind = "linear" after OAuth
316
+ santree github auth # Sets _tracker.kind = "github" after `gh auth login`
317
+ ```
318
+
319
+ For a one-off override (testing, scripting):
320
+
321
+ ```bash
322
+ SANTREE_TRACKER=github santree dashboard
218
323
  ```
219
324
 
220
- ### Linear Integration
325
+ #### Linear
221
326
 
222
- Santree fetches Linear ticket data (title, description, comments, images) and injects it into prompts when running `santree worktree work`.
327
+ Santree fetches Linear ticket data via the GraphQL API (OAuth PKCE).
223
328
 
224
329
  ```bash
225
330
  # Authenticate with Linear (opens browser for OAuth)
@@ -233,12 +338,26 @@ santree linear auth --test TEAM-123
233
338
 
234
339
  # Log out
235
340
  santree linear auth --logout
341
+
342
+ # Switch between authenticated workspaces
343
+ santree linear switch
236
344
  ```
237
345
 
238
346
  On first run, `santree linear auth` opens your browser to authorize the app with your Linear workspace. Tokens are stored in `$XDG_CONFIG_HOME/santree/auth.json` (defaults to `~/.config/santree/auth.json`) and auto-refresh transparently.
239
347
 
240
348
  If you have multiple workspaces authenticated, running `santree linear auth` in a new repo will let you pick which one to link. Images from tickets are downloaded to a temp directory and cleaned up after Claude exits.
241
349
 
350
+ #### GitHub Issues
351
+
352
+ Santree uses the existing `gh` CLI — no separate OAuth flow.
353
+
354
+ ```bash
355
+ # Verify gh is authenticated; flips this repo's tracker to GitHub
356
+ santree github auth
357
+ ```
358
+
359
+ Issues are listed via `gh search issues --assignee=@me --state=open --repo <owner>/<name>` (current repo only — cross-repo issues aren't surfaced today). Priority is derived from labels matching `P0`/`P1`/`P2`/`P3`/`urgent`/`critical`/`high`/`medium`/`low`, falling back to `No priority`. Attached images on `user-images.githubusercontent.com` and `github.com/.../assets/` are downloaded so Claude can read them when filling PR templates.
360
+
242
361
  ### Claude Code Statusline (Optional)
243
362
 
244
363
  Santree provides a custom statusline for Claude Code showing git info, model, context usage, and cost.
@@ -286,45 +405,11 @@ To preview the JSON without writing: `santree helpers session-signal install --d
286
405
 
287
406
  Verify with `santree doctor` — look for the "Session Signal Hooks" row under Claude Code.
288
407
 
289
- ### Session Hooks (Optional)
290
-
291
- Run custom scripts when Claude's session state changes. Create executable scripts in `.santree/hooks/`:
292
-
293
- ```
294
- .santree/hooks/
295
- on-waiting.sh # Runs when session needs permission approval
296
- on-active.sh # Runs when user submits a new prompt
297
- on-idle.sh # Runs when session finishes and waits for next prompt
298
- on-exited.sh # Runs when session ends
299
- ```
300
-
301
- Each script receives these environment variables:
302
-
303
- | Variable | Description |
304
- |----------|-------------|
305
- | `SANTREE_TICKET_ID` | e.g. `TEAM-123` |
306
- | `SANTREE_SESSION_STATE` | `waiting`, `active`, `idle`, or `exited` |
307
- | `SANTREE_SESSION_ID` | The Claude session ID |
308
- | `SANTREE_WORKTREE_PATH` | Absolute path to the worktree |
309
- | `SANTREE_REPO_ROOT` | Absolute path to the main repo |
310
- | `SANTREE_MESSAGE` | Notification message (only for `waiting`) |
311
-
312
- Scripts are optional — only executed if they exist and are executable. They run fire-and-forget with a 5-second timeout.
313
-
314
- **Example** — log when Claude is waiting for approval:
315
-
316
- ```bash
317
- #!/bin/bash
318
- # .santree/hooks/on-waiting.sh
319
- echo "$(date): $SANTREE_TICKET_ID waiting — $SANTREE_MESSAGE" >> /tmp/santree-hooks.log
320
- ```
321
-
322
- Make it executable: `chmod +x .santree/hooks/on-waiting.sh`
323
-
324
408
  ### Environment Variables
325
409
 
326
410
  | Variable | Effect |
327
411
  |---|---|
412
+ | `SANTREE_TRACKER` | Override the active issue tracker for a single invocation: `linear` or `github`. Takes precedence over the per-repo `_tracker.kind`. If unset, falls back to repo config → legacy `_linear.org` → auto-detect. |
328
413
  | `SANTREE_EDITOR` | Editor used by `e` (open in editor) actions in the dashboard. Defaults to `code`. Examples: `cursor`, `zed`, `code`, `nvim`. |
329
414
  | `SANTREE_MULTIPLEXER` | Terminal multiplexer used by the dashboard and `worktree create --window`. One of `tmux`, `cmux`, `none`. If unset, auto-detects from `$TMUX` / `$CMUX_SURFACE_ID`. cmux is macOS-only and limited by [manaflow-ai/cmux#1472](https://github.com/manaflow-ai/cmux/issues/1472). |
330
415
  | `SANTREE_DIFF_TOOL` | Pager used by `worktree diff` (CLI) and the dashboard diff overlay. Passed to git as `-c core.pager=<tool>` for the CLI, and used to pipe content for the overlay. Examples: `delta`, `diff-so-fancy`. Must accept a unified diff on stdin. Names are restricted to `[A-Za-z0-9_\-/.+]`. |
@@ -384,7 +469,7 @@ Shows a branch-only unified diff against the base branch's merge-base — same s
384
469
  | -------- | ------------------------------- |
385
470
  | `--plan` | Only create implementation plan |
386
471
 
387
- Automatically fetches Linear ticket data if authenticated. Degrades gracefully if not.
472
+ Automatically fetches issue data from the active tracker (Linear or GitHub Issues) if authenticated. Degrades gracefully if not.
388
473
 
389
474
  ### pr create
390
475
 
@@ -426,16 +511,16 @@ Santree currently locks in to specific providers for some integrations and is in
426
511
 
427
512
  | Area | Supported | Not yet supported |
428
513
  | --- | --- | --- |
429
- | **Ticket system** | Linear (via `santree linear auth`) | Jira, GitHub Issues, Shortcut, Asana, Linear-equivalents |
430
514
  | **Source control / forge** | GitHub (via `gh` CLI) | GitLab, Bitbucket, Gitea, Codeberg, self-hosted Forgejo |
431
515
  | **Coding agent** | Claude Code (`claude` CLI) | OpenAI Codex, OpenCode, Cursor agent, Aider, others |
432
516
 
433
- These are hardwired in `lib/linear.ts`, `lib/github.ts`, and `lib/ai.ts` respectively. Adding a second provider means abstracting an interface (similar to `lib/multiplexer/`) and wiring a selection mechanism.
517
+ These are hardwired in `lib/github.ts` and `lib/ai.ts` respectively. Adding a second provider means abstracting an interface (similar to `lib/trackers/` or `lib/multiplexer/`) and wiring a selection mechanism.
434
518
 
435
519
  ### Multi-provider (already interchangeable)
436
520
 
437
521
  | Area | How to switch | Examples |
438
522
  | --- | --- | --- |
523
+ | **Issue tracker** | `santree issue switch <linear\|github>` (or `SANTREE_TRACKER` env var, or as a side effect of `santree linear auth` / `santree github auth`) | Linear (OAuth + GraphQL), GitHub Issues (via `gh` CLI). Adding a third tracker = one new directory under `lib/trackers/`. |
439
524
  | **Editor** | `SANTREE_EDITOR` env var (or `--editor` flag on `worktree open`) | `code`, `cursor`, `zed`, `nvim`, `subl`, `webstorm` — any executable that takes a path argument |
440
525
  | **Terminal multiplexer** | `SANTREE_MULTIPLEXER` env var | `tmux` (default, all platforms), `cmux` (macOS only — see [#1472](https://github.com/manaflow-ai/cmux/issues/1472)), `none`. Zellij is planned but not implemented. |
441
526
  | **Diff renderer** | `SANTREE_DIFF_TOOL` env var | `delta`, `diff-so-fancy`, or any pager that accepts a unified diff. Falls back to plain `git diff` colorization when unset. |
@@ -502,11 +587,17 @@ source/
502
587
  ├── cli.tsx # Entry point (Pastel app runner)
503
588
  ├── lib/
504
589
  │ ├── ai.ts # Shared AI logic (context, prompt, launch)
505
- │ ├── git.ts # Git helpers (worktrees, branches, metadata)
590
+ │ ├── git.ts # Git helpers (worktrees, branches); extractTicketId is a tracker shim
506
591
  │ ├── github.ts # GitHub CLI wrapper (PR info, auth, push, checks, reviews)
507
- │ ├── linear.ts # Linear GraphQL API client (OAuth, tickets, images)
508
592
  │ ├── exec.ts # Shell command helpers
593
+ │ ├── metadata.ts # .santree/metadata.json r/w (extracted to break import cycles)
509
594
  │ ├── prompts.ts # Nunjucks template renderer
595
+ │ ├── trackers/ # Issue tracker abstraction (Linear, GitHub Issues)
596
+ │ │ ├── types.ts # IssueTracker interface + generic Issue/AssignedIssue types
597
+ │ │ ├── index.ts # getIssueTracker(repoRoot) factory
598
+ │ │ ├── linear/ # OAuth PKCE + GraphQL + image rewriter
599
+ │ │ └── github/ # `gh` CLI wrappers; priority derived from labels
600
+ │ ├── multiplexer/ # tmux/cmux/none abstraction (windows/sessions)
510
601
  │ └── dashboard/ # Dashboard UI components
511
602
  │ ├── types.ts # State types, action types, phase enums
512
603
  │ ├── IssueList.tsx # Left pane — issue list with priority, session, PR, CI columns
@@ -516,7 +607,9 @@ source/
516
607
  ├── dashboard.tsx # Top-level: interactive dashboard
517
608
  ├── worktree/ # Worktree management (create, list, switch, etc.)
518
609
  ├── pr/ # PR lifecycle (create, open, fix, review)
519
- ├── linear/ # Linear integration (auth, open)
610
+ ├── linear/ # Linear-specific OAuth (auth, switch)
611
+ ├── github/ # GitHub-specific auth (gh wrapper)
612
+ ├── issue/ # Tracker-agnostic actions (switch, open)
520
613
  └── helpers/ # Shell init, statusline
521
614
  prompts/ # Nunjucks templates: implement, plan, review, fix-pr, fill-pr, ticket
522
615
  shell/ # Shell integration templates: init.zsh.njk, init.bash.njk
@@ -1,2 +1,2 @@
1
- export declare const description = "Interactive dashboard of your Linear issues";
1
+ export declare const description = "Interactive dashboard of your assigned issues";
2
2
  export default function Dashboard(): import("react/jsx-runtime").JSX.Element;
@@ -17,7 +17,7 @@ import { extractTicketId } from "../lib/git.js";
17
17
  import { getMultiplexer } from "../lib/multiplexer/index.js";
18
18
  import { getPRTemplate } from "../lib/github.js";
19
19
  import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
20
- import { getTicketContent } from "../lib/linear.js";
20
+ import { getIssueTracker } from "../lib/trackers/index.js";
21
21
  import * as os from "os";
22
22
  import { initialState, reducer } from "../lib/dashboard/types.js";
23
23
  import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
@@ -30,7 +30,7 @@ import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
30
30
  import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
31
31
  import DiffOverlay, { flattenTreeFiles, computeDiffLayout, clampDiffLeftWidth, DIFF_DIVIDER_WIDTH, } from "../lib/dashboard/DiffOverlay.js";
32
32
  import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getCachedLatestVersion, getLatestVersionFor, getCachedLatestVersionFor, isUpdateAvailable, } from "../lib/version.js";
33
- export const description = "Interactive dashboard of your Linear issues";
33
+ export const description = "Interactive dashboard of your assigned issues";
34
34
  const execAsync = promisify(exec);
35
35
  // Resolved at module load — cheap. Honors cmux's bundled binary when running
36
36
  // inside cmux so the header reflects the binary santree will actually use.
@@ -42,17 +42,74 @@ const CLAUDE_VERSION = getInstalledClaudeVersion() ?? "";
42
42
  * R100\told/path\tnew/path
43
43
  * For renames/copies, the status code has a similarity suffix we strip.
44
44
  */
45
+ /**
46
+ * Split combined-parameter SGR sequences (e.g. `\x1b[48;2;R;G;B;38;2;R;G;B m`)
47
+ * into separate single-attribute SGRs (`\x1b[48;2;...m\x1b[38;2;...m`).
48
+ *
49
+ * Why: Ink uses `slice-ansi` to clip text horizontally, and `slice-ansi`
50
+ * miscounts visible width on combined RGB bg+fg SGRs — it cuts the line at
51
+ * roughly half the requested visible width. Delta emits exactly this combined
52
+ * form on every styled token, so the diff pane was rendering content cut at
53
+ * arbitrary points (e.g. `from datetime i` instead of `from datetime import
54
+ * timedelta`). Splitting them sidesteps the slice-ansi bug without losing any
55
+ * styling — the terminal renders the two SGRs identically to the combined one.
56
+ */
57
+ function splitCombinedSgr(s) {
58
+ return s.replace(/\x1b\[([0-9;]+)m/g, (_match, params) => {
59
+ const tokens = params.split(";");
60
+ const groups = [];
61
+ for (let i = 0; i < tokens.length; i++) {
62
+ const t = tokens[i];
63
+ if ((t === "38" || t === "48") && tokens[i + 1] === "2") {
64
+ groups.push([t, "2", tokens[i + 2], tokens[i + 3], tokens[i + 4]].join(";"));
65
+ i += 4;
66
+ }
67
+ else if ((t === "38" || t === "48") && tokens[i + 1] === "5") {
68
+ groups.push([t, "5", tokens[i + 2]].join(";"));
69
+ i += 2;
70
+ }
71
+ else {
72
+ groups.push(t);
73
+ }
74
+ }
75
+ if (groups.length <= 1)
76
+ return `\x1b[${params}m`;
77
+ return groups.map((g) => `\x1b[${g}m`).join("");
78
+ });
79
+ }
45
80
  /**
46
81
  * Pipe `git diff` output through an external tool (e.g. delta) and return the
47
82
  * combined ANSI output. Uses spawn pipes — no shell — so the tool name is safe
48
83
  * even though we already validate it in getDiffTool().
49
84
  */
50
- function runPipedDiff(cwd, mergeBase, filePath, tool) {
85
+ function runPipedDiff(cwd, gitArgs, tool, themeMode) {
51
86
  return new Promise((resolve, reject) => {
52
- const git = spawn("git", ["-C", cwd, "diff", "--color=always", mergeBase, "--", filePath], {
87
+ const git = spawn("git", ["-C", cwd, ...gitArgs], {
53
88
  stdio: ["ignore", "pipe", "pipe"],
54
89
  });
55
- const pager = spawn(tool, [], { stdio: ["pipe", "pipe", "pipe"] });
90
+ // Delta's syntax theme defaults are tuned for dark backgrounds — pale
91
+ // Monokai foreground on a light terminal becomes invisible. Force the
92
+ // theme flag matching santree's detected mode so colors stay readable.
93
+ const pagerArgs = tool === "delta" ? [themeMode === "light" ? "--light" : "--dark"] : [];
94
+ // Disable hyperlinks for delta: OSC 8 sequences (`\x1b]8;...`) are not
95
+ // handled by truncateVisible() — its CSI-only regex counts the URL
96
+ // bytes as visible characters, mangling line truncation and breaking
97
+ // terminal rendering of the wrapped text. Delta's CLI rejects an
98
+ // inline `--hyperlinks=false`, so override via GIT_CONFIG_PARAMETERS
99
+ // (delta reads its config from git). Also drop line-numbers — they
100
+ // eat ~6 cols of an already-narrow right pane.
101
+ const pagerEnv = tool === "delta"
102
+ ? {
103
+ ...process.env,
104
+ GIT_CONFIG_PARAMETERS: "'delta.hyperlinks=false' 'delta.line-numbers=false'",
105
+ }
106
+ : process.env;
107
+ // We use the pager only for its rendering — the dashboard owns
108
+ // scrolling/search itself in Ink, so we capture stdout as a string.
109
+ const pager = spawn(tool, pagerArgs, {
110
+ stdio: ["pipe", "pipe", "pipe"],
111
+ env: pagerEnv,
112
+ });
56
113
  let out = "";
57
114
  let err = "";
58
115
  git.stdout.pipe(pager.stdin);
@@ -72,7 +129,7 @@ function runPipedDiff(cwd, mergeBase, filePath, tool) {
72
129
  reject(new Error(err || `${tool} exited with code ${code}`));
73
130
  }
74
131
  else {
75
- resolve(out);
132
+ resolve(splitCombinedSgr(out));
76
133
  }
77
134
  });
78
135
  });
@@ -664,16 +721,24 @@ export default function Dashboard() {
664
721
  if (file.isUntracked) {
665
722
  // Untracked files aren't in `git diff` output — fake a "full
666
723
  // addition" diff via --no-index against /dev/null. git exits 1
667
- // when files differ; that's expected, so capture stdout via
668
- // spawnAsync rather than execAsync (which throws on non-zero).
669
- const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
670
- dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
724
+ // when files differ; that's expected, so we capture stdout
725
+ // regardless. Pipe through the configured tool when set so
726
+ // untracked files get the same syntax highlighting as tracked
727
+ // ones; otherwise fall back to spawnAsync + manual colorize.
728
+ if (tool) {
729
+ const content = await runPipedDiff(cwd, ["diff", "--color=always", "--no-index", "--", "/dev/null", file.path], tool, theme.mode);
730
+ dispatch({ type: "DIFF_CONTENT_LOADED", content });
731
+ }
732
+ else {
733
+ const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
734
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
735
+ }
671
736
  }
672
737
  else if (tool) {
673
738
  // Pipe git diff (with colors enabled so the tool can pass them
674
739
  // through if desired) into the configured tool. Use spawn pipes
675
740
  // rather than shell to avoid quoting concerns.
676
- const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
741
+ const content = await runPipedDiff(cwd, ["diff", "--color=always", mergeBase, "--", file.path], tool, theme.mode);
677
742
  dispatch({ type: "DIFF_CONTENT_LOADED", content });
678
743
  }
679
744
  else {
@@ -693,6 +758,7 @@ export default function Dashboard() {
693
758
  state.diffMergeBase,
694
759
  state.diffFileIndex,
695
760
  state.diffFiles,
761
+ theme.mode,
696
762
  ]);
697
763
  // ── Actions ───────────────────────────────────────────────────────
698
764
  const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
@@ -733,13 +799,13 @@ export default function Dashboard() {
733
799
  type: "SET_ACTION_MESSAGE",
734
800
  message: resumeCmd
735
801
  ? `Resumed session in new window: ${windowName}`
736
- : `Launched ${mode} in ${mux.kind} window: ${windowName}`,
802
+ : `Launched ${mode} in new window: ${windowName}`,
737
803
  });
738
804
  }
739
805
  else {
740
806
  dispatch({
741
807
  type: "SET_ACTION_MESSAGE",
742
- message: `Failed to create ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
808
+ message: `Failed to create window${created.message ? `: ${created.message}` : ""}`,
743
809
  });
744
810
  }
745
811
  }
@@ -768,7 +834,7 @@ export default function Dashboard() {
768
834
  else {
769
835
  dispatch({
770
836
  type: "SET_ACTION_MESSAGE",
771
- message: `Worktree created, but ${mux.kind} failed${created.message ? `: ${created.message}` : ""}`,
837
+ message: `Worktree created, but window launch failed${created.message ? `: ${created.message}` : ""}`,
772
838
  });
773
839
  }
774
840
  setTimeout(() => refresh(), 3000);
@@ -1097,12 +1163,14 @@ export default function Dashboard() {
1097
1163
  dispatch({ type: "PR_CREATE_PHASE", phase: "filling" });
1098
1164
  const ticketId = extractTicketId(s.prCreateBranch) ?? "";
1099
1165
  const mainRepoRoot = findMainRepoRoot();
1100
- // Fetch ticket content (downloads images for Linear tickets)
1166
+ // Fetch issue content from the active tracker (downloads images
1167
+ // inline so Claude can read them via --allowedTools Read).
1101
1168
  let ticketContent;
1102
1169
  if (ticketId && mainRepoRoot) {
1103
- const ticket = await getTicketContent(ticketId, mainRepoRoot);
1104
- if (ticket) {
1105
- ticketContent = renderTicket(ticket);
1170
+ const tracker = getIssueTracker(mainRepoRoot);
1171
+ const result = await tracker.getIssue(ticketId, mainRepoRoot);
1172
+ if (result.ok) {
1173
+ ticketContent = renderTicket(result.value, tracker.displayName);
1106
1174
  }
1107
1175
  }
1108
1176
  const commitLog = run(`git log ${base}..HEAD --format="- %s"`, { cwd }) || null;
@@ -1759,7 +1827,7 @@ export default function Dashboard() {
1759
1827
  dispatch({
1760
1828
  type: "SET_ACTION_MESSAGE",
1761
1829
  message: created.ok
1762
- ? `Launched AI review in ${mux.kind}`
1830
+ ? "Launched AI review in new window"
1763
1831
  : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1764
1832
  });
1765
1833
  })();
@@ -1864,7 +1932,7 @@ export default function Dashboard() {
1864
1932
  if (!created.ok) {
1865
1933
  dispatch({
1866
1934
  type: "SET_ACTION_MESSAGE",
1867
- message: `Failed to switch ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
1935
+ message: `Failed to switch window${created.message ? `: ${created.message}` : ""}`,
1868
1936
  });
1869
1937
  }
1870
1938
  })();
@@ -1876,10 +1944,10 @@ export default function Dashboard() {
1876
1944
  }
1877
1945
  return;
1878
1946
  }
1879
- // Open in Linear
1947
+ // Open issue in tracker (Linear/GitHub web UI)
1880
1948
  if (input === "o") {
1881
1949
  if (!di.issue.url) {
1882
- dispatch({ type: "SET_ACTION_MESSAGE", message: "No Linear ticket URL" });
1950
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
1883
1951
  return;
1884
1952
  }
1885
1953
  const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
@@ -1935,7 +2003,7 @@ export default function Dashboard() {
1935
2003
  dispatch({
1936
2004
  type: "SET_ACTION_MESSAGE",
1937
2005
  message: created.ok
1938
- ? `Launched review in ${mux.kind}`
2006
+ ? "Launched review in new window"
1939
2007
  : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1940
2008
  });
1941
2009
  })();
@@ -2002,7 +2070,7 @@ export default function Dashboard() {
2002
2070
  dispatch({
2003
2071
  type: "SET_ACTION_MESSAGE",
2004
2072
  message: created.ok
2005
- ? `Launched PR fix in ${mux.kind}`
2073
+ ? "Launched PR fix in new window"
2006
2074
  : `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
2007
2075
  });
2008
2076
  })();
@@ -2063,14 +2131,14 @@ export default function Dashboard() {
2063
2131
  }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined, pendingDiscard: state.diffPendingDiscard })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
2064
2132
  .split("\n")
2065
2133
  .slice(-(contentHeight - 1))
2066
- .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })) })] })] }));
2134
+ .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: getIssueTracker(repoRootRef.current).displayName }) })] })) })] })] }));
2067
2135
  }
2068
2136
  /**
2069
2137
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
2070
2138
  * lifted out of the detail panels so they sit on the same row as the global
2071
2139
  * command bar. Empty when nothing is selected.
2072
2140
  */
2073
- function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, }) {
2141
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, }) {
2074
2142
  // During the diff overlay, none of the per-issue actions apply (View diff
2075
2143
  // is what got us here, Commit/PR/etc. need the detail panel context). Keep
2076
2144
  // the row blank so the diff-specific CommandBar reads cleanly.
@@ -2081,7 +2149,7 @@ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, }) {
2081
2149
  ? buildReviewActions(selectedReview)
2082
2150
  : []
2083
2151
  : selectedIssue
2084
- ? buildIssueActions(selectedIssue)
2152
+ ? buildIssueActions(selectedIssue, trackerName)
2085
2153
  : [];
2086
2154
  if (items.length === 0)
2087
2155
  return _jsx(Text, { children: " " });