santree 0.3.0 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +55 -2
  2. package/dist/commands/dashboard.js +538 -188
  3. package/dist/commands/doctor.js +164 -13
  4. package/dist/commands/helpers/statusline.js +10 -2
  5. package/dist/commands/helpers/text-editor.d.ts +13 -0
  6. package/dist/commands/helpers/text-editor.js +118 -0
  7. package/dist/commands/update.d.ts +15 -0
  8. package/dist/commands/update.js +72 -0
  9. package/dist/commands/worktree/create.d.ts +1 -0
  10. package/dist/commands/worktree/create.js +30 -38
  11. package/dist/commands/worktree/diff.d.ts +13 -0
  12. package/dist/commands/worktree/diff.js +76 -0
  13. package/dist/lib/ai.d.ts +12 -2
  14. package/dist/lib/ai.js +48 -14
  15. package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
  16. package/dist/lib/dashboard/DetailPanel.js +235 -89
  17. package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
  18. package/dist/lib/dashboard/DiffOverlay.js +243 -0
  19. package/dist/lib/dashboard/IssueList.d.ts +20 -3
  20. package/dist/lib/dashboard/IssueList.js +74 -103
  21. package/dist/lib/dashboard/MultilineTextArea.js +225 -82
  22. package/dist/lib/dashboard/Overlays.js +1 -1
  23. package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
  24. package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
  25. package/dist/lib/dashboard/ReviewList.d.ts +3 -1
  26. package/dist/lib/dashboard/ReviewList.js +3 -3
  27. package/dist/lib/dashboard/data.js +14 -8
  28. package/dist/lib/dashboard/external-editor.d.ts +12 -0
  29. package/dist/lib/dashboard/external-editor.js +74 -0
  30. package/dist/lib/dashboard/theme.d.ts +24 -0
  31. package/dist/lib/dashboard/theme.js +113 -0
  32. package/dist/lib/dashboard/types.d.ts +52 -1
  33. package/dist/lib/dashboard/types.js +81 -0
  34. package/dist/lib/git.d.ts +26 -4
  35. package/dist/lib/git.js +45 -33
  36. package/dist/lib/multiplexer/cmux.d.ts +2 -0
  37. package/dist/lib/multiplexer/cmux.js +97 -0
  38. package/dist/lib/multiplexer/index.d.ts +4 -0
  39. package/dist/lib/multiplexer/index.js +20 -0
  40. package/dist/lib/multiplexer/none.d.ts +2 -0
  41. package/dist/lib/multiplexer/none.js +22 -0
  42. package/dist/lib/multiplexer/tmux.d.ts +2 -0
  43. package/dist/lib/multiplexer/tmux.js +82 -0
  44. package/dist/lib/multiplexer/types.d.ts +23 -0
  45. package/dist/lib/multiplexer/types.js +3 -0
  46. package/dist/lib/session-signal.js +5 -8
  47. package/dist/lib/version.d.ts +55 -0
  48. package/dist/lib/version.js +224 -0
  49. package/package.json +1 -1
  50. package/shell/init.zsh.njk +45 -15
@@ -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" })] }))] }));
@@ -5,5 +5,11 @@ interface Props {
5
5
  height: number;
6
6
  width: number;
7
7
  }
8
+ export type ReviewActionItem = {
9
+ key: string;
10
+ label: string;
11
+ color: string;
12
+ };
13
+ export declare function buildReviewActions(item: EnrichedReviewPR): ReviewActionItem[];
8
14
  export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
9
15
  export {};
@@ -18,7 +18,7 @@ function relativeTime(dateStr) {
18
18
  const months = Math.floor(days / 30);
19
19
  return `${months}mo ago`;
20
20
  }
21
- function buildActions(item) {
21
+ export function buildReviewActions(item) {
22
22
  const items = [];
23
23
  if (item.worktree) {
24
24
  items.push({ key: "r", label: "AI Review", color: "cyan" });
@@ -146,12 +146,9 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
146
146
  }
147
147
  }
148
148
  // ── Build actions footer ─────────────────────────────────────────
149
- const actionItems = buildActions(item);
150
- const actionsHeight = 2; // separator + action row
151
- const scrollableHeight = height - actionsHeight;
152
149
  const totalLines = lines.length;
153
- const canScroll = totalLines > scrollableHeight;
154
- const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
150
+ const canScroll = totalLines > height;
151
+ const contentRows = canScroll ? height - 2 : height;
155
152
  const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
156
153
  const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
157
154
  let scrollArrow = null;
@@ -162,5 +159,5 @@ export default function ReviewDetailPanel({ item, scrollOffset, height, width })
162
159
  }
163
160
  // Truncate lines to panel width to prevent overflow into left pane
164
161
  const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
165
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: rule }) }), _jsx(Box, { children: actionItems.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : "white", children: [" ", item.label] })] }, j))) })] }));
162
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) }))] }));
166
163
  }
@@ -5,7 +5,9 @@ interface Props {
5
5
  scrollOffset: number;
6
6
  height: number;
7
7
  width: number;
8
+ /** Theme-adapted selection background. Falls back to dark navy. */
9
+ selectionBg?: string;
8
10
  }
