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.
- package/README.md +55 -2
- package/dist/commands/dashboard.js +538 -188
- package/dist/commands/doctor.js +164 -13
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/commands/worktree/diff.d.ts +13 -0
- package/dist/commands/worktree/diff.js +76 -0
- package/dist/lib/ai.d.ts +12 -2
- package/dist/lib/ai.js +48 -14
- package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/DetailPanel.js +235 -89
- package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
- package/dist/lib/dashboard/DiffOverlay.js +243 -0
- package/dist/lib/dashboard/IssueList.d.ts +20 -3
- package/dist/lib/dashboard/IssueList.js +74 -103
- package/dist/lib/dashboard/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
- package/dist/lib/dashboard/ReviewList.d.ts +3 -1
- package/dist/lib/dashboard/ReviewList.js +3 -3
- package/dist/lib/dashboard/data.js +14 -8
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/theme.d.ts +24 -0
- package/dist/lib/dashboard/theme.js +113 -0
- package/dist/lib/dashboard/types.d.ts +52 -1
- package/dist/lib/dashboard/types.js +81 -0
- package/dist/lib/git.d.ts +26 -4
- package/dist/lib/git.js +45 -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/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
- 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
|
-
|
|
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" })] }))] }));
|
|
@@ -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
|
|
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 >
|
|
154
|
-
const contentRows = canScroll ?
|
|
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 }) }))
|
|
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 ?
|
|
50
|
-
rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg,
|
|
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,
|
|
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
|
|
43
|
-
if (sessState && !
|
|
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 && !
|
|
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 && !
|
|
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;
|