santree 0.2.15 → 0.4.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,118 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useApp } from "ink";
3
+ import { option } from "pastel";
4
+ import { z } from "zod/v4";
5
+ import { spawnSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ export const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
10
+ export const options = z.object({
11
+ initial: z
12
+ .string()
13
+ .optional()
14
+ .describe(option({ description: "Pre-fill the editor buffer with this text" })),
15
+ from: z
16
+ .string()
17
+ .optional()
18
+ .describe(option({ description: "Pre-fill the editor buffer with the contents of this file" })),
19
+ ext: z
20
+ .string()
21
+ .default("md")
22
+ .describe(option({ description: "Temp file extension (default: md)" })),
23
+ editor: z
24
+ .string()
25
+ .optional()
26
+ .describe(option({ description: "Override the editor command (default: $VISUAL || $EDITOR || vim)" })),
27
+ });
28
+ function resolveEditor(override) {
29
+ const raw = override ?? process.env["VISUAL"] ?? process.env["EDITOR"] ?? "vim";
30
+ const parts = raw.split(/\s+/).filter(Boolean);
31
+ const cmd = parts[0] ?? "vim";
32
+ return { cmd, args: parts.slice(1) };
33
+ }
34
+ // Render null and write all UI feedback to stderr so stdout stays clean for
35
+ // shell capture: `file=$(st helpers text-editor) && st worktree work --context-file "$file"`.
36
+ export default function TextEditor({ options: opts }) {
37
+ const { exit } = useApp();
38
+ const hasRun = useRef(false);
39
+ useEffect(() => {
40
+ if (hasRun.current)
41
+ return;
42
+ hasRun.current = true;
43
+ const ext = opts.ext.replace(/^\./, "");
44
+ const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext}`);
45
+ const seed = (() => {
46
+ if (opts.from) {
47
+ try {
48
+ return fs.readFileSync(opts.from, "utf-8");
49
+ }
50
+ catch {
51
+ return opts.initial ?? "";
52
+ }
53
+ }
54
+ return opts.initial ?? "";
55
+ })();
56
+ try {
57
+ fs.writeFileSync(filePath, seed);
58
+ }
59
+ catch (err) {
60
+ process.stderr.write(`Failed to create temp file: ${err.message}\n`);
61
+ process.exitCode = 1;
62
+ exit();
63
+ return;
64
+ }
65
+ // Ink put stdin in raw mode on mount; release it for the editor.
66
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
67
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
68
+ try {
69
+ process.stdin.setRawMode(false);
70
+ }
71
+ catch { }
72
+ }
73
+ const { cmd, args } = resolveEditor(opts.editor);
74
+ const result = spawnSync(cmd, [...args, filePath], { stdio: "inherit" });
75
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
76
+ try {
77
+ process.stdin.setRawMode(wasRaw);
78
+ }
79
+ catch { }
80
+ }
81
+ if (result.error || result.status !== 0) {
82
+ process.stderr.write(result.error
83
+ ? `Failed to launch editor '${cmd}': ${result.error.message}\n`
84
+ : `Editor '${cmd}' exited with status ${result.status}\n`);
85
+ try {
86
+ fs.unlinkSync(filePath);
87
+ }
88
+ catch { }
89
+ process.exitCode = 1;
90
+ exit();
91
+ return;
92
+ }
93
+ let content = "";
94
+ try {
95
+ content = fs.readFileSync(filePath, "utf-8");
96
+ }
97
+ catch (err) {
98
+ process.stderr.write(`Failed to read temp file: ${err.message}\n`);
99
+ process.exitCode = 1;
100
+ exit();
101
+ return;
102
+ }
103
+ // Empty buffer => treat as cancel (matches `git commit` behavior)
104
+ if (content.trim().length === 0) {
105
+ try {
106
+ fs.unlinkSync(filePath);
107
+ }
108
+ catch { }
109
+ process.stderr.write("Cancelled (empty buffer)\n");
110
+ process.exitCode = 1;
111
+ exit();
112
+ return;
113
+ }
114
+ process.stdout.write(`${filePath}\n`);
115
+ exit();
116
+ }, [opts, exit]);
117
+ return null;
118
+ }
@@ -5,6 +5,7 @@ export declare const options: z.ZodObject<{
5
5
  work: z.ZodOptional<z.ZodBoolean>;
6
6
  plan: z.ZodOptional<z.ZodBoolean>;
7
7
  "no-pull": z.ZodOptional<z.ZodBoolean>;
8
+ window: z.ZodOptional<z.ZodBoolean>;
8
9
  tmux: z.ZodOptional<z.ZodBoolean>;
9
10
  name: z.ZodOptional<z.ZodString>;
10
11
  }, z.core.$strip>;
@@ -3,36 +3,21 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { execSync } from "child_process";
7
6
  import * as fs from "fs";
8
7
  import { createWorktree, findMainRepoRoot, getDefaultBranch, pullLatest, hasInitScript, getInitScriptPath, extractTicketId, } from "../../lib/git.js";
9
8
  import { spawnAsync } from "../../lib/exec.js";
9
+ import { getMultiplexer } from "../../lib/multiplexer/index.js";
10
10
  export const description = "Create a new worktree from a branch";
11
11
  export const options = z.object({
12
12
  base: z.string().optional().describe("Base branch to create from"),
13
13
  work: z.boolean().optional().describe("Launch Claude after creating"),
14
14
  plan: z.boolean().optional().describe("With --work, only plan"),
15
15
  "no-pull": z.boolean().optional().describe("Skip pulling latest changes"),
16
- tmux: z.boolean().optional().describe("Create a new tmux window"),
17
- name: z.string().optional().describe("Custom tmux window name"),
16
+ window: z.boolean().optional().describe("Create a new multiplexer window/workspace (tmux/cmux)"),
17
+ tmux: z.boolean().optional().describe("Alias for --window (deprecated)"),
18
+ name: z.string().optional().describe("Custom window/workspace name"),
18
19
  });
19
20
  export const args = z.tuple([z.string().optional().describe("Branch name")]);
20
- function isInTmux() {
21
- return !!process.env.TMUX;
22
- }
23
- function createTmuxWindow(name, path, runCommand) {
24
- try {
25
- execSync(`tmux new-window -n "${name}" -c "${path}"`, { stdio: "ignore" });
26
- // If a command is provided, send it to the new window
27
- if (runCommand) {
28
- execSync(`tmux send-keys -t "${name}" "${runCommand}" Enter`, { stdio: "ignore" });
29
- }
30
- return true;
31
- }
32
- catch {
33
- return false;
34
- }
35
- }
36
21
  function getWindowName(branchName, customName) {
37
22
  if (customName)
38
23
  return customName;
@@ -50,35 +35,38 @@ export default function Create({ options, args }) {
50
35
  const [message, setMessage] = useState("");
51
36
  const [worktreePath, setWorktreePath] = useState("");
52
37
  const [baseBranch, setBaseBranch] = useState(null);
53
- const [tmuxWindowName, setTmuxWindowName] = useState(null);
54
- function finalize(path, branch) {
55
- // Handle tmux window creation
56
- if (options.tmux) {
57
- if (!isInTmux()) {
58
- setMessage("Worktree created, but not in tmux session");
38
+ const [muxWindowName, setMuxWindowName] = useState(null);
39
+ const [muxKind, setMuxKind] = useState(null);
40
+ async function finalize(path, branch) {
41
+ const wantsWindow = options.window || options.tmux;
42
+ if (wantsWindow) {
43
+ const mux = getMultiplexer();
44
+ if (!mux.isActive()) {
45
+ setMessage("Worktree created, but no active multiplexer");
59
46
  setStatus("done");
60
47
  console.log(`SANTREE_CD:${path}`);
61
48
  return;
62
49
  }
63
- setStatus("tmux");
64
- setMessage("Creating tmux window...");
50
+ setStatus("spawning-window");
51
+ setMessage(`Creating ${mux.kind} window...`);
65
52
  const windowName = getWindowName(branch, options.name);
66
- setTmuxWindowName(windowName);
67
- // Build command to run in new window (if --work is set)
53
+ setMuxWindowName(windowName);
54
+ setMuxKind(mux.kind);
68
55
  let runCommand;
69
56
  if (options.work) {
70
57
  runCommand = options.plan ? "st worktree work --plan" : "st worktree work";
71
58
  }
72
- if (!createTmuxWindow(windowName, path, runCommand)) {
73
- setMessage("Worktree created, but failed to create tmux window");
59
+ const result = await mux.createWindow({ name: windowName, cwd: path, command: runCommand });
60
+ if (!result.ok) {
61
+ setMessage(`Worktree created, but failed to create ${mux.kind} window${result.message ? `: ${result.message}` : ""}`);
74
62
  setStatus("done");
75
63
  console.log(`SANTREE_CD:${path}`);
76
64
  return;
77
65
  }
78
66
  setStatus("done");
79
67
  const workInfo = options.work ? (options.plan ? " + Claude (plan)" : " + Claude") : "";
80
- setMessage(`Worktree and tmux window created!${workInfo}`);
81
- // Don't output SANTREE_CD when tmux window is created - user is already in new window
68
+ setMessage(`Worktree and ${mux.kind} window created!${workInfo}`);
69
+ // Don't output SANTREE_CD when a window is created user is already in the new window
82
70
  return;
83
71
  }
84
72
  setStatus("done");
@@ -133,7 +121,7 @@ export default function Create({ options, args }) {
133
121
  }
134
122
  catch {
135
123
  setMessage("Warning: Init script exists but is not executable");
136
- finalize(result.path, branch);
124
+ await finalize(result.path, branch);
137
125
  return;
138
126
  }
139
127
  const initResult = await spawnAsync(initScript, [], {
@@ -147,10 +135,10 @@ export default function Create({ options, args }) {
147
135
  if (initResult.code !== 0) {
148
136
  setMessage(`Warning: Init script exited with code ${initResult.code}`);
149
137
  }
150
- finalize(result.path, branch);
138
+ await finalize(result.path, branch);
151
139
  }
152
140
  else {
153
- finalize(result.path, branch);
141
+ await finalize(result.path, branch);
154
142
  }
155
143
  }
156
144
  else {
@@ -165,9 +153,13 @@ export default function Create({ options, args }) {
165
153
  options.work,
166
154
  options.plan,
167
155
  options["no-pull"],
156
+ options.window,
168
157
  options.tmux,
169
158
  options.name,
170
159
  ]);
171
- const isLoading = status === "pulling" || status === "creating" || status === "init-script" || status === "tmux";
172
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), options.tmux && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "tmux:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), tmuxWindowName && _jsxs(Text, { dimColor: true, children: [" tmux window: ", tmuxWindowName] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
160
+ const isLoading = status === "pulling" ||
161
+ status === "creating" ||
162
+ status === "init-script" ||
163
+ status === "spawning-window";
164
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83C\uDF31 Create Worktree" }) }), branchName && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "blue", paddingX: 1, width: "100%", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branchName })] }), baseBranch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "base:" }), _jsx(Text, { color: "blue", children: baseBranch })] })), options["no-pull"] && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "skip pull:" }), _jsx(Text, { color: "yellow", children: "yes" })] })), options.work && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "after:" }), _jsx(Text, { backgroundColor: "magenta", color: "white", children: options.plan ? " plan " : " work " })] })), (options.window || options.tmux) && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "window:" }), _jsx(Text, { backgroundColor: "green", color: "white", children: ` ${options.name || "auto"} ` })] }))] })), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message] })] })), status === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] }), _jsxs(Text, { dimColor: true, children: [" ", worktreePath] }), muxWindowName && (_jsxs(Text, { dimColor: true, children: [" ", muxKind ?? "tmux", " window: ", muxWindowName] }))] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
173
165
  }
package/dist/lib/ai.js CHANGED
@@ -143,12 +143,7 @@ export function launchAgent(prompt, opts) {
143
143
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
144
144
  }
145
145
  const args = [];
146
- if (process.env.SANTREE_SKIP_PERMISSIONS) {
147
- args.push("--dangerously-skip-permissions");
148
- }
149
- if (opts?.planMode) {
150
- args.push("--permission-mode", "plan");
151
- }
146
+ args.push("--permission-mode", opts?.planMode ? "plan" : "auto");
152
147
  if (opts?.sessionId) {
153
148
  if (opts.resume) {
154
149
  args.push("--resume", opts.sessionId);
@@ -170,9 +165,17 @@ export function runAgent(prompt, opts) {
170
165
  if (!bin) {
171
166
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
172
167
  }
173
- const skipPerms = process.env.SANTREE_SKIP_PERMISSIONS ? ["--dangerously-skip-permissions"] : [];
174
168
  const toolArgs = opts?.allowedTools?.length ? ["--allowedTools", ...opts.allowedTools] : [];
175
- const result = spawnSync(bin, [...skipPerms, ...toolArgs, "-p", "--output-format", "text", "--", promptArg(prompt)], {
169
+ const result = spawnSync(bin, [
170
+ "--permission-mode",
171
+ "auto",
172
+ ...toolArgs,
173
+ "-p",
174
+ "--output-format",
175
+ "text",
176
+ "--",
177
+ promptArg(prompt),
178
+ ], {
176
179
  encoding: "utf-8",
177
180
  maxBuffer: 10 * 1024 * 1024,
178
181
  });
@@ -1,48 +1,332 @@
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
+ import { editExternally } from "./external-editor.js";
9
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
+ // macOS clipboard → PNG. Returns the written file path on success, or null if
11
+ // the clipboard holds no image, the platform isn't macOS, or the write produced
12
+ // a file that isn't actually a PNG. The coercion to «class PNGf» errors when
13
+ // the clipboard holds only text — verified against real clipboards.
14
+ function pasteClipboardImageToTmp() {
15
+ if (process.platform !== "darwin")
16
+ return null;
17
+ const filePath = join(tmpdir(), `santree-paste-${Date.now()}.png`);
18
+ const script = `try
19
+ set pngData to the clipboard as «class PNGf»
20
+ set theFile to POSIX file "${filePath}"
21
+ set fileRef to open for access theFile with write permission
22
+ set eof fileRef to 0
23
+ write pngData to fileRef
24
+ close access fileRef
25
+ return "ok"
26
+ on error
27
+ return "no-image"
28
+ end try`;
29
+ try {
30
+ const result = spawnSync("osascript", ["-e", script], { encoding: "utf-8", timeout: 3000 });
31
+ if (result.status !== 0 || result.stdout.trim() !== "ok")
32
+ return null;
33
+ if (statSync(filePath).size === 0) {
34
+ try {
35
+ unlinkSync(filePath);
36
+ }
37
+ catch { }
38
+ return null;
39
+ }
40
+ const fd = openSync(filePath, "r");
41
+ const header = Buffer.alloc(8);
42
+ readSync(fd, header, 0, 8, 0);
43
+ closeSync(fd);
44
+ if (!header.equals(PNG_MAGIC)) {
45
+ try {
46
+ unlinkSync(filePath);
47
+ }
48
+ catch { }
49
+ return null;
50
+ }
51
+ return filePath;
52
+ }
53
+ catch {
54
+ // osascript unavailable or fs error — silent no-op
55
+ }
56
+ return null;
57
+ }
58
+ // ── Word boundary helpers (whitespace-delimited) ────────────────────────────
59
+ function prevWordStart(text, pos) {
60
+ let p = pos;
61
+ while (p > 0 && /\s/.test(text[p - 1]))
62
+ p--;
63
+ while (p > 0 && /\S/.test(text[p - 1]))
64
+ p--;
65
+ return p;
66
+ }
67
+ function nextWordEnd(text, pos) {
68
+ let p = pos;
69
+ while (p < text.length && /\s/.test(text[p]))
70
+ p++;
71
+ while (p < text.length && /\S/.test(text[p]))
72
+ p++;
73
+ return p;
74
+ }
75
+ function lineStart(text, pos) {
76
+ const before = text.lastIndexOf("\n", pos - 1);
77
+ return before === -1 ? 0 : before + 1;
78
+ }
79
+ function lineEnd(text, pos) {
80
+ const after = text.indexOf("\n", pos);
81
+ return after === -1 ? text.length : after;
82
+ }
83
+ function buildVisualRows(value, innerWidth) {
84
+ const lines = value.length === 0 ? [""] : value.split("\n");
85
+ const rows = [];
86
+ const w = Math.max(1, innerWidth);
87
+ for (let li = 0; li < lines.length; li++) {
88
+ const line = lines[li];
89
+ if (line.length === 0) {
90
+ rows.push({ logicalLine: li, startCol: 0, text: "" });
91
+ continue;
92
+ }
93
+ for (let i = 0; i < line.length; i += w) {
94
+ rows.push({ logicalLine: li, startCol: i, text: line.slice(i, i + w) });
95
+ }
96
+ }
97
+ return rows;
98
+ }
99
+ function cursorVisualPos(rows, value, cursor, innerWidth) {
100
+ const lines = value.length === 0 ? [""] : value.split("\n");
101
+ let logicalLine = 0;
102
+ let lineStartOffset = 0;
103
+ for (let li = 0; li < lines.length; li++) {
104
+ const len = lines[li].length;
105
+ if (cursor <= lineStartOffset + len) {
106
+ logicalLine = li;
107
+ break;
108
+ }
109
+ lineStartOffset += len + 1;
110
+ }
111
+ const colInLine = cursor - lineStartOffset;
112
+ const candidates = rows
113
+ .map((r, i) => ({ r, i }))
114
+ .filter(({ r }) => r.logicalLine === logicalLine);
115
+ for (let ci = 0; ci < candidates.length; ci++) {
116
+ const { r, i } = candidates[ci];
117
+ if (colInLine >= r.startCol && colInLine < r.startCol + r.text.length) {
118
+ return { vRow: i, vCol: colInLine - r.startCol };
119
+ }
120
+ if (colInLine === r.startCol + r.text.length) {
121
+ // Cursor sits at the end of this visual row. If the row is exactly width-full
122
+ // AND there's another visual row in the same logical line, the next typed char
123
+ // belongs at the start of that next row — defer.
124
+ if (r.text.length === innerWidth && ci + 1 < candidates.length) {
125
+ continue;
126
+ }
127
+ // Last row of this logical line and exactly width-full → return a virtual row
128
+ // past the end so the cursor is rendered at col 0 of a fresh row instead of
129
+ // overflowing the right edge.
130
+ if (r.text.length === innerWidth) {
131
+ return { vRow: i + 1, vCol: 0 };
132
+ }
133
+ return { vRow: i, vCol: colInLine - r.startCol };
134
+ }
135
+ }
136
+ const last = candidates[candidates.length - 1];
137
+ if (last)
138
+ return { vRow: last.i, vCol: last.r.text.length };
139
+ return { vRow: 0, vCol: 0 };
140
+ }
3
141
  export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeholder, width, height = 6, focus = true, }) {
142
+ const [cursor, setCursor] = useState(value.length);
143
+ useEffect(() => {
144
+ if (cursor > value.length)
145
+ setCursor(value.length);
146
+ }, [value, cursor]);
147
+ const insertAt = (pos, text) => {
148
+ onChange(value.slice(0, pos) + text + value.slice(pos));
149
+ setCursor(pos + text.length);
150
+ };
151
+ const deleteRange = (from, to) => {
152
+ if (from === to)
153
+ return;
154
+ const lo = Math.min(from, to);
155
+ const hi = Math.max(from, to);
156
+ onChange(value.slice(0, lo) + value.slice(hi));
157
+ setCursor(lo);
158
+ };
4
159
  useInput((input, key) => {
5
- // Ctrl+D submits
160
+ // Ctrl+D: submit
6
161
  if (key.ctrl && input === "d") {
7
162
  onSubmit();
8
163
  return;
9
164
  }
10
- // ESC cancels. Parent disables SGR mouse tracking while this overlay is
11
- // mounted so clicks can no longer masquerade as ESC.
12
- if (key.escape) {
165
+ // Ctrl+C: cancel (preferred over Esc vim users rely on Esc muscle memory)
166
+ if (key.ctrl && input === "c") {
13
167
  onCancel();
14
168
  return;
15
169
  }
170
+ // Ctrl+O: escalate to $SANTREE_EDITOR / $VISUAL / $EDITOR. On save+close
171
+ // the buffer is replaced and the form is auto-submitted (matches git commit).
172
+ if (key.ctrl && input === "o") {
173
+ const result = editExternally(value, "md");
174
+ if (!result.ok)
175
+ return;
176
+ if (result.cancelled) {
177
+ onCancel();
178
+ return;
179
+ }
180
+ onChange(result.content);
181
+ setCursor(result.content.length);
182
+ onSubmit();
183
+ return;
184
+ }
185
+ // Ctrl+V: paste clipboard image as a temp file reference.
186
+ if (key.ctrl && input === "v") {
187
+ const imagePath = pasteClipboardImageToTmp();
188
+ if (imagePath)
189
+ insertAt(cursor, `![pasted image](${imagePath})`);
190
+ return;
191
+ }
192
+ // Esc: swallow without cancelling (vim users hit it constantly).
193
+ if (key.escape)
194
+ return;
195
+ // ── Readline-ish line editing ───────────────────────────────────
196
+ // Ctrl+A: start of line (also what iTerm2 / Ghostty send for Cmd+Left)
197
+ if (key.ctrl && input === "a") {
198
+ setCursor(lineStart(value, cursor));
199
+ return;
200
+ }
201
+ // Ctrl+E: end of line (also what iTerm2 / Ghostty send for Cmd+Right)
202
+ if (key.ctrl && input === "e") {
203
+ setCursor(lineEnd(value, cursor));
204
+ return;
205
+ }
206
+ // Ctrl+W: delete word backwards
207
+ if (key.ctrl && input === "w") {
208
+ deleteRange(prevWordStart(value, cursor), cursor);
209
+ return;
210
+ }
211
+ // Ctrl+U: delete to line start
212
+ if (key.ctrl && input === "u") {
213
+ deleteRange(lineStart(value, cursor), cursor);
214
+ return;
215
+ }
216
+ // Ctrl+K: delete to line end
217
+ if (key.ctrl && input === "k") {
218
+ deleteRange(cursor, lineEnd(value, cursor));
219
+ return;
220
+ }
221
+ // Option+Backspace (meta+backspace): delete word backwards
222
+ if (key.meta && (key.backspace || key.delete)) {
223
+ deleteRange(prevWordStart(value, cursor), cursor);
224
+ return;
225
+ }
226
+ // Option+Left / Option+Right: word jump.
227
+ // Mac terminals (Ghostty/iTerm2/Terminal.app) typically send the emacs-style
228
+ // `\x1bb` / `\x1bf` rather than the meta+arrow CSI sequence, so Ink reports
229
+ // these as `key.meta && input === "b" | "f"`. Cover both forms.
230
+ if (key.meta && (key.leftArrow || input === "b")) {
231
+ setCursor(prevWordStart(value, cursor));
232
+ return;
233
+ }
234
+ if (key.meta && (key.rightArrow || input === "f")) {
235
+ setCursor(nextWordEnd(value, cursor));
236
+ return;
237
+ }
238
+ // Option+Up / Option+Down: doc start/end (used by some Mac terminals)
239
+ if (key.meta && key.upArrow) {
240
+ setCursor(0);
241
+ return;
242
+ }
243
+ if (key.meta && key.downArrow) {
244
+ setCursor(value.length);
245
+ return;
246
+ }
16
247
  if (key.backspace || key.delete) {
17
- onChange(value.slice(0, -1));
248
+ if (cursor === 0)
249
+ return;
250
+ onChange(value.slice(0, cursor - 1) + value.slice(cursor));
251
+ setCursor(cursor - 1);
252
+ return;
253
+ }
254
+ // Plain arrows: visual-row navigation when possible; left/right by 1 char.
255
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
256
+ if (key.leftArrow) {
257
+ setCursor(Math.max(0, cursor - 1));
258
+ return;
259
+ }
260
+ if (key.rightArrow) {
261
+ setCursor(Math.min(value.length, cursor + 1));
262
+ return;
263
+ }
264
+ const innerW = Math.max(1, (width ?? 80) - 4);
265
+ const rows = buildVisualRows(value, innerW);
266
+ const { vRow, vCol } = cursorVisualPos(rows, value, cursor, innerW);
267
+ const targetVRow = key.upArrow ? vRow - 1 : vRow + 1;
268
+ if (targetVRow < 0) {
269
+ setCursor(0);
270
+ return;
271
+ }
272
+ if (targetVRow >= rows.length) {
273
+ setCursor(value.length);
274
+ return;
275
+ }
276
+ const target = rows[targetVRow];
277
+ const targetColInLine = target.startCol + Math.min(vCol, target.text.length);
278
+ let offset = 0;
279
+ const lines = value.length === 0 ? [""] : value.split("\n");
280
+ for (let li = 0; li < target.logicalLine; li++)
281
+ offset += lines[li].length + 1;
282
+ setCursor(offset + targetColInLine);
18
283
  return;
19
284
  }
20
- // Swallow navigation keys this is an append-only text area.
21
- if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab)
285
+ // Tab: insert a literal tab character.
286
+ if (key.tab) {
287
+ insertAt(cursor, "\t");
22
288
  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.
289
+ }
290
+ // Enter: insert newline (also handles paste containing \r).
27
291
  if (key.return) {
28
292
  const chunk = input ? input.replace(/\r\n?/g, "\n") : "\n";
29
- onChange(value + chunk);
293
+ insertAt(cursor, chunk);
30
294
  return;
31
295
  }
32
- // Swallow remaining modifier combos.
33
296
  if (key.ctrl || key.meta)
34
297
  return;
35
298
  if (!input)
36
299
  return;
37
- // Pasted content may embed \r or \r\n — normalize to \n.
38
- const normalized = input.replace(/\r\n?/g, "\n");
39
- onChange(value + normalized);
300
+ insertAt(cursor, input.replace(/\r\n?/g, "\n"));
40
301
  }, { isActive: focus });
41
- const lines = value.length === 0 ? [""] : value.split("\n");
42
- const visibleLines = lines.slice(Math.max(0, lines.length - height));
302
+ const innerWidth = Math.max(1, (width ?? 80) - 4);
303
+ const rows = buildVisualRows(value, innerWidth);
304
+ const { vRow: cursorVRow, vCol: cursorVCol } = cursorVisualPos(rows, value, cursor, innerWidth);
305
+ const totalRows = Math.max(rows.length, cursorVRow + 1);
306
+ let scrollStart = 0;
307
+ if (cursorVRow >= height)
308
+ scrollStart = cursorVRow - height + 1;
309
+ const visibleRows = rows.slice(scrollStart, scrollStart + height);
43
310
  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));
47
- })) }));
311
+ const hiddenAbove = scrollStart;
312
+ const hiddenBelow = Math.max(0, totalRows - scrollStart - height);
313
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_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 })] })) : (Array.from({ length: height }).map((_, i) => {
314
+ const row = visibleRows[i];
315
+ const absoluteVRow = scrollStart + i;
316
+ const isCursorRow = focus && absoluteVRow === cursorVRow;
317
+ if (!row) {
318
+ // Phantom row past the end (cursor sits on a fresh line at wrap boundary)
319
+ if (isCursorRow) {
320
+ return (_jsx(Box, { minHeight: 1, children: _jsx(Text, { inverse: true, children: " " }) }, `phantom-${i}`));
321
+ }
322
+ return _jsx(Box, { minHeight: 1 }, `pad-${i}`);
323
+ }
324
+ if (!isCursorRow) {
325
+ return (_jsx(Box, { minHeight: 1, children: _jsx(Text, { children: row.text }) }, i));
326
+ }
327
+ const before = row.text.slice(0, cursorVCol);
328
+ const atCursor = cursorVCol < row.text.length ? row.text[cursorVCol] : " ";
329
+ const after = cursorVCol < row.text.length ? row.text.slice(cursorVCol + 1) : "";
330
+ return (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: atCursor }), _jsx(Text, { children: after })] }, i));
331
+ })) }), (hiddenAbove > 0 || hiddenBelow > 0) && (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Text, { dimColor: true, children: hiddenAbove > 0 ? `↑ ${hiddenAbove} more above` : "" }), _jsx(Text, { dimColor: true, children: hiddenBelow > 0 ? `${hiddenBelow} more below ↓` : "" })] }))] }));
48
332
  }
@@ -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 {};