9
11
  export declare function getReviewListRowCount(flatReviews: EnrichedReviewPR[]): number;
10
- export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }: Props): import("react/jsx-runtime").JSX.Element;
12
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
11
13
  export {};
@@ -14,7 +14,7 @@ const HEADER_ROWS = 1;
14
14
  export function getReviewListRowCount(flatReviews) {
15
15
  return HEADER_ROWS + flatReviews.length;
16
16
  }
17
- export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }) {
17
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
18
18
  const listHeight = height - FOOTER_HEIGHT;
19
19
  const numColWidth = 6;
20
20
  const authorColWidth = 12;
@@ -46,8 +46,8 @@ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, h
46
46
  : pr.author.login;
47
47
  const changes = `+${item.additions} -${item.deletions}`;
48
48
  const ci = checksIndicator(item.checks);
49
- const bg = selected ? "#1e3a5f" : undefined;
50
- rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
49
+ const bg = selected ? selectionBg : undefined;
50
+ rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
51
51
  }
52
52
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
53
53
  }
@@ -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, getDiffShortstatAsync, } 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) {
@@ -33,14 +33,15 @@ export async function loadDashboardData(repoRoot) {
33
33
  let reviewsInfo = null;
34
34
  if (wt) {
35
35
  const base = getBaseBranch(wt.branch);
36
- const [gitStatusOutput, ahead, pr] = await Promise.all([
36
+ const [gitStatusOutput, ahead, pr, shortstat] = await Promise.all([
37
37
  getGitStatusAsync(wt.path),
38
38
  getCommitsAheadAsync(wt.path, base),
39
39
  getPRInfoAsync(wt.branch),
40
+ getDiffShortstatAsync(wt.path, base),
40
41
  ]);
41
42
  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)) {
43
+ // Validate against the active multiplexer — if the session has gone, clear stale state
44
+ if (sessState && !isSessionAlive(issue.identifier)) {
44
45
  clearSessionState(repoRoot, issue.identifier);
45
46
  sessState = null;
46
47
  }
@@ -54,6 +55,7 @@ export async function loadDashboardData(repoRoot) {
54
55
  gitStatus: gitStatusOutput,
55
56
  sessionState: ss === "exited" ? null : ss,
56
57
  sessionMessage: sessState?.message ?? null,
58
+ diffStats: shortstat,
57
59
  };
58
60
  prInfo = pr;
59
61
  if (pr) {
@@ -76,10 +78,11 @@ export async function loadDashboardData(repoRoot) {
76
78
  .filter(([tid]) => !consumedTicketIds.has(tid))
77
79
  .map(async ([tid, wt]) => {
78
80
  const base = getBaseBranch(wt.branch);
79
- const [gitStatusOutput, ahead, pr] = await Promise.all([
81
+ const [gitStatusOutput, ahead, pr, shortstat] = await Promise.all([
80
82
  getGitStatusAsync(wt.path),
81
83
  getCommitsAheadAsync(wt.path, base),
82
84
  getPRInfoAsync(wt.branch),
85
+ getDiffShortstatAsync(wt.path, base),
83
86
  ]);
84
87
  let checksInfo = null;
85
88
  let reviewsInfo = null;
@@ -96,7 +99,7 @@ export async function loadDashboardData(repoRoot) {
96
99
  .replace(/-/g, " ")
97
100
  .trim() || tid;
98
101
  let sessState = readSessionState(repoRoot, tid);
99
- if (sessState && !isSessionAliveInTmux(tid)) {
102
+ if (sessState && !isSessionAlive(tid)) {
100
103
  clearSessionState(repoRoot, tid);
101
104
  sessState = null;
102
105
  }
@@ -123,6 +126,7 @@ export async function loadDashboardData(repoRoot) {
123
126
  gitStatus: gitStatusOutput,
124
127
  sessionState: ss === "exited" ? null : ss,
125
128
  sessionMessage: sessState?.message ?? null,
129
+ diffStats: shortstat,
126
130
  },
127
131
  pr,
128
132
  checks: checksInfo,
@@ -255,12 +259,13 @@ export async function loadReviewsData(repoRoot) {
255
259
  if (wt) {
256
260
  const ticketId = extractTicketId(branch);
257
261
  const base = getBaseBranch(branch);
258
- const [gitStatusOutput, ahead] = await Promise.all([
262
+ const [gitStatusOutput, ahead, shortstat] = await Promise.all([
259
263
  getGitStatusAsync(wt.path),
260
264
  getCommitsAheadAsync(wt.path, base),
265
+ getDiffShortstatAsync(wt.path, base),
261
266
  ]);
262
267
  let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
263
- if (sessState && ticketId && !isSessionAliveInTmux(ticketId)) {
268
+ if (sessState && ticketId && !isSessionAlive(ticketId)) {
264
269
  clearSessionState(repoRoot, ticketId);
265
270
  sessState = null;
266
271
  }
@@ -274,6 +279,7 @@ export async function loadReviewsData(repoRoot) {
274
279
  gitStatus: gitStatusOutput,
275
280
  sessionState: ss === "exited" ? null : ss,
276
281
  sessionMessage: sessState?.message ?? null,
282
+ diffStats: shortstat,
277
283
  };
278
284
  }
279
285
  }
@@ -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;