santree 0.2.14 → 0.3.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.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer, useCallback, useRef, useState } from "react";
3
3
  import { Text, Box, useInput, useStdout, useApp } from "ink";
4
4
  import Spinner from "ink-spinner";
@@ -371,16 +371,19 @@ export default function Dashboard() {
371
371
  // ── Mouse tracking pause ─────────────────────────────────────────
372
372
  // The MultilineTextArea captures ESC for cancel. With SGR mouse tracking on,
373
373
  // every click emits `\x1b[<btn;col;rowM` — Ink reads the leading ESC and fires
374
- // key.escape, dismissing the overlay. Disable mouse tracking while the
375
- // context-input overlay is mounted; restore on unmount.
374
+ // key.escape, dismissing the overlay. Disable tracking while any overlay
375
+ // phase mounts a MultilineTextArea (context-input editing OR pr-create
376
+ // review); restore when that phase ends.
376
377
  useEffect(() => {
377
- if (state.overlay !== "context-input")
378
+ const needsMouseOff = (state.overlay === "context-input" && state.contextInputPhase === "editing") ||
379
+ (state.overlay === "pr-create" && state.prCreatePhase === "review");
380
+ if (!needsMouseOff)
378
381
  return;
379
382
  process.stdout.write("\x1b[?1002l\x1b[?1006l");
380
383
  return () => {
381
384
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
382
385
  };
383
- }, [state.overlay]);
386
+ }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
384
387
  // ── Actions ───────────────────────────────────────────────────────
385
388
  const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
386
389
  const windowName = di.issue.identifier;
@@ -890,6 +893,9 @@ export default function Dashboard() {
890
893
  }
891
894
  // PR create overlay
892
895
  if (state.overlay === "pr-create") {
896
+ // Review phase — MultilineTextArea owns the keyboard
897
+ if (state.prCreatePhase === "review")
898
+ return;
893
899
  if (key.escape) {
894
900
  dispatch({ type: "PR_CREATE_CANCEL" });
895
901
  return;
@@ -904,21 +910,17 @@ export default function Dashboard() {
904
910
  return;
905
911
  }
906
912
  }
907
- if (state.prCreatePhase === "review") {
913
+ if (state.prCreatePhase === "confirm") {
908
914
  if (input === "y" || key.return) {
909
915
  confirmPrCreate();
910
916
  return;
911
917
  }
912
- if (input === "w") {
913
- openPrInWeb();
914
- return;
915
- }
916
- if (key.shift && key.downArrow) {
917
- dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
918
+ if (input === "e") {
919
+ dispatch({ type: "PR_CREATE_EDIT" });
918
920
  return;
919
921
  }
920
- if (key.shift && key.upArrow) {
921
- dispatch({ type: "SCROLL_DETAIL", offset: Math.max(0, state.detailScrollOffset - 3) });
922
+ if (input === "w") {
923
+ openPrInWeb();
922
924
  return;
923
925
  }
924
926
  }
@@ -1001,9 +1003,29 @@ export default function Dashboard() {
1001
1003
  }
1002
1004
  return;
1003
1005
  }
1004
- // Context-input overlay — MultilineTextArea owns its own useInput;
1005
- // outer useInput is disabled for this overlay via isActive below.
1006
+ // Context-input overlay.
1007
+ // Editing phase: MultilineTextArea owns useInput (outer is disabled
1008
+ // via isActive below).
1009
+ // Review phase: outer handles y/n/e/ESC.
1006
1010
  if (state.overlay === "context-input") {
1011
+ if (state.contextInputPhase === "review") {
1012
+ if (input === "y" || key.return) {
1013
+ const mode = state.contextInputMode;
1014
+ const ctx = state.contextInputValue;
1015
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
1016
+ if (mode)
1017
+ doWork(mode, ctx);
1018
+ return;
1019
+ }
1020
+ if (input === "n" || input === "e") {
1021
+ dispatch({ type: "CONTEXT_INPUT_EDIT" });
1022
+ return;
1023
+ }
1024
+ if (key.escape) {
1025
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
1026
+ return;
1027
+ }
1028
+ }
1007
1029
  return;
1008
1030
  }
1009
1031
  // Confirm delete overlay
@@ -1475,7 +1497,8 @@ export default function Dashboard() {
1475
1497
  return;
1476
1498
  }
1477
1499
  }, {
1478
- isActive: state.overlay !== "context-input" &&
1500
+ isActive: (state.overlay !== "context-input" || state.contextInputPhase === "review") &&
1501
+ (state.overlay !== "pr-create" || state.prCreatePhase !== "review") &&
1479
1502
  (state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
1480
1503
  });
1481
1504
  // ── Render ─────────────────────────────────────────────────────────
@@ -1487,13 +1510,10 @@ export default function Dashboard() {
1487
1510
  }
1488
1511
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1489
1512
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1490
- 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] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), 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 === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
1491
- const mode = state.contextInputMode;
1492
- const ctx = state.contextInputValue;
1493
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1494
- if (mode)
1495
- doWork(mode, ctx);
1496
- }, onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " ", "launch", " ", _jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " ", "newline", " ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " ", "cancel"] })] }) })) : state.overlay === "base-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 base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
1513
+ 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] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), 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 === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " newline ", _jsx(Text, { color: "cyan", bold: true, children: "\u2191\u2193\u2190\u2192" }), " move ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+V" }), " paste image ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " continue ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1514
+ .split("\n")
1515
+ .slice(0, 12)
1516
+ .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] }))] }) })) : state.overlay === "base-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 base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
1497
1517
  const selected = idx === state.baseSelectIndex;
