@vigent/cli 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/agents.js +65 -0
- package/dist/app.js +2487 -0
- package/dist/builtin-themes.js +352 -0
- package/dist/choices.js +73 -0
- package/dist/cli-engines.js +537 -0
- package/dist/cli.js +440 -0
- package/dist/clipboard.js +56 -0
- package/dist/color.js +126 -0
- package/dist/config-edit.js +61 -0
- package/dist/config.js +406 -0
- package/dist/demo.js +81 -0
- package/dist/diffview.js +94 -0
- package/dist/engines.js +195 -0
- package/dist/export.js +29 -0
- package/dist/float.js +182 -0
- package/dist/import.js +260 -0
- package/dist/keys.js +589 -0
- package/dist/markdown.js +80 -0
- package/dist/palette.js +83 -0
- package/dist/picker.js +301 -0
- package/dist/review.js +158 -0
- package/dist/riftri.js +284 -0
- package/dist/session.js +460 -0
- package/dist/settings.js +174 -0
- package/dist/suggest.js +31 -0
- package/dist/theme-new.js +49 -0
- package/dist/theme.js +487 -0
- package/dist/tree.js +115 -0
- package/dist/version.js +45 -0
- package/dist/vimcolors.js +206 -0
- package/dist/worktree.js +357 -0
- package/package.json +64 -4
package/dist/palette.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a full theme from a small color palette.
|
|
3
|
+
*
|
|
4
|
+
* Only bg, fg and the six accent colors are required; everything else is
|
|
5
|
+
* derived. This is the easiest way to write a custom theme:
|
|
6
|
+
*
|
|
7
|
+
* [palette]
|
|
8
|
+
* bg = "#0b0e14"
|
|
9
|
+
* fg = "#bfbdb6"
|
|
10
|
+
* red = "#f07178" ...
|
|
11
|
+
*/
|
|
12
|
+
import { isLight, mix } from "./color.js";
|
|
13
|
+
export const PALETTE_KEYS = [
|
|
14
|
+
"bg", "fg", "red", "yellow", "green", "cyan", "blue", "magenta",
|
|
15
|
+
"bg_dark", "bg_alt", "selection", "muted", "subtle", "orange", "accent",
|
|
16
|
+
];
|
|
17
|
+
export function fromPalette(name, p) {
|
|
18
|
+
const light = isLight(p.bg);
|
|
19
|
+
const bgDark = p.bg_dark ?? mix(p.bg, "#000000", light ? 0.05 : 0.22);
|
|
20
|
+
const bgAlt = p.bg_alt ?? mix(p.bg, p.fg, 0.08);
|
|
21
|
+
const selection = p.selection ?? mix(p.bg, p.blue, 0.28);
|
|
22
|
+
const muted = p.muted ?? mix(p.bg, p.fg, 0.45);
|
|
23
|
+
const subtle = p.subtle ?? mix(p.bg, p.fg, 0.25);
|
|
24
|
+
const orange = p.orange ?? p.yellow;
|
|
25
|
+
const accent = p.accent ?? p.yellow;
|
|
26
|
+
const badge = (bg) => ({ fg: p.bg, bg, bold: true });
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
background: light ? "light" : "dark",
|
|
30
|
+
// kept so `vigent theme new --from <this>` can start from the exact colors
|
|
31
|
+
palette: { ...p, bg_dark: bgDark, bg_alt: bgAlt, selection, muted, subtle, orange, accent },
|
|
32
|
+
groups: {
|
|
33
|
+
Normal: { fg: p.fg, bg: p.bg },
|
|
34
|
+
NormalFloat: { fg: p.fg, bg: bgDark },
|
|
35
|
+
FloatBorder: { fg: subtle, bg: bgDark },
|
|
36
|
+
FloatTitle: { fg: p.blue, bg: bgDark, bold: true },
|
|
37
|
+
WinSeparator: { fg: subtle },
|
|
38
|
+
StatusLine: { fg: p.fg, bg: bgAlt },
|
|
39
|
+
StatusLineNC: { fg: muted, bg: bgDark },
|
|
40
|
+
WinBar: { fg: p.fg, bold: true },
|
|
41
|
+
CursorLine: { bg: bgAlt },
|
|
42
|
+
CursorLineNr: { fg: accent, bold: true },
|
|
43
|
+
LineNr: { fg: subtle },
|
|
44
|
+
Visual: { bg: selection },
|
|
45
|
+
Comment: { fg: muted, italic: true },
|
|
46
|
+
NonText: { fg: subtle },
|
|
47
|
+
Title: { fg: p.blue, bold: true },
|
|
48
|
+
Directory: { fg: p.blue },
|
|
49
|
+
Identifier: { fg: p.magenta },
|
|
50
|
+
Function: { fg: p.blue },
|
|
51
|
+
String: { fg: p.green },
|
|
52
|
+
Constant: { fg: orange },
|
|
53
|
+
Keyword: { fg: p.magenta },
|
|
54
|
+
Statement: { fg: p.magenta },
|
|
55
|
+
Type: { fg: p.yellow },
|
|
56
|
+
Special: { fg: p.cyan },
|
|
57
|
+
Search: { fg: p.bg, bg: p.yellow },
|
|
58
|
+
CurSearch: { fg: p.bg, bg: orange },
|
|
59
|
+
DiffAdd: { bg: mix(p.bg, p.green, 0.2) },
|
|
60
|
+
DiffDelete: { bg: mix(p.bg, p.red, 0.2) },
|
|
61
|
+
DiffChange: { bg: mix(p.bg, p.blue, 0.15) },
|
|
62
|
+
Added: { fg: p.green },
|
|
63
|
+
Removed: { fg: p.red },
|
|
64
|
+
Changed: { fg: p.blue },
|
|
65
|
+
ErrorMsg: { fg: p.red },
|
|
66
|
+
WarningMsg: { fg: p.yellow },
|
|
67
|
+
ModeMsg: { fg: p.fg, bold: true },
|
|
68
|
+
MoreMsg: { fg: p.green },
|
|
69
|
+
Pmenu: { fg: p.fg, bg: bgAlt },
|
|
70
|
+
PmenuSel: { bg: selection, bold: true },
|
|
71
|
+
VigentSidebar: { fg: p.fg, bg: bgDark },
|
|
72
|
+
VigentIndent: { fg: subtle },
|
|
73
|
+
VigentModeNormal: badge(p.blue),
|
|
74
|
+
VigentModeInsert: badge(p.green),
|
|
75
|
+
VigentModeVisual: badge(p.magenta),
|
|
76
|
+
VigentModeCommand: badge(p.yellow),
|
|
77
|
+
VigentUser: { fg: p.blue, bold: true },
|
|
78
|
+
VigentTool: { fg: p.cyan },
|
|
79
|
+
VigentToolOutput: { fg: muted },
|
|
80
|
+
VigentSpinner: { fg: orange },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating colorscheme picker (`:colorscheme` with no argument).
|
|
3
|
+
*
|
|
4
|
+
* Type to filter, arrows / Ctrl-n / Ctrl-p / Tab to move (each move previews
|
|
5
|
+
* the theme live), Enter to keep it, Esc to go back to the previous theme.
|
|
6
|
+
*/
|
|
7
|
+
import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
8
|
+
import { RESET, sgr } from "./theme.js";
|
|
9
|
+
export const PICKER_ICONS = {
|
|
10
|
+
unicode: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", lt: "├", rt: "┤", current: "●", prompt: "❯" },
|
|
11
|
+
ascii: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", lt: "+", rt: "+", current: "*", prompt: ">" },
|
|
12
|
+
};
|
|
13
|
+
const VISIBLE = 14;
|
|
14
|
+
export class ThemePicker {
|
|
15
|
+
painter;
|
|
16
|
+
themes;
|
|
17
|
+
current;
|
|
18
|
+
icons;
|
|
19
|
+
onPreview;
|
|
20
|
+
onDone;
|
|
21
|
+
requestRender;
|
|
22
|
+
look;
|
|
23
|
+
query = "";
|
|
24
|
+
index = 0;
|
|
25
|
+
scroll = 0;
|
|
26
|
+
constructor(painter, themes, current, icons, onPreview, onDone, requestRender, look = () => undefined) {
|
|
27
|
+
this.painter = painter;
|
|
28
|
+
this.themes = themes;
|
|
29
|
+
this.current = current;
|
|
30
|
+
this.icons = icons;
|
|
31
|
+
this.onPreview = onPreview;
|
|
32
|
+
this.onDone = onDone;
|
|
33
|
+
this.requestRender = requestRender;
|
|
34
|
+
this.look = look;
|
|
35
|
+
this.index = Math.max(0, themes.indexOf(current));
|
|
36
|
+
this.scroll = Math.max(0, this.index - VISIBLE + 1);
|
|
37
|
+
}
|
|
38
|
+
filtered() {
|
|
39
|
+
return this.query ? fuzzyFilter(this.themes, this.query, (t) => t) : this.themes;
|
|
40
|
+
}
|
|
41
|
+
move(delta) {
|
|
42
|
+
const items = this.filtered();
|
|
43
|
+
if (items.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
this.index = (this.index + delta + items.length) % items.length;
|
|
46
|
+
if (this.index < this.scroll)
|
|
47
|
+
this.scroll = this.index;
|
|
48
|
+
if (this.index >= this.scroll + VISIBLE)
|
|
49
|
+
this.scroll = this.index - VISIBLE + 1;
|
|
50
|
+
this.onPreview(items[this.index]);
|
|
51
|
+
}
|
|
52
|
+
handleInput(data) {
|
|
53
|
+
const key = parseKey(data) ?? "";
|
|
54
|
+
switch (key) {
|
|
55
|
+
case "escape":
|
|
56
|
+
case "ctrl+c":
|
|
57
|
+
this.onDone(null);
|
|
58
|
+
return;
|
|
59
|
+
case "enter":
|
|
60
|
+
case "return": {
|
|
61
|
+
const items = this.filtered();
|
|
62
|
+
this.onDone(items[this.index] ?? null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
case "down":
|
|
66
|
+
case "tab":
|
|
67
|
+
case "ctrl+n":
|
|
68
|
+
case "ctrl+j":
|
|
69
|
+
this.move(1);
|
|
70
|
+
break;
|
|
71
|
+
case "up":
|
|
72
|
+
case "shift+tab":
|
|
73
|
+
case "ctrl+p":
|
|
74
|
+
case "ctrl+k":
|
|
75
|
+
this.move(-1);
|
|
76
|
+
break;
|
|
77
|
+
case "backspace":
|
|
78
|
+
this.query = this.query.slice(0, -1);
|
|
79
|
+
this.resetSelection();
|
|
80
|
+
break;
|
|
81
|
+
case "ctrl+u":
|
|
82
|
+
this.query = "";
|
|
83
|
+
this.resetSelection();
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
|
|
87
|
+
this.query += data;
|
|
88
|
+
this.resetSelection();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.requestRender();
|
|
92
|
+
}
|
|
93
|
+
resetSelection() {
|
|
94
|
+
this.index = 0;
|
|
95
|
+
this.scroll = 0;
|
|
96
|
+
const first = this.filtered()[0];
|
|
97
|
+
if (first)
|
|
98
|
+
this.onPreview(first);
|
|
99
|
+
}
|
|
100
|
+
lookCache = new Map();
|
|
101
|
+
looks(name) {
|
|
102
|
+
if (!this.lookCache.has(name))
|
|
103
|
+
this.lookCache.set(name, this.look(name));
|
|
104
|
+
return this.lookCache.get(name);
|
|
105
|
+
}
|
|
106
|
+
invalidate() { }
|
|
107
|
+
render(width) {
|
|
108
|
+
const P = this.painter;
|
|
109
|
+
const I = this.icons;
|
|
110
|
+
const w = Math.max(20, width);
|
|
111
|
+
const inner = w - 2;
|
|
112
|
+
const bg = P.attr("NormalFloat").bg;
|
|
113
|
+
const border = (s) => P.on("FloatBorder", bg) + s + RESET;
|
|
114
|
+
const row = (content, plainWidth, rowBg = bg) => border(I.v) +
|
|
115
|
+
content +
|
|
116
|
+
P.on("NormalFloat", rowBg) +
|
|
117
|
+
" ".repeat(Math.max(0, inner - plainWidth)) +
|
|
118
|
+
RESET +
|
|
119
|
+
border(I.v);
|
|
120
|
+
const items = this.filtered();
|
|
121
|
+
const title = " Colorschemes ";
|
|
122
|
+
const count = ` ${items.length === 0 ? 0 : this.index + 1}/${items.length} `;
|
|
123
|
+
const top = border(I.tl + I.h) +
|
|
124
|
+
P.on("FloatTitle", bg) +
|
|
125
|
+
title +
|
|
126
|
+
RESET +
|
|
127
|
+
border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr);
|
|
128
|
+
const lines = [top];
|
|
129
|
+
const prompt = `${I.prompt} ${this.query}`;
|
|
130
|
+
lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(prompt) + 2));
|
|
131
|
+
lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
|
|
132
|
+
const shown = items.slice(this.scroll, this.scroll + VISIBLE);
|
|
133
|
+
for (let i = 0; i < VISIBLE; i++) {
|
|
134
|
+
const name = shown[i];
|
|
135
|
+
if (name === undefined) {
|
|
136
|
+
lines.push(row("", 0));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const selected = this.scroll + i === this.index;
|
|
140
|
+
const rowBg = selected ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg) : bg;
|
|
141
|
+
const marker = name === this.current ? `${I.current} ` : " ";
|
|
142
|
+
const group = selected ? "PmenuSel" : "NormalFloat";
|
|
143
|
+
const look = this.looks(name);
|
|
144
|
+
// name, then a strip of the theme's own colors, then its tags
|
|
145
|
+
const swatchW = look ? look.colors.length * 2 + 1 : 0;
|
|
146
|
+
const tags = look?.tags.join(" ") ?? "";
|
|
147
|
+
const nameW = Math.max(8, Math.min(24, inner - 2 - swatchW - (tags ? tags.length + 1 : 0)));
|
|
148
|
+
const text = truncateToWidth(`${marker}${name}`, nameW, "…");
|
|
149
|
+
let content = P.on(group, rowBg) + ` ${text}${" ".repeat(Math.max(0, nameW - visibleWidth(text)))} `;
|
|
150
|
+
let used = nameW + 2;
|
|
151
|
+
if (look && inner - used >= swatchW) {
|
|
152
|
+
for (const c of look.colors)
|
|
153
|
+
content += sgr({ bg: c }, P.colorMode) + " ";
|
|
154
|
+
content += RESET + P.on(group, rowBg) + " ";
|
|
155
|
+
used += swatchW;
|
|
156
|
+
if (tags && inner - used >= tags.length) {
|
|
157
|
+
content += P.on("Comment", rowBg) + tags;
|
|
158
|
+
used += tags.length;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lines.push(row(content + RESET, used, rowBg));
|
|
162
|
+
}
|
|
163
|
+
if (items.length === 0)
|
|
164
|
+
lines[3] = row(P.on("Comment", bg) + " no match" + RESET, 10);
|
|
165
|
+
lines.push(border(I.bl + I.h.repeat(Math.max(0, inner - visibleWidth(count) - 1))) +
|
|
166
|
+
P.on("Comment", bg) +
|
|
167
|
+
count +
|
|
168
|
+
RESET +
|
|
169
|
+
border(I.h + I.br));
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A filterable list (`:model`, `:effort`). Type to filter, ↑/↓ (Ctrl-n/p,
|
|
175
|
+
* Tab) to move, Enter picks. With `custom`, Enter on text that matches
|
|
176
|
+
* nothing picks the text itself (a model the list doesn't know).
|
|
177
|
+
*/
|
|
178
|
+
export class ChoicePicker {
|
|
179
|
+
painter;
|
|
180
|
+
icons;
|
|
181
|
+
title;
|
|
182
|
+
current;
|
|
183
|
+
custom;
|
|
184
|
+
onDone;
|
|
185
|
+
requestRender;
|
|
186
|
+
query = "";
|
|
187
|
+
index = 0;
|
|
188
|
+
scroll = 0;
|
|
189
|
+
items = [];
|
|
190
|
+
loading = true;
|
|
191
|
+
constructor(painter, icons, title, items, current, custom, onDone, requestRender) {
|
|
192
|
+
this.painter = painter;
|
|
193
|
+
this.icons = icons;
|
|
194
|
+
this.title = title;
|
|
195
|
+
this.current = current;
|
|
196
|
+
this.custom = custom;
|
|
197
|
+
this.onDone = onDone;
|
|
198
|
+
this.requestRender = requestRender;
|
|
199
|
+
Promise.resolve(items)
|
|
200
|
+
.then((list) => {
|
|
201
|
+
this.items = list;
|
|
202
|
+
this.loading = false;
|
|
203
|
+
this.index = Math.max(0, list.findIndex((c) => c.value === current));
|
|
204
|
+
this.scroll = Math.max(0, this.index - VISIBLE + 1);
|
|
205
|
+
this.requestRender();
|
|
206
|
+
})
|
|
207
|
+
.catch(() => {
|
|
208
|
+
this.loading = false;
|
|
209
|
+
this.requestRender();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
filtered() {
|
|
213
|
+
return this.query ? fuzzyFilter(this.items, this.query, (c) => `${c.value} ${c.note ?? ""}`) : this.items;
|
|
214
|
+
}
|
|
215
|
+
handleInput(data) {
|
|
216
|
+
const key = parseKey(data) ?? "";
|
|
217
|
+
const items = this.filtered();
|
|
218
|
+
switch (key) {
|
|
219
|
+
case "escape":
|
|
220
|
+
case "ctrl+c":
|
|
221
|
+
this.onDone(null);
|
|
222
|
+
return;
|
|
223
|
+
case "enter":
|
|
224
|
+
case "return": {
|
|
225
|
+
const picked = items[this.index]?.value ?? (this.custom && this.query.trim() ? this.query.trim() : undefined);
|
|
226
|
+
if (picked !== undefined)
|
|
227
|
+
this.onDone(picked);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case "down":
|
|
231
|
+
case "tab":
|
|
232
|
+
case "ctrl+n":
|
|
233
|
+
case "ctrl+j":
|
|
234
|
+
if (items.length)
|
|
235
|
+
this.index = (this.index + 1) % items.length;
|
|
236
|
+
break;
|
|
237
|
+
case "up":
|
|
238
|
+
case "shift+tab":
|
|
239
|
+
case "ctrl+p":
|
|
240
|
+
case "ctrl+k":
|
|
241
|
+
if (items.length)
|
|
242
|
+
this.index = (this.index - 1 + items.length) % items.length;
|
|
243
|
+
break;
|
|
244
|
+
case "backspace":
|
|
245
|
+
this.query = this.query.slice(0, -1);
|
|
246
|
+
this.index = 0;
|
|
247
|
+
break;
|
|
248
|
+
case "ctrl+u":
|
|
249
|
+
this.query = "";
|
|
250
|
+
this.index = 0;
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
|
|
254
|
+
this.query += data;
|
|
255
|
+
this.index = 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (this.index < this.scroll)
|
|
259
|
+
this.scroll = this.index;
|
|
260
|
+
if (this.index >= this.scroll + VISIBLE)
|
|
261
|
+
this.scroll = this.index - VISIBLE + 1;
|
|
262
|
+
this.requestRender();
|
|
263
|
+
}
|
|
264
|
+
invalidate() { }
|
|
265
|
+
render(width) {
|
|
266
|
+
const P = this.painter;
|
|
267
|
+
const I = this.icons;
|
|
268
|
+
const inner = Math.max(20, width) - 2;
|
|
269
|
+
const bg = P.attr("NormalFloat").bg;
|
|
270
|
+
const border = (s) => P.on("FloatBorder", bg) + s + RESET;
|
|
271
|
+
const row = (content, used, rowBg = bg) => border(I.v) + content + P.on("NormalFloat", rowBg) + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v);
|
|
272
|
+
const items = this.filtered();
|
|
273
|
+
const title = ` ${this.title} `;
|
|
274
|
+
const lines = [border(I.tl + I.h) + P.on("FloatTitle", bg) + title + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr)];
|
|
275
|
+
lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(this.query) + 4));
|
|
276
|
+
lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
|
|
277
|
+
const shown = items.slice(this.scroll, this.scroll + VISIBLE);
|
|
278
|
+
for (let i = 0; i < VISIBLE; i++) {
|
|
279
|
+
const c = shown[i];
|
|
280
|
+
if (!c) {
|
|
281
|
+
let text = "";
|
|
282
|
+
if (i === 0 && this.loading)
|
|
283
|
+
text = " loading…";
|
|
284
|
+
else if (i === 0 && items.length === 0)
|
|
285
|
+
text = this.custom && this.query.trim() ? ` Enter uses '${this.query.trim()}'` : " no match";
|
|
286
|
+
lines.push(row(P.on("Comment", bg) + truncateToWidth(text, inner, "…") + RESET, Math.min(inner, visibleWidth(text))));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const selected = this.scroll + i === this.index;
|
|
290
|
+
const rowBg = selected ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg) : bg;
|
|
291
|
+
const group = selected ? "PmenuSel" : "NormalFloat";
|
|
292
|
+
const marker = c.value === this.current ? `${I.current} ` : " ";
|
|
293
|
+
const name = truncateToWidth(`${marker}${c.value}`, Math.max(10, inner - 4), "…");
|
|
294
|
+
const note = c.note ? truncateToWidth(` ${c.note}`, Math.max(0, inner - 2 - visibleWidth(name)), "…") : "";
|
|
295
|
+
lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on("Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
|
|
296
|
+
}
|
|
297
|
+
const count = ` ${items.length ? this.index + 1 : 0}/${items.length}${this.custom ? " · type any name" : ""} `;
|
|
298
|
+
lines.push(border(I.bl + I.h.repeat(Math.max(0, inner - visibleWidth(count) - 1))) + P.on("Comment", bg) + count + RESET + border(I.h + I.br));
|
|
299
|
+
return lines;
|
|
300
|
+
}
|
|
301
|
+
}
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting an agent's work out of its worktree: `:merge` and `:pr`.
|
|
3
|
+
*
|
|
4
|
+
* Both first commit whatever the agent left uncommitted, with a conventional
|
|
5
|
+
* message built from the branch (fix/flaky-checkout-test -> "fix: flaky
|
|
6
|
+
* checkout test").
|
|
7
|
+
*
|
|
8
|
+
* :merge squash-merges the branch into the base branch in your checkout (one
|
|
9
|
+
* commit, like GitHub's squash merge). It refuses unless your checkout is on
|
|
10
|
+
* that base branch with no uncommitted changes, and a conflict is rolled back
|
|
11
|
+
* so your checkout is left exactly as it was.
|
|
12
|
+
*
|
|
13
|
+
* :pr pushes the branch and opens a pull request with `gh`, or prints the
|
|
14
|
+
* compare URL when gh isn't available.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
|
+
function git(args, cwd) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
function gitError(err) {
|
|
21
|
+
const e = err;
|
|
22
|
+
const text = String(e.stderr || e.stdout || e.message || err).trim();
|
|
23
|
+
return text.replace(/^(fatal|error): /gm, "").split("\n")[0];
|
|
24
|
+
}
|
|
25
|
+
/** "fix/flaky-checkout-test" -> "fix: flaky checkout test"; "agent/add-search" -> "add search". */
|
|
26
|
+
export function commitMessage(branch) {
|
|
27
|
+
const parts = branch.split("/");
|
|
28
|
+
const name = (parts.pop() ?? branch).replace(/-\d+$/, "").replace(/-/g, " ");
|
|
29
|
+
const type = parts.pop();
|
|
30
|
+
const conventional = ["feat", "fix", "docs", "refactor", "test", "chore", "perf", "ci", "build", "style"];
|
|
31
|
+
return type && conventional.includes(type) ? `${type}: ${name}` : name;
|
|
32
|
+
}
|
|
33
|
+
/** Commit everything the agent left uncommitted. Returns the new commit, or undefined if there was nothing. */
|
|
34
|
+
export function commitPending(wt, message = commitMessage(wt.branch)) {
|
|
35
|
+
if (!wt.path)
|
|
36
|
+
throw new Error("no worktree");
|
|
37
|
+
if (git(["status", "--porcelain"], wt.path) === "")
|
|
38
|
+
return undefined;
|
|
39
|
+
git(["add", "-A"], wt.path);
|
|
40
|
+
try {
|
|
41
|
+
git(["commit", "-q", "-m", message], wt.path);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
throw new Error(`commit failed: ${gitError(err)}`);
|
|
45
|
+
}
|
|
46
|
+
return git(["rev-parse", "--short", "HEAD"], wt.path);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Squash-merge (default) or merge the agent's branch into its base branch,
|
|
50
|
+
* in your checkout at `root`.
|
|
51
|
+
*/
|
|
52
|
+
export function mergeWorktree(root, wt, opts = {}) {
|
|
53
|
+
const squash = opts.squash ?? true;
|
|
54
|
+
if (!wt.path)
|
|
55
|
+
return { ok: false, message: "this agent has no worktree" };
|
|
56
|
+
const base = wt.base;
|
|
57
|
+
if (!base)
|
|
58
|
+
return { ok: false, message: "unknown base branch for this worktree" };
|
|
59
|
+
const current = git(["branch", "--show-current"], root);
|
|
60
|
+
if (current !== base) {
|
|
61
|
+
return { ok: false, message: `your checkout is on '${current || "a detached HEAD"}'; switch it to '${base}' to merge` };
|
|
62
|
+
}
|
|
63
|
+
if (git(["status", "--porcelain", "--untracked-files=no"], root) !== "") {
|
|
64
|
+
return { ok: false, message: `your checkout has uncommitted changes; commit or stash them, then :merge again` };
|
|
65
|
+
}
|
|
66
|
+
let pending;
|
|
67
|
+
try {
|
|
68
|
+
pending = commitPending(wt);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return { ok: false, message: err.message };
|
|
72
|
+
}
|
|
73
|
+
if (git(["rev-list", "--count", `${base}..${wt.branch}`], root) === "0") {
|
|
74
|
+
return { ok: false, message: `nothing to merge: ${wt.branch} has no changes on top of ${base}` };
|
|
75
|
+
}
|
|
76
|
+
const message = commitMessage(wt.branch);
|
|
77
|
+
if (squash) {
|
|
78
|
+
try {
|
|
79
|
+
git(["merge", "--squash", wt.branch], root);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const reason = gitError(err);
|
|
83
|
+
git(["reset", "-q", "--merge"], root); // undo the half-applied squash
|
|
84
|
+
return { ok: false, message: `merge conflict, nothing changed: ${reason}` };
|
|
85
|
+
}
|
|
86
|
+
if (git(["diff", "--cached", "--name-only"], root) === "") {
|
|
87
|
+
git(["reset", "-q", "--merge"], root);
|
|
88
|
+
return { ok: false, message: `nothing to merge: ${wt.branch} is already in ${base}` };
|
|
89
|
+
}
|
|
90
|
+
const log = git(["log", "--reverse", "--format=- %s", `${base}..${wt.branch}`], root);
|
|
91
|
+
git(["commit", "-q", "-m", message, "-m", log], root);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
try {
|
|
95
|
+
git(["merge", "--no-ff", "-q", "-m", `Merge branch '${wt.branch}'`, wt.branch], root);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const reason = gitError(err);
|
|
99
|
+
try {
|
|
100
|
+
git(["merge", "--abort"], root);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// nothing to abort
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, message: `merge conflict, nothing changed: ${reason}` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const commit = git(["rev-parse", "--short", "HEAD"], root);
|
|
109
|
+
const how = squash ? "squash-merged" : "merged";
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
commit,
|
|
113
|
+
message: `${how} ${wt.branch} into ${base} as ${commit}${pending ? " (committed the agent's pending changes first)" : ""}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** https://github.com/owner/repo from an https or ssh remote URL, or undefined for other hosts. */
|
|
117
|
+
export function githubWebUrl(remote) {
|
|
118
|
+
const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote.trim());
|
|
119
|
+
return m ? `https://github.com/${m[1]}/${m[2]}` : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** Push the agent's branch and open a pull request (with gh when it's available). */
|
|
122
|
+
export function openPullRequest(root, wt, opts = {}) {
|
|
123
|
+
const remote = opts.remote ?? "origin";
|
|
124
|
+
if (!wt.path)
|
|
125
|
+
return { ok: false, message: "this agent has no worktree" };
|
|
126
|
+
let remoteUrl;
|
|
127
|
+
try {
|
|
128
|
+
remoteUrl = git(["remote", "get-url", remote], root);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { ok: false, message: `no '${remote}' remote to push to` };
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
commitPending(wt);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, message: err.message };
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
git(["push", "-q", "-u", remote, wt.branch], wt.path);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return { ok: false, message: `git push failed: ${gitError(err)}` };
|
|
144
|
+
}
|
|
145
|
+
const base = wt.base ?? "main";
|
|
146
|
+
if (opts.useGh !== false) {
|
|
147
|
+
const gh = spawnSync("gh", ["pr", "create", "--base", base, "--head", wt.branch, "--title", commitMessage(wt.branch), "--fill"], { cwd: wt.path, encoding: "utf8", timeout: 60_000 });
|
|
148
|
+
const url = /https:\/\/\S+\/pull\/\d+/.exec(`${gh.stdout ?? ""}\n${gh.stderr ?? ""}`)?.[0];
|
|
149
|
+
if (url)
|
|
150
|
+
return { ok: true, url, message: `pull request: ${url}` };
|
|
151
|
+
}
|
|
152
|
+
const web = githubWebUrl(remoteUrl);
|
|
153
|
+
if (web) {
|
|
154
|
+
const url = `${web}/compare/${base}...${wt.branch}?expand=1`;
|
|
155
|
+
return { ok: true, url, message: `pushed ${wt.branch}; open the pull request at ${url}` };
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, message: `pushed ${wt.branch} to ${remote}` };
|
|
158
|
+
}
|