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.
@@ -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
  });
@@ -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
- function offsetToRowCol(value, offset) {
63
- const lines = value.split("\n");
64
- let idx = 0;
65
- for (let r = 0; r < lines.length; r++) {
66
- const len = lines[r].length;
67
- if (offset <= idx + len) {
68
- return [r, offset - idx];
69
- }
70
- idx += len + 1;
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
- const last = lines.length - 1;
73
- return [last, lines[last].length];
97
+ return rows;
74
98
  }
75
- function rowColToOffset(value, row, col) {
76
- const lines = value.split("\n");
77
- const clampedRow = Math.max(0, Math.min(row, lines.length - 1));
78
- let idx = 0;
79
- for (let r = 0; r < clampedRow; r++) {
80
- idx += lines[r].length + 1;
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 clampedCol = Math.max(0, Math.min(col, lines[clampedRow].length));
83
- return idx + clampedCol;
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 deleteBefore = (pos) => {
97
- if (pos === 0)
151
+ const deleteRange = (from, to) => {
152
+ if (from === to)
98
153
  return;
99
- onChange(value.slice(0, pos - 1) + value.slice(pos));
100
- setCursor(pos - 1);
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 submits
160
+ // Ctrl+D: submit
104
161
  if (key.ctrl && input === "d") {
105
162
  onSubmit();
106
163
  return;
107
164
  }
108
- // Ctrl+V try to paste clipboard image as a temp file reference.
109
- // Regular Cmd+V text paste is handled by the terminal and arrives as
110
- // normal input below.
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, `![pasted image](${imagePath})`);
115
- }
116
190
  return;
117
191
  }
118
- // ESC cancels (parent disables SGR mouse tracking while mounted
119
- // so clicks don't masquerade as ESC)
120
- if (key.escape) {
121
- onCancel();
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
- deleteBefore(cursor);
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
- // Arrow navigation column is remembered via col-from-current-pos
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
- const lines = value.split("\n");
131
- const [row, col] = offsetToRowCol(value, cursor);
132
- if (key.upArrow) {
133
- if (row === 0)
134
- setCursor(0);
135
- else
136
- setCursor(rowColToOffset(value, row - 1, col));
137
- }
138
- else if (key.downArrow) {
139
- if (row === lines.length - 1)
140
- setCursor(value.length);
141
- else
142
- setCursor(rowColToOffset(value, row + 1, col));
143
- }
144
- else if (key.leftArrow) {
256
+ if (key.leftArrow) {
145
257
  setCursor(Math.max(0, cursor - 1));
258
+ return;
146
259
  }
147
- else if (key.rightArrow) {
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
- if (key.tab)
285
+ // Tab: insert a literal tab character.
286
+ if (key.tab) {
287
+ insertAt(cursor, "\t");
153
288
  return;
154
- // Enter inserts a newline at cursor. MUST run before meta/ctrl swallow
155
- // so Option+Enter / Ctrl+Enter also insert. When a paste carries content
156
- // alongside \r, append the whole normalized chunk.
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
- const normalized = input.replace(/\r\n?/g, "\n");
168
- insertAt(cursor, normalized);
300
+ insertAt(cursor, input.replace(/\r\n?/g, "\n"));
169
301
  }, { isActive: focus });
170
- const [cursorRow, cursorCol] = offsetToRowCol(value, cursor);
171
- const lines = value.length === 0 ? [""] : value.split("\n");
172
- // Scroll viewport so the cursor row is always visible
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 (cursorRow >= height)
175
- scrollStart = cursorRow - height + 1;
176
- const visibleLines = lines.slice(scrollStart, scrollStart + height);
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
- return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: "cyan", paddingX: 1, minHeight: height + 2, children: isEmpty && placeholder ? (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (visibleLines.map((line, i) => {
179
- const absoluteRow = scrollStart + i;
180
- const isCursorRow = focus && absoluteRow === cursorRow;
181
- if (!isCursorRow) {
182
- return (_jsx(Box, { minHeight: 1, children: _jsx(Text, { children: line }) }, i));
183
- }
184
- const before = line.slice(0, cursorCol);
185
- const atCursor = cursorCol < line.length ? line[cursorCol] : " ";
186
- const after = cursorCol < line.length ? line.slice(cursorCol + 1) : "";
187
- return (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: atCursor }), _jsx(Text, { children: after })] }, i));
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: "Enter" }), " newline ", _jsx(Text, { color: "cyan", bold: true, children: "\u2191\u2193\u2190\u2192" }), " move ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+V" }), " paste image ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " continue ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
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, isSessionAliveInTmux, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
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 tmux — if no claude process is running, clear stale state
43
- if (sessState && !isSessionAliveInTmux(issue.identifier)) {
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 && !isSessionAliveInTmux(tid)) {
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 && !isSessionAliveInTmux(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;