1498
1518
  const defaultBranch = getDefaultBranch();
1499
1519
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
@@ -1501,5 +1521,5 @@ export default function Dashboard() {
1501
1521
  }), _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 === "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 })) : 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, 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.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
1502
1522
  .split("\n")
1503
1523
  .slice(-(contentHeight - 1))
1504
- .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, scrollOffset: state.detailScrollOffset })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
1524
+ .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 })) })] }))] }));
1505
1525
  }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  export declare const description = "Launch Claude to work on current ticket";
3
3
  export declare const options: z.ZodObject<{
4
4
  plan: z.ZodOptional<z.ZodBoolean>;
5
- "context-file": z.ZodOptional<z.ZodString>;
5
+ contextFile: z.ZodOptional<z.ZodString>;
6
6
  }, z.core.$strip>;
7
7
  type Props = {
8
8
  options: z.infer<typeof options>;
@@ -10,7 +10,7 @@ import { getSessionId, setSessionId } from "../../lib/git.js";
10
10
  export const description = "Launch Claude to work on current ticket";
11
11
  export const options = z.object({
12
12
  plan: z.boolean().optional().describe("Only create implementation plan"),
13
- "context-file": z
13
+ contextFile: z
14
14
  .string()
15
15
  .optional()
16
16
  .describe("Path to a file whose contents are appended to the prompt as extra context"),
@@ -28,7 +28,7 @@ export default function Work({ options }) {
28
28
  const [error, setError] = useState(null);
29
29
  const [mode] = useState(options.plan ? "plan" : "implement");
30
30
  const [aiContext, setAiContext] = useState(null);
31
- const contextFilePath = options["context-file"];
31
+ const contextFilePath = options.contextFile;
32
32
  useEffect(() => {
33
33
  async function init() {
34
34
  // Small delay to allow spinner to render
@@ -1,48 +1,189 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
2
3
  import { Box, Text, useInput } from "ink";
4
+ import { spawnSync } from "node:child_process";
5
+ import { openSync, readSync, closeSync, statSync, unlinkSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
9
+ // macOS clipboard → PNG. Returns the written file path on success, or null if
10
+ // the clipboard holds no image, the platform isn't macOS, or the write produced
11
+ // a file that isn't actually a PNG. The coercion to «class PNGf» errors when
12
+ // the clipboard holds only text — verified against real clipboards.
13
+ function pasteClipboardImageToTmp() {
14
+ if (process.platform !== "darwin")
15
+ return null;
16
+ const filePath = join(tmpdir(), `santree-paste-${Date.now()}.png`);
17
+ const script = `try
18
+ set pngData to the clipboard as «class PNGf»
19
+ set theFile to POSIX file "${filePath}"
20
+ set fileRef to open for access theFile with write permission
21
+ set eof fileRef to 0
22
+ write pngData to fileRef
23
+ close access fileRef
24
+ return "ok"
25
+ on error
26
+ return "no-image"
27
+ end try`;
28
+ try {
29
+ const result = spawnSync("osascript", ["-e", script], {
30
+ encoding: "utf-8",
31
+ timeout: 3000,
32
+ });
33
+ if (result.status !== 0 || result.stdout.trim() !== "ok")
34
+ return null;
35
+ // Defense in depth: verify the file is non-empty and starts with the PNG
36
+ // magic header. Guards against an osascript quirk writing a stub.
37
+ if (statSync(filePath).size === 0) {
38
+ try {
39
+ unlinkSync(filePath);
40
+ }
41
+ catch { }
42
+ return null;
43
+ }
44
+ const fd = openSync(filePath, "r");
45
+ const header = Buffer.alloc(8);
46
+ readSync(fd, header, 0, 8, 0);
47
+ closeSync(fd);
48
+ if (!header.equals(PNG_MAGIC)) {
49
+ try {
50
+ unlinkSync(filePath);
51
+ }
52
+ catch { }
53
+ return null;
54
+ }
55
+ return filePath;
56
+ }
57
+ catch {
58
+ // osascript unavailable or fs error — silent no-op
59
+ }
60
+ return null;
61
+ }
62
+ function offsetToRowCol(value, offset) {
63
+ const lines = value.split("\n");
64
+ let idx = 0;
65
+ for (let r = 0; r < lines.length; r++) {
66
+ const len = lines[r].length;
67
+ if (offset <= idx + len) {
68
+ return [r, offset - idx];
69
+ }
70
+ idx += len + 1;
71
+ }
72
+ const last = lines.length - 1;
73
+ return [last, lines[last].length];
74
+ }
75
+ function rowColToOffset(value, row, col) {
76
+ const lines = value.split("\n");
77
+ const clampedRow = Math.max(0, Math.min(row, lines.length - 1));
78
+ let idx = 0;
79
+ for (let r = 0; r < clampedRow; r++) {
80
+ idx += lines[r].length + 1;
81
+ }
82
+ const clampedCol = Math.max(0, Math.min(col, lines[clampedRow].length));
83
+ return idx + clampedCol;
84
+ }
3
85
  export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeholder, width, height = 6, focus = true, }) {
86
+ const [cursor, setCursor] = useState(value.length);
87
+ // Keep cursor within bounds if value shrinks externally
88
+ useEffect(() => {
89
+ if (cursor > value.length)
90
+ setCursor(value.length);
91
+ }, [value, cursor]);
92
+ const insertAt = (pos, text) => {
93
+ onChange(value.slice(0, pos) + text + value.slice(pos));
94
+ setCursor(pos + text.length);
95
+ };
96
+ const deleteBefore = (pos) => {
97
+ if (pos === 0)
98
+ return;
99
+ onChange(value.slice(0, pos - 1) + value.slice(pos));
100
+ setCursor(pos - 1);
101
+ };
4
102
  useInput((input, key) => {
5
103
  // Ctrl+D submits
6
104
  if (key.ctrl && input === "d") {
7
105
  onSubmit();
8
106
  return;
9
107
  }
10
- // ESC cancels. Parent disables SGR mouse tracking while this overlay is
11
- // mounted so clicks can no longer masquerade as ESC.
108
+ // Ctrl+V try to paste clipboard image as a temp file reference.
109
+ // Regular Cmd+V text paste is handled by the terminal and arrives as
110
+ // normal input below.
111
+ if (key.ctrl && input === "v") {
112
+ const imagePath = pasteClipboardImageToTmp();
113
+ if (imagePath) {
114
+ insertAt(cursor, `![pasted image](${imagePath})`);
115
+ }
116
+ return;
117
+ }
118
+ // ESC cancels (parent disables SGR mouse tracking while mounted
119
+ // so clicks don't masquerade as ESC)
12
120
  if (key.escape) {
13
121
  onCancel();
14
122
  return;
15
123
  }
16
124
  if (key.backspace || key.delete) {
17
- onChange(value.slice(0, -1));
125
+ deleteBefore(cursor);
126
+ return;
127
+ }
128
+ // Arrow navigation — column is remembered via col-from-current-pos
129
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
130
+ const lines = value.split("\n");
131
+ const [row, col] = offsetToRowCol(value, cursor);
132
+ if (key.upArrow) {
133
+ if (row === 0)
134
+ setCursor(0);
135
+ else
136
+ setCursor(rowColToOffset(value, row - 1, col));
137
+ }
138
+ else if (key.downArrow) {
139
+ if (row === lines.length - 1)
140
+ setCursor(value.length);
141
+ else
142
+ setCursor(rowColToOffset(value, row + 1, col));
143
+ }
144
+ else if (key.leftArrow) {
145
+ setCursor(Math.max(0, cursor - 1));
146
+ }
147
+ else if (key.rightArrow) {
148
+ setCursor(Math.min(value.length, cursor + 1));
149
+ }
18
150
  return;
19
151
  }
20
- // Swallow navigation keys — this is an append-only text area.
21
- if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab)
152
+ if (key.tab)
22
153
  return;
23
- // Enter inserts a newline. MUST run before the meta/ctrl swallow below so
24
- // Option+Enter and Ctrl+Enter also insert newlines. When Ink delivers a
25
- // paste as one chunk, `input` may carry embedded content alongside the
26
- // \r — normalize and append the whole thing instead of dropping it.
154
+ // Enter inserts a newline at cursor. MUST run before meta/ctrl swallow
155
+ // so Option+Enter / Ctrl+Enter also insert. When a paste carries content
156
+ // alongside \r, append the whole normalized chunk.
27
157
  if (key.return) {
28
158
  const chunk = input ? input.replace(/\r\n?/g, "\n") : "\n";
29
- onChange(value + chunk);
159
+ insertAt(cursor, chunk);
30
160
  return;
31
161
  }
32
- // Swallow remaining modifier combos.
162
+ // Swallow remaining modifier combos
33
163
  if (key.ctrl || key.meta)
34
164
  return;
35
165
  if (!input)
36
166
  return;
37
- // Pasted content may embed \r or \r\n — normalize to \n.
38
167
  const normalized = input.replace(/\r\n?/g, "\n");
39
- onChange(value + normalized);
168
+ insertAt(cursor, normalized);
40
169
  }, { isActive: focus });
170
+ const [cursorRow, cursorCol] = offsetToRowCol(value, cursor);
41
171
  const lines = value.length === 0 ? [""] : value.split("\n");
42
- const visibleLines = lines.slice(Math.max(0, lines.length - height));
172
+ // Scroll viewport so the cursor row is always visible
173
+ let scrollStart = 0;
174
+ if (cursorRow >= height)
175
+ scrollStart = cursorRow - height + 1;
176
+ const visibleLines = lines.slice(scrollStart, scrollStart + height);
43
177
  const isEmpty = value.length === 0;
44
- return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: "cyan", paddingX: 1, minHeight: height + 2, children: isEmpty && placeholder ? (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { color: "cyan", children: "\u2588" }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (visibleLines.map((line, i) => {
45
- const isLast = i === visibleLines.length - 1;
46
- return (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { children: line }), isLast && focus ? _jsx(Text, { color: "cyan", children: "\u2588" }) : null] }, i));
178
+ return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: "cyan", paddingX: 1, minHeight: height + 2, children: isEmpty && placeholder ? (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (visibleLines.map((line, i) => {
179
+ const absoluteRow = scrollStart + i;
180
+ const isCursorRow = focus && absoluteRow === cursorRow;
181
+ if (!isCursorRow) {
182
+ return (_jsx(Box, { minHeight: 1, children: _jsx(Text, { children: line }) }, i));
183
+ }
184
+ const before = line.slice(0, cursorCol);
185
+ const atCursor = cursorCol < line.length ? line[cursorCol] : " ";
186
+ const after = cursorCol < line.length ? line.slice(cursorCol + 1) : "";
187
+ return (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: atCursor }), _jsx(Text, { children: after })] }, i));
47
188
  })) }));
