santree 0.1.4 → 0.2.0

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