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.
- package/README.md +9 -0
- package/dist/commands/dashboard.js +159 -119
- package/dist/commands/doctor.js +66 -1
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/lib/ai.js +11 -8
- package/dist/lib/dashboard/MultilineTextArea.js +306 -22
- package/dist/lib/dashboard/Overlays.d.ts +2 -2
- package/dist/lib/dashboard/Overlays.js +6 -5
- package/dist/lib/dashboard/data.js +5 -5
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/types.d.ts +13 -1
- package/dist/lib/dashboard/types.js +13 -0
- package/dist/lib/git.d.ts +6 -4
- package/dist/lib/git.js +8 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
|
@@ -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
|
-
|
|
17
|
-
|
|
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 [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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("
|
|
64
|
-
setMessage(
|
|
50
|
+
setStatus("spawning-window");
|
|
51
|
+
setMessage(`Creating ${mux.kind} window...`);
|
|
65
52
|
const windowName = getWindowName(branch, options.name);
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
81
|
-
// Don't output SANTREE_CD when
|
|
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" ||
|
|
172
|
-
|
|
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
|
-
|
|
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, [
|
|
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
|
|
160
|
+
// Ctrl+D: submit
|
|
6
161
|
if (key.ctrl && input === "d") {
|
|
7
162
|
onSubmit();
|
|
8
163
|
return;
|
|
9
164
|
}
|
|
10
|
-
//
|
|
11
|
-
|
|
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, ``);
|
|
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
|
-
|
|
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
|
-
//
|
|
21
|
-
if (key.
|
|
285
|
+
// Tab: insert a literal tab character.
|
|
286
|
+
if (key.tab) {
|
|
287
|
+
insertAt(cursor, "\t");
|
|
22
288
|
return;
|
|
23
|
-
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
const
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
25
|
+
dispatch: React.Dispatch<DashboardAction>;
|
|
26
26
|
}
|
|
27
|
-
export declare function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title,
|
|
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 {};
|