48
189
  }
@@ -22,7 +22,7 @@ interface PrCreateOverlayProps {
22
22
  url: string | null;
23
23
  body: string | null;
24
24
  title: string | null;
25
- scrollOffset: number;
25
+ dispatch: React.Dispatch<DashboardAction>;
26
26
  }
27
- export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, scrollOffset, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
27
+ export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }: PrCreateOverlayProps): import("react/jsx-runtime").JSX.Element;
28
28
  export {};
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import TextInput from "ink-text-input";
5
+ import { MultilineTextArea } from "./MultilineTextArea.js";
5
6
  export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phase, message, error, dispatch, onSubmit, }) {
6
7
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Commit & Push" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), gitStatus ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Changes:" }), gitStatus
7
8
  .split("\n")
@@ -20,9 +21,9 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
20
21
  return (_jsxs(Text, { color: color, children: [" ", line] }, i));
21
22
  }), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
22
23
  }
23
- export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, scrollOffset, }) {
24
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Review PR" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), body
25
- ?.split("\n")
26
- .slice(scrollOffset, scrollOffset + height - 11)
27
- .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line }, i))), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "y" }), "/Enter create", " ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel Shift+arrows scroll"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
24
+ export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }) {
25
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " newline ", _jsx(Text, { color: "cyan", bold: true, children: "\u2191\u2193\u2190\u2192" }), " move ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+V" }), " paste image ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " continue ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
26
+ .split("\n")
27
+ .slice(0, Math.max(4, height - 12))
28
+ .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line || " " }, i))), (body ?? "").split("\n").length > Math.max(4, height - 12) && (_jsxs(Text, { dimColor: true, children: ["\u2026+", (body ?? "").split("\n").length - Math.max(4, height - 12), " more lines"] }))] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " create ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " open in browser ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "confirm" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
28
29
  }
