santree 0.1.5 → 0.2.1

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.
@@ -0,0 +1,945 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useCallback, useRef, useState } from "react";
3
+ import { Text, Box, useInput, useStdout, useApp } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { exec, execSync, spawn } from "child_process";
6
+ import { promisify } from "util";
7
+ import { createRequire } from "module";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ const require = createRequire(import.meta.url);
11
+ const { version } = require("../../package.json");
12
+ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, } from "../lib/git.js";
13
+ import { spawnAsync } from "../lib/exec.js";
14
+ import { resolveAgentBinary } from "../lib/ai.js";
15
+ import { initialState, reducer } from "../lib/dashboard/types.js";
16
+ import { loadDashboardData } from "../lib/dashboard/data.js";
17
+ import IssueList from "../lib/dashboard/IssueList.js";
18
+ import DetailPanel from "../lib/dashboard/DetailPanel.js";
19
+ import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
20
+ export const description = "Interactive dashboard of your Linear issues";
21
+ const execAsync = promisify(exec);
22
+ // ── Helpers ───────────────────────────────────────────────────────────
23
+ function isInTmux() {
24
+ return !!process.env.TMUX;
25
+ }
26
+ function slugify(title) {
27
+ return title
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-|-$/g, "")
31
+ .slice(0, 40);
32
+ }
33
+ // ── Scroll helpers ────────────────────────────────────────────────────
34
+ function getRowIndexForFlatIndex(groups, flatIndex) {
35
+ let row = 1; // skip column header row
36
+ let issuesSeen = 0;
37
+ for (const g of groups) {
38
+ row++; // project header
39
+ for (const sg of g.statusGroups) {
40
+ row++; // status header
41
+ for (let i = 0; i < sg.issues.length; i++) {
42
+ if (issuesSeen === flatIndex)
43
+ return row;
44
+ row++;
45
+ issuesSeen++;
46
+ }
47
+ }
48
+ }
49
+ return 0;
50
+ }
51
+ function getFlatIndexForListRow(groups, listRow) {
52
+ if (listRow === 0)
53
+ return null; // column header row
54
+ let row = 1; // skip column header row
55
+ let issuesSeen = 0;
56
+ for (const g of groups) {
57
+ if (row === listRow)
58
+ return null; // project header row
59
+ row++;
60
+ for (const sg of g.statusGroups) {
61
+ if (row === listRow)
62
+ return null; // status header row
63
+ row++;
64
+ for (let i = 0; i < sg.issues.length; i++) {
65
+ if (row === listRow)
66
+ return issuesSeen;
67
+ row++;
68
+ issuesSeen++;
69
+ }
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ // ── Terminal escape sequences ─────────────────────────────────────────
75
+ //
76
+ // We control the terminal by writing ANSI escape sequences to stdout.
77
+ // These are special byte strings that terminals interpret as commands
78
+ // rather than displayable text.
79
+ //
80
+ // Format: \x1b[ starts a "CSI" (Control Sequence Introducer).
81
+ // \x1b is the ESC character (hex 0x1B, decimal 27).
82
+ // The `[` after ESC begins a CSI sequence.
83
+ // `?` marks a "private mode" (DEC-specific terminal feature).
84
+ // The number identifies which feature, and the letter at the end
85
+ // is the action: `h` = enable (high), `l` = disable (low).
86
+ //
87
+ // Sequences used:
88
+ // \x1b[?1049h / l — Enter/leave alternate screen buffer.
89
+ // The alt screen is a separate drawing area (like vim
90
+ // or less use). When you leave, the original terminal
91
+ // content is restored as if nothing happened.
92
+ // \x1b[?25h / l — Show/hide the text cursor.
93
+ // \x1b[?1002h / l — Enable/disable button-event mouse tracking.
94
+ // The terminal sends mouse press, release, drag, and
95
+ // scroll events as input sequences we can parse.
96
+ // \x1b[?1006h / l — Enable/disable SGR (Select Graphic Rendition)
97
+ // extended mouse format. Without this, mouse reporting
98
+ // breaks beyond column/row 223. SGR encodes events as
99
+ // \x1b[<button;col;row M/m (M=press, m=release).
100
+ // Must run before Ink renders the first frame to avoid leaking output
101
+ // to the main terminal buffer.
102
+ let altScreenEntered = false;
103
+ function ensureAltScreen() {
104
+ if (altScreenEntered)
105
+ return;
106
+ altScreenEntered = true;
107
+ if (isInTmux()) {
108
+ try {
109
+ execSync('tmux rename-window "santree"', { stdio: "ignore" });
110
+ }
111
+ catch { }
112
+ }
113
+ process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
114
+ process.stdout.write("\x1b[?25l"); // Hide cursor
115
+ }
116
+ /** Leave alternate screen and restore cursor — used when exiting to shell */
117
+ function leaveAltScreen() {
118
+ process.stdout.write("\x1b[?1049l"); // Leave alternate screen buffer
119
+ process.stdout.write("\x1b[?25h"); // Show cursor
120
+ }
121
+ // ── Component ─────────────────────────────────────────────────────────
122
+ export default function Dashboard() {
123
+ ensureAltScreen();
124
+ const { exit } = useApp();
125
+ const { stdout } = useStdout();
126
+ const [state, dispatch] = useReducer(reducer, initialState);
127
+ const refreshTimerRef = useRef(null);
128
+ const repoRootRef = useRef(null);
129
+ const stateRef = useRef(state);
130
+ stateRef.current = state;
131
+ const draggingRef = useRef(false);
132
+ const [termSize, setTermSize] = useState({
133
+ columns: stdout?.columns ?? 80,
134
+ rows: stdout?.rows ?? 24,
135
+ });
136
+ useEffect(() => {
137
+ const onResize = () => {
138
+ setTermSize({
139
+ columns: stdout?.columns ?? 80,
140
+ rows: stdout?.rows ?? 24,
141
+ });
142
+ };
143
+ stdout?.on("resize", onResize);
144
+ return () => {
145
+ stdout?.off("resize", onResize);
146
+ };
147
+ }, [stdout]);
148
+ const { columns, rows } = termSize;
149
+ const separatorWidth = 3;
150
+ const [leftWidth, setLeftWidth] = useState(Math.floor(columns * 0.42));
151
+ const leftWidthRef = useRef(leftWidth);
152
+ leftWidthRef.current = leftWidth;
153
+ const rightWidth = columns - leftWidth - separatorWidth;
154
+ const contentHeight = rows - 1; // 1 header
155
+ const LIST_FOOTER_HEIGHT = 2;
156
+ // ── Data loading ──────────────────────────────────────────────────
157
+ const refresh = useCallback(async (isInitial = false) => {
158
+ if (!isInitial)
159
+ dispatch({ type: "REFRESH_START" });
160
+ const repoRoot = repoRootRef.current ?? findMainRepoRoot();
161
+ if (!repoRoot) {
162
+ dispatch({ type: "SET_ERROR", error: "Not inside a git repository" });
163
+ return;
164
+ }
165
+ repoRootRef.current = repoRoot;
166
+ try {
167
+ const data = await loadDashboardData(repoRoot);
168
+ dispatch({ type: "SET_DATA", ...data });
169
+ }
170
+ catch (e) {
171
+ dispatch({
172
+ type: "SET_ERROR",
173
+ error: e instanceof Error ? e.message : "Unknown error",
174
+ });
175
+ }
176
+ }, []);
177
+ useEffect(() => {
178
+ // Enable button-event mouse tracking (?1002h) with SGR extended format (?1006h)
179
+ // This reports press, release, drag, and scroll wheel events
180
+ process.stdout.write("\x1b[?1002h\x1b[?1006h");
181
+ // Mouse handler on raw stdin — handles click-to-select and drag-to-resize
182
+ const onData = (data) => {
183
+ const str = data.toString("utf-8");
184
+ // SGR mouse format: \x1b[<button;col;rowM (press/drag) or ...m (release)
185
+ const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
186
+ if (!match)
187
+ return;
188
+ const button = parseInt(match[1], 10);
189
+ const col = parseInt(match[2], 10); // 1-based
190
+ const row = parseInt(match[3], 10); // 1-based
191
+ const isRelease = match[4] === "m";
192
+ const isPress = match[4] === "M" && button === 0;
193
+ const isDrag = match[4] === "M" && button === 32;
194
+ const cols = stdout?.columns ?? 80;
195
+ const minW = 20;
196
+ const sepW = 3;
197
+ // Release — stop dragging
198
+ if (isRelease && draggingRef.current) {
199
+ draggingRef.current = false;
200
+ return;
201
+ }
202
+ // Drag — resize if actively dragging
203
+ if (isDrag && draggingRef.current) {
204
+ // col is 1-based; place divider center at mouse position
205
+ const newLeft = Math.max(minW, Math.min(col - 1, cols - sepW - minW));
206
+ setLeftWidth(newLeft);
207
+ return;
208
+ }
209
+ // Scroll wheel — button 64 = up, 65 = down
210
+ if (match[4] === "M" && (button === 64 || button === 65)) {
211
+ const s = stateRef.current;
212
+ const lw = leftWidthRef.current;
213
+ const delta = button === 65 ? 3 : -3;
214
+ if (col <= lw) {
215
+ // Scroll left pane (issue list)
216
+ const maxIdx = s.flatIssues.length - 1;
217
+ if (maxIdx < 0)
218
+ return;
219
+ const next = Math.max(0, Math.min(s.selectedIndex + delta, maxIdx));
220
+ dispatch({ type: "SELECT", index: next });
221
+ }
222
+ else {
223
+ // Scroll right pane (detail)
224
+ const next = Math.max(0, s.detailScrollOffset + delta);
225
+ dispatch({ type: "SCROLL_DETAIL", offset: next });
226
+ }
227
+ return;
228
+ }
229
+ if (!isPress)
230
+ return;
231
+ // Left-click press: check if on divider to start drag
232
+ const lw = leftWidthRef.current;
233
+ const divStart = lw + 1; // 1-based start of separator
234
+ const divEnd = lw + sepW; // 1-based end of separator
235
+ if (col >= divStart && col <= divEnd) {
236
+ draggingRef.current = true;
237
+ return;
238
+ }
239
+ // Left-click press: select issue in left pane
240
+ const s = stateRef.current;
241
+ if (s.loading || s.error || s.flatIssues.length === 0)
242
+ return;
243
+ if (col > lw)
244
+ return;
245
+ // Row 1 is the header line, content starts at row 2 (1-based)
246
+ const contentRow = row - 2; // 0-based row within content area
247
+ if (contentRow < 0)
248
+ return;
249
+ const listRow = s.listScrollOffset + contentRow;
250
+ const flatIdx = getFlatIndexForListRow(s.groups, listRow);
251
+ if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
252
+ dispatch({ type: "SELECT", index: flatIdx });
253
+ }
254
+ };
255
+ if (process.stdin.isTTY) {
256
+ process.stdin.on("data", onData);
257
+ }
258
+ const init = async () => {
259
+ await new Promise((r) => setTimeout(r, 100));
260
+ await refresh(true);
261
+ };
262
+ init();
263
+ // Auto-refresh every 30s
264
+ refreshTimerRef.current = setInterval(() => refresh(), 30_000);
265
+ return () => {
266
+ if (refreshTimerRef.current)
267
+ clearInterval(refreshTimerRef.current);
268
+ // Disable SGR extended format (?1006l) and button-event tracking (?1002l)
269
+ process.stdout.write("\x1b[?1006l\x1b[?1002l");
270
+ leaveAltScreen();
271
+ if (process.stdin.isTTY) {
272
+ process.stdin.removeListener("data", onData);
273
+ }
274
+ };
275
+ }, [refresh]);
276
+ // ── List scroll tracking ──────────────────────────────────────────
277
+ useEffect(() => {
278
+ const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
279
+ const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
280
+ let offset = state.listScrollOffset;
281
+ if (rowIdx < offset) {
282
+ offset = Math.max(0, rowIdx - 1);
283
+ }
284
+ else if (rowIdx >= offset + maxVisible) {
285
+ offset = rowIdx - maxVisible + 2;
286
+ }
287
+ if (offset !== state.listScrollOffset) {
288
+ dispatch({ type: "SCROLL_LIST", offset });
289
+ }
290
+ }, [state.selectedIndex, state.groups, contentHeight, state.listScrollOffset]);
291
+ // ── Actions ───────────────────────────────────────────────────────
292
+ const launchWorkInTmux = useCallback((di, mode, worktreePath) => {
293
+ const windowName = di.issue.identifier;
294
+ const sessionId = di.worktree?.sessionId;
295
+ const bin = resolveAgentBinary();
296
+ const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
297
+ const workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
298
+ try {
299
+ // Switch to existing window if it exists
300
+ execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
301
+ const cmd = resumeCmd ?? workCmd;
302
+ execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
303
+ dispatch({
304
+ type: "SET_ACTION_MESSAGE",
305
+ message: resumeCmd
306
+ ? `Resumed session in: ${windowName}`
307
+ : `Launched ${mode} in: ${windowName}`,
308
+ });
309
+ }
310
+ catch {
311
+ // Window doesn't exist — create it
312
+ try {
313
+ execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
314
+ // Small delay so the new shell can start reading input before we send keys,
315
+ // otherwise buffered keystrokes from the dashboard pane can leak in.
316
+ execSync("sleep 0.1", { stdio: "ignore" });
317
+ const cmd = resumeCmd ?? workCmd;
318
+ execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
319
+ dispatch({
320
+ type: "SET_ACTION_MESSAGE",
321
+ message: resumeCmd
322
+ ? `Resumed session in new window: ${windowName}`
323
+ : `Launched ${mode} in tmux window: ${windowName}`,
324
+ });
325
+ }
326
+ catch {
327
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to create tmux window" });
328
+ }
329
+ }
330
+ // Delayed refresh to pick up session ID created by `st worktree work`
331
+ setTimeout(() => refresh(), 3000);
332
+ }, [refresh]);
333
+ const launchAfterCreation = useCallback((mode, worktreePath, ticketId) => {
334
+ if (isInTmux()) {
335
+ const windowName = ticketId;
336
+ const workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
337
+ try {
338
+ execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
339
+ execSync("sleep 0.1", { stdio: "ignore" });
340
+ execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
341
+ dispatch({
342
+ type: "SET_ACTION_MESSAGE",
343
+ message: `Created worktree + launched ${mode} in: ${windowName}`,
344
+ });
345
+ }
346
+ catch {
347
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree created, but tmux failed" });
348
+ }
349
+ setTimeout(() => refresh(), 3000);
350
+ }
351
+ else {
352
+ leaveAltScreen();
353
+ console.log(`SANTREE_CD:${worktreePath}`);
354
+ console.log(`SANTREE_WORK:${mode}`);
355
+ exit();
356
+ }
357
+ }, [exit, refresh]);
358
+ const createAndLaunch = useCallback(async (mode, runSetup) => {
359
+ const di = stateRef.current.flatIssues[stateRef.current.selectedIndex];
360
+ if (!di)
361
+ return;
362
+ const repoRoot = repoRootRef.current;
363
+ if (!repoRoot)
364
+ return;
365
+ // Guard against concurrent creation
366
+ if (stateRef.current.creatingForTicket)
367
+ return;
368
+ const ticketId = di.issue.identifier;
369
+ dispatch({ type: "CREATION_START", ticketId });
370
+ const slug = slugify(di.issue.title);
371
+ const branchName = `feature/${ticketId}-${slug}`;
372
+ const base = getDefaultBranch();
373
+ // 1. Pull latest (async to avoid blocking the event loop)
374
+ dispatch({ type: "CREATION_LOG", logs: `Fetching origin...\n` });
375
+ try {
376
+ await execAsync("git fetch origin", { cwd: repoRoot });
377
+ dispatch({ type: "CREATION_LOG", logs: `Checking out ${base}...\n` });
378
+ await execAsync(`git checkout ${base}`, { cwd: repoRoot });
379
+ dispatch({ type: "CREATION_LOG", logs: `Pulling ${base}...\n` });
380
+ await execAsync(`git pull origin ${base}`, { cwd: repoRoot });
381
+ dispatch({ type: "CREATION_LOG", logs: `Pulled latest ${base}\n` });
382
+ }
383
+ catch (e) {
384
+ const msg = e instanceof Error ? e.message : "Failed to pull latest";
385
+ dispatch({ type: "CREATION_LOG", logs: `Warning: ${msg}\n` });
386
+ }
387
+ // 2. Create worktree
388
+ dispatch({ type: "CREATION_LOG", logs: `Creating worktree ${branchName}...\n` });
389
+ const result = await createWorktree(branchName, base, repoRoot);
390
+ if (!result.success || !result.path) {
391
+ dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
392
+ dispatch({
393
+ type: "SET_ACTION_MESSAGE",
394
+ message: `Failed: ${result.error ?? "Unknown error"}`,
395
+ });
396
+ return;
397
+ }
398
+ dispatch({ type: "CREATION_LOG", logs: `Worktree created at ${result.path}\n` });
399
+ // 3. Run init script if requested
400
+ if (runSetup) {
401
+ const initScript = getInitScriptPath(repoRoot);
402
+ let canExecute = true;
403
+ try {
404
+ fs.accessSync(initScript, fs.constants.X_OK);
405
+ }
406
+ catch {
407
+ dispatch({
408
+ type: "CREATION_LOG",
409
+ logs: "Warning: init.sh exists but is not executable, skipping\n",
410
+ });
411
+ canExecute = false;
412
+ }
413
+ if (canExecute) {
414
+ dispatch({ type: "CREATION_LOG", logs: "Running init.sh...\n" });
415
+ let lastLen = 0;
416
+ const initResult = await spawnAsync(initScript, [], {
417
+ cwd: result.path,
418
+ env: {
419
+ ...process.env,
420
+ SANTREE_WORKTREE_PATH: result.path,
421
+ SANTREE_REPO_ROOT: repoRoot,
422
+ },
423
+ onOutput: (output) => {
424
+ const delta = output.slice(lastLen);
425
+ if (delta)
426
+ dispatch({ type: "CREATION_LOG", logs: delta });
427
+ lastLen = output.length;
428
+ },
429
+ });
430
+ if (initResult.code !== 0) {
431
+ dispatch({
432
+ type: "CREATION_LOG",
433
+ logs: `\nInit script exited with code ${initResult.code}\n`,
434
+ });
435
+ }
436
+ else {
437
+ dispatch({ type: "CREATION_LOG", logs: "\nSetup complete!\n" });
438
+ }
439
+ }
440
+ }
441
+ // 4. Done — launch work
442
+ dispatch({ type: "CREATION_DONE" });
443
+ launchAfterCreation(mode, result.path, ticketId);
444
+ }, [launchAfterCreation]);
445
+ const doWork = useCallback((mode) => {
446
+ const di = state.flatIssues[state.selectedIndex];
447
+ if (!di)
448
+ return;
449
+ const repoRoot = repoRootRef.current;
450
+ if (!repoRoot)
451
+ return;
452
+ dispatch({ type: "SET_OVERLAY", overlay: null });
453
+ if (di.worktree) {
454
+ // Worktree exists — launch work
455
+ if (isInTmux()) {
456
+ launchWorkInTmux(di, mode, di.worktree.path);
457
+ }
458
+ else {
459
+ leaveAltScreen();
460
+ console.log(`SANTREE_CD:${di.worktree.path}`);
461
+ console.log(`SANTREE_WORK:${mode}`);
462
+ exit();
463
+ }
464
+ }
465
+ else {
466
+ // No worktree — ask about setup if init script exists
467
+ if (hasInitScript(repoRoot)) {
468
+ dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
469
+ return;
470
+ }
471
+ // No init script — create directly
472
+ createAndLaunch(mode, false);
473
+ }
474
+ }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, createAndLaunch]);
475
+ // ── Commit flow ──────────────────────────────────────────────────
476
+ const handleStageAll = useCallback(async () => {
477
+ const wtPath = stateRef.current.commitWorktreePath;
478
+ const ticketId = stateRef.current.commitTicketId;
479
+ if (!wtPath)
480
+ return;
481
+ try {
482
+ await execAsync("git add -A", { cwd: wtPath });
483
+ dispatch({ type: "COMMIT_MESSAGE", message: `[${ticketId}] ` });
484
+ dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
485
+ }
486
+ catch (e) {
487
+ dispatch({
488
+ type: "COMMIT_ERROR",
489
+ error: e?.stderr?.trim() || e?.message || "Failed to stage",
490
+ });
491
+ }
492
+ }, []);
493
+ const handleCommitSubmit = useCallback(async (value) => {
494
+ const s = stateRef.current;
495
+ if (!s.commitWorktreePath || !s.commitBranch)
496
+ return;
497
+ const trimmed = value.trim();
498
+ if (!trimmed) {
499
+ dispatch({ type: "COMMIT_ERROR", error: "Empty commit message" });
500
+ return;
501
+ }
502
+ const msg = trimmed.includes(`[${s.commitTicketId}]`)
503
+ ? trimmed
504
+ : `[${s.commitTicketId}] ${trimmed}`;
505
+ dispatch({ type: "COMMIT_PHASE", phase: "committing" });
506
+ try {
507
+ await execAsync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
508
+ cwd: s.commitWorktreePath,
509
+ });
510
+ }
511
+ catch (e) {
512
+ dispatch({
513
+ type: "COMMIT_ERROR",
514
+ error: e?.stderr?.trim() || e?.stdout?.trim() || e?.message || "Commit failed",
515
+ });
516
+ return;
517
+ }
518
+ dispatch({ type: "COMMIT_PHASE", phase: "pushing" });
519
+ try {
520
+ await execAsync(`git push -u origin "${s.commitBranch}"`, { cwd: s.commitWorktreePath });
521
+ }
522
+ catch (e) {
523
+ dispatch({ type: "COMMIT_ERROR", error: e?.stderr?.trim() || e?.message || "Push failed" });
524
+ return;
525
+ }
526
+ dispatch({ type: "COMMIT_DONE" });
527
+ setTimeout(() => {
528
+ dispatch({ type: "COMMIT_CANCEL" });
529
+ refresh();
530
+ }, 2000);
531
+ }, [refresh]);
532
+ // ── Editor actions ───────────────────────────────────────────────
533
+ const openInEditor = useCallback((wtPath) => {
534
+ const editor = process.env.SANTREE_EDITOR || "code";
535
+ spawn(editor, [wtPath], { detached: true, stdio: "ignore" }).unref();
536
+ dispatch({
537
+ type: "SET_ACTION_MESSAGE",
538
+ message: `Opened ${path.basename(wtPath)} in ${editor}`,
539
+ });
540
+ }, []);
541
+ const openWorkspace = useCallback(() => {
542
+ const repoRoot = repoRootRef.current;
543
+ if (!repoRoot)
544
+ return;
545
+ const editor = process.env.SANTREE_EDITOR || "code";
546
+ try {
547
+ const entries = fs.readdirSync(repoRoot);
548
+ const wsFile = entries.find((f) => f.endsWith(".code-workspace"));
549
+ if (!wsFile) {
550
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No .code-workspace file found" });
551
+ return;
552
+ }
553
+ spawn(editor, [path.join(repoRoot, wsFile)], { detached: true, stdio: "ignore" }).unref();
554
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened workspace in ${editor}` });
555
+ }
556
+ catch {
557
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to open workspace" });
558
+ }
559
+ }, []);
560
+ // ── PR create flow ───────────────────────────────────────────────
561
+ const doPrCreate = useCallback(async (fill) => {
562
+ const s = stateRef.current;
563
+ if (!s.prCreateWorktreePath || !s.prCreateBranch)
564
+ return;
565
+ const base = getBaseBranch(s.prCreateBranch);
566
+ // Push first
567
+ dispatch({ type: "PR_CREATE_PHASE", phase: "pushing" });
568
+ try {
569
+ await execAsync(`git -C "${s.prCreateWorktreePath}" push -u origin "${s.prCreateBranch}"`);
570
+ }
571
+ catch (e) {
572
+ const msg = e?.stderr?.trim() || e?.message || "Push failed";
573
+ dispatch({ type: "PR_CREATE_ERROR", error: msg });
574
+ return;
575
+ }
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 {
584
+ await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, {
585
+ cwd: s.prCreateWorktreePath,
586
+ });
587
+ dispatch({ type: "PR_CREATE_DONE", url: "" });
588
+ }
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
+ }, [refresh]);
599
+ // ── Keyboard ──────────────────────────────────────────────────────
600
+ useInput((input, key) => {
601
+ // Clear action messages on any keypress
602
+ if (state.actionMessage && input !== "q") {
603
+ dispatch({ type: "SET_ACTION_MESSAGE", message: null });
604
+ }
605
+ // Commit overlay
606
+ if (state.overlay === "commit") {
607
+ if (key.escape) {
608
+ dispatch({ type: "COMMIT_CANCEL" });
609
+ return;
610
+ }
611
+ if (state.commitPhase === "confirm-stage") {
612
+ if (input === "y") {
613
+ handleStageAll();
614
+ return;
615
+ }
616
+ if (input === "n") {
617
+ dispatch({ type: "COMMIT_CANCEL" });
618
+ return;
619
+ }
620
+ return;
621
+ }
622
+ // awaiting-message is handled by TextInput, not useInput
623
+ // All other phases: swallow input
624
+ return;
625
+ }
626
+ // PR create overlay
627
+ if (state.overlay === "pr-create") {
628
+ if (key.escape) {
629
+ dispatch({ type: "PR_CREATE_CANCEL" });
630
+ return;
631
+ }
632
+ if (state.prCreatePhase === "choose-mode") {
633
+ if (input === "f") {
634
+ doPrCreate(true);
635
+ return;
636
+ }
637
+ if (input === "w") {
638
+ doPrCreate(false);
639
+ return;
640
+ }
641
+ }
642
+ return;
643
+ }
644
+ // Confirm setup overlay
645
+ if (state.overlay === "confirm-setup") {
646
+ const mode = state.setupMode;
647
+ if (input === "y" && mode) {
648
+ dispatch({ type: "SETUP_CONFIRM_DONE" });
649
+ createAndLaunch(mode, true);
650
+ return;
651
+ }
652
+ if (input === "n" && mode) {
653
+ dispatch({ type: "SETUP_CONFIRM_DONE" });
654
+ createAndLaunch(mode, false);
655
+ return;
656
+ }
657
+ if (key.escape) {
658
+ dispatch({ type: "SETUP_CONFIRM_DONE" });
659
+ return;
660
+ }
661
+ return;
662
+ }
663
+ // Mode select overlay
664
+ if (state.overlay === "mode-select") {
665
+ if (input === "p" || input === "1") {
666
+ doWork("plan");
667
+ return;
668
+ }
669
+ if (input === "i" || input === "2") {
670
+ doWork("implement");
671
+ return;
672
+ }
673
+ if (key.escape || input === "q") {
674
+ dispatch({ type: "SET_OVERLAY", overlay: null });
675
+ return;
676
+ }
677
+ return;
678
+ }
679
+ // Confirm delete overlay
680
+ if (state.overlay === "confirm-delete") {
681
+ if (input === "y") {
682
+ dispatch({ type: "SET_OVERLAY", overlay: null });
683
+ const di = state.flatIssues[state.selectedIndex];
684
+ if (di?.worktree) {
685
+ const repoRoot = repoRootRef.current;
686
+ if (repoRoot) {
687
+ dispatch({ type: "DELETE_START", ticketId: di.issue.identifier });
688
+ const force = di.worktree.dirty;
689
+ removeWorktree(di.worktree.branch, repoRoot, force).then((result) => {
690
+ dispatch({ type: "DELETE_DONE" });
691
+ if (result.success) {
692
+ dispatch({
693
+ type: "SET_ACTION_MESSAGE",
694
+ message: `Removed worktree for ${di.issue.identifier}`,
695
+ });
696
+ refresh();
697
+ }
698
+ else {
699
+ dispatch({
700
+ type: "SET_ACTION_MESSAGE",
701
+ message: `Failed: ${result.error ?? "Unknown error"}`,
702
+ });
703
+ }
704
+ });
705
+ }
706
+ }
707
+ return;
708
+ }
709
+ if (input === "n" || key.escape || input === "q") {
710
+ dispatch({ type: "SET_OVERLAY", overlay: null });
711
+ return;
712
+ }
713
+ return;
714
+ }
715
+ // Quit
716
+ if (input === "q") {
717
+ exit();
718
+ return;
719
+ }
720
+ const maxIndex = state.flatIssues.length - 1;
721
+ // Navigation
722
+ if (input === "j" || (key.downArrow && !key.shift)) {
723
+ const next = Math.min(state.selectedIndex + 1, maxIndex);
724
+ dispatch({ type: "SELECT", index: next });
725
+ return;
726
+ }
727
+ if (input === "k" || (key.upArrow && !key.shift)) {
728
+ const prev = Math.max(state.selectedIndex - 1, 0);
729
+ dispatch({ type: "SELECT", index: prev });
730
+ return;
731
+ }
732
+ // Detail scroll
733
+ if (key.shift && key.downArrow) {
734
+ dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
735
+ return;
736
+ }
737
+ if (key.shift && key.upArrow) {
738
+ dispatch({
739
+ type: "SCROLL_DETAIL",
740
+ offset: Math.max(0, state.detailScrollOffset - 3),
741
+ });
742
+ return;
743
+ }
744
+ const di = state.flatIssues[state.selectedIndex];
745
+ if (!di)
746
+ return;
747
+ // Work
748
+ if (input === "w") {
749
+ if (di.worktree?.sessionId) {
750
+ dispatch({
751
+ type: "SET_ACTION_MESSAGE",
752
+ message: "Session active. Press Enter to resume.",
753
+ });
754
+ return;
755
+ }
756
+ dispatch({ type: "SET_OVERLAY", overlay: "mode-select" });
757
+ return;
758
+ }
759
+ // Switch to worktree (Enter) — also resumes session
760
+ if (key.return) {
761
+ if (!di.worktree) {
762
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
763
+ return;
764
+ }
765
+ if (isInTmux()) {
766
+ const windowName = di.issue.identifier;
767
+ const sessionId = di.worktree.sessionId;
768
+ const bin = resolveAgentBinary();
769
+ const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
770
+ try {
771
+ execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
772
+ }
773
+ catch {
774
+ // Window doesn't exist — create one and resume/launch
775
+ try {
776
+ execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
777
+ stdio: "ignore",
778
+ });
779
+ execSync("sleep 0.1", { stdio: "ignore" });
780
+ const cmd = resumeCmd ?? "st worktree work";
781
+ execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, {
782
+ stdio: "ignore",
783
+ });
784
+ }
785
+ catch {
786
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
787
+ }
788
+ }
789
+ }
790
+ else {
791
+ leaveAltScreen();
792
+ console.log(`SANTREE_CD:${di.worktree.path}`);
793
+ exit();
794
+ }
795
+ return;
796
+ }
797
+ // Open in Linear
798
+ if (input === "o") {
799
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
800
+ execSync(`${openCmd} "${di.issue.url}"`, { stdio: "ignore" });
801
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
802
+ return;
803
+ }
804
+ // Open PR
805
+ if (input === "p") {
806
+ if (!di.pr?.url) {
807
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to open" });
808
+ return;
809
+ }
810
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
811
+ execSync(`${openCmd} "${di.pr.url}"`, { stdio: "ignore" });
812
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
813
+ return;
814
+ }
815
+ // Create PR
816
+ if (input === "c") {
817
+ if (!di.worktree) {
818
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Create a worktree first (w)" });
819
+ return;
820
+ }
821
+ if (di.pr) {
822
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "PR already exists" });
823
+ return;
824
+ }
825
+ dispatch({
826
+ type: "PR_CREATE_START",
827
+ ticketId: di.issue.identifier,
828
+ worktreePath: di.worktree.path,
829
+ branch: di.worktree.branch,
830
+ });
831
+ return;
832
+ }
833
+ // Review PR
834
+ if (input === "r") {
835
+ if (!di.pr || !di.worktree) {
836
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
837
+ return;
838
+ }
839
+ if (isInTmux()) {
840
+ const windowName = `review-${di.issue.identifier}`;
841
+ try {
842
+ execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
843
+ stdio: "ignore",
844
+ });
845
+ execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, { stdio: "ignore" });
846
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched review in tmux" });
847
+ }
848
+ catch {
849
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
850
+ }
851
+ }
852
+ else {
853
+ leaveAltScreen();
854
+ console.log(`SANTREE_CD:${di.worktree.path}`);
855
+ exit();
856
+ }
857
+ return;
858
+ }
859
+ // Open in editor
860
+ if (input === "e") {
861
+ if (!di.worktree) {
862
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to open" });
863
+ return;
864
+ }
865
+ openInEditor(di.worktree.path);
866
+ return;
867
+ }
868
+ // Open workspace
869
+ if (input === "E") {
870
+ openWorkspace();
871
+ return;
872
+ }
873
+ // Commit & push
874
+ if (input === "C") {
875
+ if (!di.worktree) {
876
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree" });
877
+ return;
878
+ }
879
+ if (!di.worktree.dirty) {
880
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No changes to commit" });
881
+ return;
882
+ }
883
+ dispatch({
884
+ type: "COMMIT_START",
885
+ ticketId: di.issue.identifier,
886
+ worktreePath: di.worktree.path,
887
+ branch: di.worktree.branch,
888
+ gitStatus: di.worktree.gitStatus,
889
+ });
890
+ return;
891
+ }
892
+ // Fix PR
893
+ if (input === "f") {
894
+ if (!di.pr || !di.worktree) {
895
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
896
+ return;
897
+ }
898
+ if (isInTmux()) {
899
+ const windowName = `fix-${di.issue.identifier}`;
900
+ try {
901
+ execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
902
+ stdio: "ignore",
903
+ });
904
+ execSync(`tmux send-keys -t "${windowName}" "st pr fix" Enter`, { stdio: "ignore" });
905
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched PR fix in tmux" });
906
+ }
907
+ catch {
908
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch PR fix" });
909
+ }
910
+ }
911
+ else {
912
+ leaveAltScreen();
913
+ console.log(`SANTREE_CD:${di.worktree.path}`);
914
+ exit();
915
+ }
916
+ return;
917
+ }
918
+ // Delete worktree
919
+ if (input === "d") {
920
+ if (!di.worktree) {
921
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to remove" });
922
+ return;
923
+ }
924
+ dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
925
+ return;
926
+ }
927
+ // Refresh
928
+ if (input === "R") {
929
+ refresh();
930
+ return;
931
+ }
932
+ }, { isActive: state.overlay !== "commit" || state.commitPhase !== "awaiting-message" });
933
+ // ── Render ─────────────────────────────────────────────────────────
934
+ if (state.loading) {
935
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading dashboard..." })] }) }));
936
+ }
937
+ if (state.error) {
938
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
939
+ }
940
+ if (state.flatIssues.length === 0) {
941
+ 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
+ }
943
+ 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 })) })] }))] }));
945
+ }