santree 0.3.0 → 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 +115 -95
- 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 +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- 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/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
|
@@ -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
|
});
|
|
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { openSync, readSync, closeSync, statSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { editExternally } from "./external-editor.js";
|
|
8
9
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
9
10
|
// macOS clipboard → PNG. Returns the written file path on success, or null if
|
|
10
11
|
// the clipboard holds no image, the platform isn't macOS, or the write produced
|
|
@@ -26,14 +27,9 @@ on error
|
|
|
26
27
|
return "no-image"
|
|
27
28
|
end try`;
|
|
28
29
|
try {
|
|
29
|
-
const result = spawnSync("osascript", ["-e", script], {
|
|
30
|
-
encoding: "utf-8",
|
|
31
|
-
timeout: 3000,
|
|
32
|
-
});
|
|
30
|
+
const result = spawnSync("osascript", ["-e", script], { encoding: "utf-8", timeout: 3000 });
|
|
33
31
|
if (result.status !== 0 || result.stdout.trim() !== "ok")
|
|
34
32
|
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
33
|
if (statSync(filePath).size === 0) {
|
|
38
34
|
try {
|
|
39
35
|
unlinkSync(filePath);
|
|
@@ -59,32 +55,91 @@ end try`;
|
|
|
59
55
|
}
|
|
60
56
|
return null;
|
|
61
57
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
|
71
96
|
}
|
|
72
|
-
|
|
73
|
-
return [last, lines[last].length];
|
|
97
|
+
return rows;
|
|
74
98
|
}
|
|
75
|
-
function
|
|
76
|
-
const lines = value.split("\n");
|
|
77
|
-
|
|
78
|
-
let
|
|
79
|
-
for (let
|
|
80
|
-
|
|
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;
|
|
81
110
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
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 };
|
|
84
140
|
}
|
|
85
141
|
export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeholder, width, height = 6, focus = true, }) {
|
|
86
142
|
const [cursor, setCursor] = useState(value.length);
|
|
87
|
-
// Keep cursor within bounds if value shrinks externally
|
|
88
143
|
useEffect(() => {
|
|
89
144
|
if (cursor > value.length)
|
|
90
145
|
setCursor(value.length);
|
|
@@ -93,97 +148,185 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
|
|
|
93
148
|
onChange(value.slice(0, pos) + text + value.slice(pos));
|
|
94
149
|
setCursor(pos + text.length);
|
|
95
150
|
};
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
151
|
+
const deleteRange = (from, to) => {
|
|
152
|
+
if (from === to)
|
|
98
153
|
return;
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
|
101
158
|
};
|
|
102
159
|
useInput((input, key) => {
|
|
103
|
-
// Ctrl+D
|
|
160
|
+
// Ctrl+D: submit
|
|
104
161
|
if (key.ctrl && input === "d") {
|
|
105
162
|
onSubmit();
|
|
106
163
|
return;
|
|
107
164
|
}
|
|
108
|
-
// Ctrl+
|
|
109
|
-
|
|
110
|
-
|
|
165
|
+
// Ctrl+C: cancel (preferred over Esc — vim users rely on Esc muscle memory)
|
|
166
|
+
if (key.ctrl && input === "c") {
|
|
167
|
+
onCancel();
|
|
168
|
+
return;
|
|
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.
|
|
111
186
|
if (key.ctrl && input === "v") {
|
|
112
187
|
const imagePath = pasteClipboardImageToTmp();
|
|
113
|
-
if (imagePath)
|
|
188
|
+
if (imagePath)
|
|
114
189
|
insertAt(cursor, ``);
|
|
115
|
-
}
|
|
116
190
|
return;
|
|
117
191
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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);
|
|
122
245
|
return;
|
|
123
246
|
}
|
|
124
247
|
if (key.backspace || key.delete) {
|
|
125
|
-
|
|
248
|
+
if (cursor === 0)
|
|
249
|
+
return;
|
|
250
|
+
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
|
|
251
|
+
setCursor(cursor - 1);
|
|
126
252
|
return;
|
|
127
253
|
}
|
|
128
|
-
//
|
|
254
|
+
// Plain arrows: visual-row navigation when possible; left/right by 1 char.
|
|
129
255
|
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
|
|
130
|
-
|
|
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) {
|
|
256
|
+
if (key.leftArrow) {
|
|
145
257
|
setCursor(Math.max(0, cursor - 1));
|
|
258
|
+
return;
|
|
146
259
|
}
|
|
147
|
-
|
|
260
|
+
if (key.rightArrow) {
|
|
148
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;
|
|
149
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);
|
|
150
283
|
return;
|
|
151
284
|
}
|
|
152
|
-
|
|
285
|
+
// Tab: insert a literal tab character.
|
|
286
|
+
if (key.tab) {
|
|
287
|
+
insertAt(cursor, "\t");
|
|
153
288
|
return;
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
// alongside \r, append the whole normalized chunk.
|
|
289
|
+
}
|
|
290
|
+
// Enter: insert newline (also handles paste containing \r).
|
|
157
291
|
if (key.return) {
|
|
158
292
|
const chunk = input ? input.replace(/\r\n?/g, "\n") : "\n";
|
|
159
293
|
insertAt(cursor, chunk);
|
|
160
294
|
return;
|
|
161
295
|
}
|
|
162
|
-
// Swallow remaining modifier combos
|
|
163
296
|
if (key.ctrl || key.meta)
|
|
164
297
|
return;
|
|
165
298
|
if (!input)
|
|
166
299
|
return;
|
|
167
|
-
|
|
168
|
-
insertAt(cursor, normalized);
|
|
300
|
+
insertAt(cursor, input.replace(/\r\n?/g, "\n"));
|
|
169
301
|
}, { isActive: focus });
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
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);
|
|
173
306
|
let scrollStart = 0;
|
|
174
|
-
if (
|
|
175
|
-
scrollStart =
|
|
176
|
-
const
|
|
307
|
+
if (cursorVRow >= height)
|
|
308
|
+
scrollStart = cursorVRow - height + 1;
|
|
309
|
+
const visibleRows = rows.slice(scrollStart, scrollStart + height);
|
|
177
310
|
const isEmpty = value.length === 0;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 ↓` : "" })] }))] }));
|
|
189
332
|
}
|
|
@@ -22,7 +22,7 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
|
|
|
22
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" })] }));
|
|
23
23
|
}
|
|
24
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: "
|
|
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: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " 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
26
|
.split("\n")
|
|
27
27
|
.slice(0, Math.max(4, height - 12))
|
|
28
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" })] }))] }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState,
|
|
1
|
+
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
|
|
2
2
|
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
|
|
3
3
|
import { fetchAssignedIssues } from "../linear.js";
|
|
4
4
|
export async function loadDashboardData(repoRoot) {
|
|
@@ -39,8 +39,8 @@ export async function loadDashboardData(repoRoot) {
|
|
|
39
39
|
getPRInfoAsync(wt.branch),
|
|
40
40
|
]);
|
|
41
41
|
let sessState = readSessionState(repoRoot, issue.identifier);
|
|
42
|
-
// Validate against
|
|
43
|
-
if (sessState && !
|
|
42
|
+
// Validate against the active multiplexer — if the session has gone, clear stale state
|
|
43
|
+
if (sessState && !isSessionAlive(issue.identifier)) {
|
|
44
44
|
clearSessionState(repoRoot, issue.identifier);
|
|
45
45
|
sessState = null;
|
|
46
46
|
}
|
|
@@ -96,7 +96,7 @@ export async function loadDashboardData(repoRoot) {
|
|
|
96
96
|
.replace(/-/g, " ")
|
|
97
97
|
.trim() || tid;
|
|
98
98
|
let sessState = readSessionState(repoRoot, tid);
|
|
99
|
-
if (sessState && !
|
|
99
|
+
if (sessState && !isSessionAlive(tid)) {
|
|
100
100
|
clearSessionState(repoRoot, tid);
|
|
101
101
|
sessState = null;
|
|
102
102
|
}
|
|
@@ -260,7 +260,7 @@ export async function loadReviewsData(repoRoot) {
|
|
|
260
260
|
getCommitsAheadAsync(wt.path, base),
|
|
261
261
|
]);
|
|
262
262
|
let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
|
|
263
|
-
if (sessState && ticketId && !
|
|
263
|
+
if (sessState && ticketId && !isSessionAlive(ticketId)) {
|
|
264
264
|
clearSessionState(repoRoot, ticketId);
|
|
265
265
|
sessState = null;
|
|
266
266
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface EditExternallyResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
content: string;
|
|
4
|
+
cancelled: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Open the user's editor on a temp file seeded with `initial`, then return the
|
|
8
|
+
* saved content. Empty buffer is treated as cancel (matches `git commit`).
|
|
9
|
+
*
|
|
10
|
+
* Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
|
|
11
|
+
*/
|
|
12
|
+
export declare function editExternally(initial: string, ext?: string): EditExternallyResult;
|