@@ -60,7 +60,7 @@ export interface EnrichedReviewPR {
60
60
  export type DashboardTab = "issues" | "reviews";
61
61
  export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
62
62
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
63
- export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
63
+ export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
64
64
  export interface DashboardState {
65
65
  activeTab: DashboardTab;
66
66
  groups: ProjectGroup[];
@@ -102,6 +102,7 @@ export interface DashboardState {
102
102
  baseSelectChosen: string | null;
103
103
  contextInputValue: string;
104
104
  contextInputMode: "plan" | "implement" | null;
105
+ contextInputPhase: "editing" | "review";
105
106
  }
106
107
  export type DashboardAction = {
107
108
  type: "SET_DATA";
@@ -181,6 +182,13 @@ export type DashboardAction = {
181
182
  type: "PR_CREATE_REVIEW";
182
183
  body: string;
183
184
  title: string;
185
+ } | {
186
+ type: "PR_CREATE_BODY_CHANGE";
187
+ body: string;
188
+ } | {
189
+ type: "PR_CREATE_CONFIRM";
190
+ } | {
191
+ type: "PR_CREATE_EDIT";
184
192
  } | {
185
193
  type: "PR_CREATE_DONE";
186
194
  url: string;
@@ -223,6 +231,10 @@ export type DashboardAction = {
223
231
  } | {
224
232
  type: "CONTEXT_INPUT_CHANGE";
225
233
  value: string;
234
+ } | {
235
+ type: "CONTEXT_INPUT_REVIEW";
236
+ } | {
237
+ type: "CONTEXT_INPUT_EDIT";
226
238
  } | {
227
239
  type: "CONTEXT_INPUT_DONE";
228
240
  };
@@ -40,6 +40,7 @@ export const initialState = {
40
40
  baseSelectChosen: null,
41
41
  contextInputValue: "",
42
42
  contextInputMode: null,
43
+ contextInputPhase: "editing",
43
44
  };
44
45
  export function reducer(state, action) {
45
46
  switch (action.type) {
@@ -165,6 +166,12 @@ export function reducer(state, action) {
165
166
  prCreateTitle: action.title,
166
167
  detailScrollOffset: 0,
167
168
  };
169
+ case "PR_CREATE_BODY_CHANGE":
170
+ return { ...state, prCreateBody: action.body };
171
+ case "PR_CREATE_CONFIRM":
172
+ return { ...state, prCreatePhase: "confirm" };
173
+ case "PR_CREATE_EDIT":
174
+ return { ...state, prCreatePhase: "review" };
168
175
  case "PR_CREATE_DONE":
169
176
  return {
170
177
  ...state,
@@ -250,15 +257,21 @@ export function reducer(state, action) {
250
257
  overlay: "context-input",
251
258
  contextInputMode: action.mode,
252
259
  contextInputValue: "",
260
+ contextInputPhase: "editing",
253
261
  };
254
262
  case "CONTEXT_INPUT_CHANGE":
255
263
  return { ...state, contextInputValue: action.value };
264
+ case "CONTEXT_INPUT_REVIEW":
265
+ return { ...state, contextInputPhase: "review" };
266
+ case "CONTEXT_INPUT_EDIT":
267
+ return { ...state, contextInputPhase: "editing" };
256
268
  case "CONTEXT_INPUT_DONE":
257
269
  return {
258
270
  ...state,
259
271
  overlay: null,
260
272
  contextInputMode: null,
261
273
  contextInputValue: "",
274
+ contextInputPhase: "editing",
262
275
  };
263
276
  default:
264
277
  return state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.14",
3
+ "version": "0.3.0",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",