mergestorm 0.3.13 → 0.3.15
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 +14 -15
- package/dist/api.d.ts +82 -5
- package/dist/api.js +166 -7
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/commands/branches.d.ts +6 -2
- package/dist/commands/branches.js +29 -14
- package/dist/commands/browse.d.ts +39 -0
- package/dist/commands/browse.js +259 -0
- package/dist/commands/credits.js +7 -10
- package/dist/commands/help.d.ts +5 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/jobs.d.ts +11 -2
- package/dist/commands/jobs.js +64 -15
- package/dist/commands/login.js +4 -3
- package/dist/commands/logout.js +2 -1
- package/dist/commands/pr.d.ts +22 -0
- package/dist/commands/pr.js +127 -0
- package/dist/commands/queue.d.ts +12 -0
- package/dist/commands/queue.js +160 -0
- package/dist/commands/registry.js +93 -18
- package/dist/commands/review-client.d.ts +13 -0
- package/dist/commands/review-client.js +110 -0
- package/dist/commands/review.d.ts +6 -0
- package/dist/commands/review.js +18 -7
- package/dist/commands/settings.d.ts +17 -0
- package/dist/commands/settings.js +89 -0
- package/dist/commands/skill.d.ts +20 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/stack.d.ts +2 -0
- package/dist/commands/stack.js +91 -57
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +30 -15
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.js +21 -7
- package/dist/errors.d.ts +1 -1
- package/dist/shell.js +39 -31
- package/dist/stack-dto.d.ts +82 -1
- package/dist/ui/ansi.d.ts +2 -0
- package/dist/ui/ansi.js +2 -0
- package/dist/ui/banner.d.ts +34 -2
- package/dist/ui/banner.js +163 -79
- package/dist/ui/findings.d.ts +2 -1
- package/dist/ui/findings.js +13 -7
- package/dist/ui/line-tabs.d.ts +27 -0
- package/dist/ui/line-tabs.js +170 -0
- package/dist/ui/logo.d.ts +10 -2
- package/dist/ui/logo.js +13 -9
- package/dist/ui/present.d.ts +10 -0
- package/dist/ui/present.js +13 -0
- package/dist/ui/prompt.d.ts +58 -1
- package/dist/ui/prompt.js +225 -42
- package/dist/ui/select.js +1 -1
- package/dist/ui/tabs.d.ts +125 -0
- package/dist/ui/tabs.js +480 -0
- package/dist/ui/usage.d.ts +4 -0
- package/dist/ui/usage.js +22 -2
- package/package.json +2 -1
- package/skill/SKILL.md +98 -0
- package/skill/mergestorm-pr-loop/SKILL.md +76 -0
package/dist/ui/tabs.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { ansi } from "./ansi.js";
|
|
4
|
+
import { frameWidth, preferBoxedUi, roundedBox, terminalColumns, } from "./box.js";
|
|
5
|
+
import { padVisible, visibleWidth } from "./width.js";
|
|
6
|
+
import { formatUsagePanel } from "./usage.js";
|
|
7
|
+
import { dropdownViewport } from "./prompt.js";
|
|
8
|
+
export const TAB_ORDER = ["status", "usage", "jobs", "config"];
|
|
9
|
+
const TAB_LABELS = {
|
|
10
|
+
status: "Status",
|
|
11
|
+
usage: "Usage",
|
|
12
|
+
jobs: "Jobs",
|
|
13
|
+
config: "Config",
|
|
14
|
+
};
|
|
15
|
+
export function initialTabsState(tab = "status") {
|
|
16
|
+
return {
|
|
17
|
+
tab,
|
|
18
|
+
scroll: { status: 0, usage: 0, detail: 0 },
|
|
19
|
+
jobsIndex: 0,
|
|
20
|
+
configIndex: 0,
|
|
21
|
+
detail: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function clampNum(value, lo, hi) {
|
|
25
|
+
return Math.min(Math.max(value, lo), Math.max(lo, hi));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One keypress against the browser state. Pure so tests can drive the whole
|
|
29
|
+
* key map without a TTY. Tabs clamp at the ends (no wrap-around, so "which
|
|
30
|
+
* tab am I on" never gets ambiguous); Esc backs out of a job detail before
|
|
31
|
+
* it closes the browser; `q` always closes.
|
|
32
|
+
*/
|
|
33
|
+
export function reduceTabsKey(state, key, ctx) {
|
|
34
|
+
if (key === "q")
|
|
35
|
+
return { kind: "close" };
|
|
36
|
+
if (key === "escape") {
|
|
37
|
+
if (state.detail) {
|
|
38
|
+
return {
|
|
39
|
+
kind: "state",
|
|
40
|
+
state: { ...state, detail: null, scroll: { ...state.scroll, detail: 0 } },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { kind: "close" };
|
|
44
|
+
}
|
|
45
|
+
if (key === "left" || key === "right") {
|
|
46
|
+
const at = TAB_ORDER.indexOf(state.tab);
|
|
47
|
+
const next = key === "left" ? Math.max(0, at - 1) : Math.min(TAB_ORDER.length - 1, at + 1);
|
|
48
|
+
return {
|
|
49
|
+
kind: "state",
|
|
50
|
+
state: {
|
|
51
|
+
...state,
|
|
52
|
+
tab: TAB_ORDER[next],
|
|
53
|
+
detail: null,
|
|
54
|
+
scroll: { ...state.scroll, detail: 0 },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (key === "up" || key === "down") {
|
|
59
|
+
const delta = key === "up" ? -1 : 1;
|
|
60
|
+
if (state.detail) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "state",
|
|
63
|
+
state: {
|
|
64
|
+
...state,
|
|
65
|
+
scroll: {
|
|
66
|
+
...state.scroll,
|
|
67
|
+
detail: clampNum(state.scroll.detail + delta, 0, ctx.maxScroll),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (state.tab === "jobs") {
|
|
73
|
+
return {
|
|
74
|
+
kind: "state",
|
|
75
|
+
state: {
|
|
76
|
+
...state,
|
|
77
|
+
jobsIndex: clampNum(state.jobsIndex + delta, 0, Math.max(0, ctx.jobsCount - 1)),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (state.tab === "config") {
|
|
82
|
+
const count = ctx.config?.length ?? 0;
|
|
83
|
+
return {
|
|
84
|
+
kind: "state",
|
|
85
|
+
state: {
|
|
86
|
+
...state,
|
|
87
|
+
configIndex: clampNum(state.configIndex + delta, 0, Math.max(0, count - 1)),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
kind: "state",
|
|
93
|
+
state: {
|
|
94
|
+
...state,
|
|
95
|
+
scroll: {
|
|
96
|
+
...state.scroll,
|
|
97
|
+
[state.tab]: clampNum(state.scroll[state.tab] + delta, 0, ctx.maxScroll),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (key === "return" && state.tab === "jobs" && !state.detail && ctx.jobsCount > 0) {
|
|
103
|
+
return { kind: "open-job", index: state.jobsIndex, state };
|
|
104
|
+
}
|
|
105
|
+
if (key === "space" && state.tab === "config" && !state.detail) {
|
|
106
|
+
const row = ctx.config?.[state.configIndex];
|
|
107
|
+
// Read-only rows (connected flags) ignore Space instead of erroring.
|
|
108
|
+
if (row?.writable) {
|
|
109
|
+
return { kind: "toggle-setting", key: row.key, value: !row.value, state };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { kind: "state", state };
|
|
113
|
+
}
|
|
114
|
+
/** Body rows available inside the frame: total rows minus chrome + hint. */
|
|
115
|
+
export function tabsBodyRows(rows) {
|
|
116
|
+
const r = typeof rows === "number" && Number.isFinite(rows) && rows >= 1 ? rows : 24;
|
|
117
|
+
return Math.max(3, r - 4);
|
|
118
|
+
}
|
|
119
|
+
export function tabsMaxScroll(layout) {
|
|
120
|
+
return Math.max(0, layout.contentRows - layout.bodyRows);
|
|
121
|
+
}
|
|
122
|
+
function tabsTitle(state, color) {
|
|
123
|
+
const parts = TAB_ORDER.map((tab) => {
|
|
124
|
+
let label = TAB_LABELS[tab];
|
|
125
|
+
if (tab === "jobs" && state.detail) {
|
|
126
|
+
label = `${label} › ${state.detail.jobId.slice(0, 8)}`;
|
|
127
|
+
}
|
|
128
|
+
if (!color)
|
|
129
|
+
return tab === state.tab ? `[${label}]` : ` ${label} `;
|
|
130
|
+
return tab === state.tab ? ansi.boldBrightGreen(label) : ansi.dim(label);
|
|
131
|
+
});
|
|
132
|
+
return parts.join(color ? ansi.dim(" · ") : " · ");
|
|
133
|
+
}
|
|
134
|
+
function jobsBody(state, data, bodyRows, width, color) {
|
|
135
|
+
if (data.jobs.length === 0) {
|
|
136
|
+
const text = data.jobsEmptyText ?? "No review jobs yet. Run `review` to submit one.";
|
|
137
|
+
return [` ${color ? ansi.dim(text) : text}`];
|
|
138
|
+
}
|
|
139
|
+
const header = ["JOB", "STATUS", "VERDICT", "THREAD", "WHEN"];
|
|
140
|
+
const cells = data.jobs.map((j) => [
|
|
141
|
+
j.cells.job,
|
|
142
|
+
j.cells.status,
|
|
143
|
+
j.cells.verdict,
|
|
144
|
+
j.cells.thread,
|
|
145
|
+
j.cells.when,
|
|
146
|
+
]);
|
|
147
|
+
const widths = header.map((h, i) => Math.max(h.length, ...cells.map((r) => r[i]?.length ?? 0)));
|
|
148
|
+
const fmt = (cols) => cols.map((c, i) => padVisible(c, widths[i])).join(" ");
|
|
149
|
+
const lines = [` ${color ? ansi.dim(fmt(header)) : fmt(header)}`];
|
|
150
|
+
const viewportRows = Math.max(1, bodyRows - 1);
|
|
151
|
+
const view = dropdownViewport(data.jobs.length, state.jobsIndex, viewportRows);
|
|
152
|
+
const end = Math.min(data.jobs.length, view.start + viewportRows);
|
|
153
|
+
for (let i = view.start; i < end; i++) {
|
|
154
|
+
const selected = i === state.jobsIndex;
|
|
155
|
+
const marker = selected ? "›" : " ";
|
|
156
|
+
const row = ` ${color && selected ? ansi.brightGreen(marker) : marker} ${fmt(cells[i])}`;
|
|
157
|
+
const clipped = visibleWidth(row) > width ? padVisible(row, width) : row;
|
|
158
|
+
lines.push(color && selected ? ansi.invert(clipped) : clipped);
|
|
159
|
+
}
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
/** On/off rows for the Config tab; scrolls by selection like the jobs list. */
|
|
163
|
+
function configBody(state, data, bodyRows, width, color) {
|
|
164
|
+
if (!data.config || data.config.length === 0) {
|
|
165
|
+
const text = data.configEmptyText ?? "Settings unavailable (offline or older API).";
|
|
166
|
+
return [` ${color ? ansi.dim(text) : text}`];
|
|
167
|
+
}
|
|
168
|
+
const rows = data.config;
|
|
169
|
+
const labelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
170
|
+
const noteRows = data.configNote ? 1 : 0;
|
|
171
|
+
const viewportRows = Math.max(1, bodyRows - noteRows);
|
|
172
|
+
const view = dropdownViewport(rows.length, state.configIndex, viewportRows);
|
|
173
|
+
const end = Math.min(rows.length, view.start + viewportRows);
|
|
174
|
+
const lines = [];
|
|
175
|
+
for (let i = view.start; i < end; i++) {
|
|
176
|
+
const row = rows[i];
|
|
177
|
+
const selected = i === state.configIndex;
|
|
178
|
+
const marker = selected ? "›" : " ";
|
|
179
|
+
const value = row.writable
|
|
180
|
+
? row.value
|
|
181
|
+
? "on"
|
|
182
|
+
: "off"
|
|
183
|
+
: row.value
|
|
184
|
+
? "connected"
|
|
185
|
+
: "not connected";
|
|
186
|
+
const paintedValue = !color
|
|
187
|
+
? value
|
|
188
|
+
: row.writable
|
|
189
|
+
? row.value
|
|
190
|
+
? ansi.brightGreen(value)
|
|
191
|
+
: ansi.dim(value)
|
|
192
|
+
: ansi.dim(value);
|
|
193
|
+
const line = ` ${color && selected ? ansi.brightGreen(marker) : marker} ${padVisible(row.label, labelWidth + 2)}${paintedValue}`;
|
|
194
|
+
const clipped = visibleWidth(line) > width ? padVisible(line, width) : line;
|
|
195
|
+
lines.push(color && selected ? ansi.invert(clipped) : clipped);
|
|
196
|
+
}
|
|
197
|
+
if (data.configNote) {
|
|
198
|
+
const note = ` ${data.configNote}`;
|
|
199
|
+
lines.push(color ? ansi.dim(note) : note);
|
|
200
|
+
}
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
/** Key hint on the left, optional reachability note on the right. */
|
|
204
|
+
export function tabsHintLine(left, right, columns, color) {
|
|
205
|
+
const room = Math.max(1, columns - 1);
|
|
206
|
+
const L = ` ${color ? ansi.dim(left) : left}`;
|
|
207
|
+
if (!right) {
|
|
208
|
+
return visibleWidth(L) > room ? padVisible(L, room) : L;
|
|
209
|
+
}
|
|
210
|
+
const R = color ? ansi.dim(right) : right;
|
|
211
|
+
if (visibleWidth(L) + 1 + visibleWidth(R) > room) {
|
|
212
|
+
const keep = Math.max(0, room - visibleWidth(R) - 1);
|
|
213
|
+
const clippedL = padVisible(L, keep);
|
|
214
|
+
const gap = Math.max(1, room - visibleWidth(clippedL) - visibleWidth(R));
|
|
215
|
+
return `${clippedL}${" ".repeat(gap)}${R}`;
|
|
216
|
+
}
|
|
217
|
+
return `${L}${" ".repeat(Math.max(1, room - visibleWidth(L) - visibleWidth(R)))}${R}`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The whole browser frame for one render: tab strip in the top border, the
|
|
221
|
+
* active tab's body clipped to `rows - 4`, and a dim key hint. Pure; the
|
|
222
|
+
* raw-mode loop and tests both consume it.
|
|
223
|
+
*/
|
|
224
|
+
export function layoutTabs(state, data, columns, rows) {
|
|
225
|
+
const color = data.color ?? false;
|
|
226
|
+
const boxed = preferBoxedUi(columns);
|
|
227
|
+
const cols = terminalColumns(80, columns);
|
|
228
|
+
const outer = frameWidth(80, columns);
|
|
229
|
+
const contentCells = boxed ? Math.max(20, outer - 4) : Math.max(20, cols - 1);
|
|
230
|
+
const bodyRows = tabsBodyRows(rows);
|
|
231
|
+
const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
|
|
232
|
+
let content;
|
|
233
|
+
let contentRows;
|
|
234
|
+
let scroll = 0;
|
|
235
|
+
if (state.detail) {
|
|
236
|
+
content = state.detail.rows;
|
|
237
|
+
contentRows = content.length;
|
|
238
|
+
scroll = state.scroll.detail;
|
|
239
|
+
}
|
|
240
|
+
else if (state.tab === "status") {
|
|
241
|
+
content = data.statusLines;
|
|
242
|
+
contentRows = content.length;
|
|
243
|
+
scroll = state.scroll.status;
|
|
244
|
+
}
|
|
245
|
+
else if (state.tab === "usage") {
|
|
246
|
+
const usageEmpty = data.usageEmptyText ?? "Usage unavailable (offline or older API).";
|
|
247
|
+
content = data.usage
|
|
248
|
+
? formatUsagePanel({ ...data.usage, columns: contentCells, color })
|
|
249
|
+
: [` ${color ? ansi.dim(usageEmpty) : usageEmpty}`];
|
|
250
|
+
contentRows = content.length;
|
|
251
|
+
scroll = state.scroll.usage;
|
|
252
|
+
}
|
|
253
|
+
else if (state.tab === "config") {
|
|
254
|
+
content = configBody(state, data, bodyRows, contentCells, color);
|
|
255
|
+
// Config scrolls by selection inside configBody; no body scroll.
|
|
256
|
+
contentRows = content.length;
|
|
257
|
+
scroll = 0;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
content = jobsBody(state, data, bodyRows, contentCells, color);
|
|
261
|
+
// The jobs list scrolls by selection inside jobsBody; no body scroll.
|
|
262
|
+
contentRows = content.length;
|
|
263
|
+
scroll = 0;
|
|
264
|
+
}
|
|
265
|
+
const start = clampNum(scroll, 0, Math.max(0, contentRows - bodyRows));
|
|
266
|
+
const body = content.slice(start, start + bodyRows).map(clip);
|
|
267
|
+
// Constant frame height regardless of tab, so redraws replace every row.
|
|
268
|
+
while (body.length < bodyRows)
|
|
269
|
+
body.push("");
|
|
270
|
+
const hint = state.detail
|
|
271
|
+
? "↑↓ scroll · esc back to jobs · q quit"
|
|
272
|
+
: state.tab === "jobs"
|
|
273
|
+
? "←→ tabs · ↑↓ select · enter open · q close"
|
|
274
|
+
: state.tab === "config"
|
|
275
|
+
? "←→ tabs · ↑↓ select · space toggle · q close"
|
|
276
|
+
: "←→ tabs · ↑↓ scroll · esc close";
|
|
277
|
+
const clippedHint = tabsHintLine(hint, data.footerNote, cols, color);
|
|
278
|
+
const title = tabsTitle(state, color);
|
|
279
|
+
if (!boxed) {
|
|
280
|
+
const plainTitle = visibleWidth(title) > cols - 1 ? padVisible(title, cols - 1) : title;
|
|
281
|
+
return {
|
|
282
|
+
lines: [plainTitle, ...body, clippedHint],
|
|
283
|
+
bodyRows,
|
|
284
|
+
contentRows,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
lines: [
|
|
289
|
+
...roundedBox(body, { title, width: outer, padding: 1 }),
|
|
290
|
+
clippedHint,
|
|
291
|
+
],
|
|
292
|
+
bodyRows,
|
|
293
|
+
contentRows,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const up = (n) => `\u001b[${n}A`;
|
|
297
|
+
const clearDown = "\u001b[0J";
|
|
298
|
+
/** Resize redraw clears from row 1: old rows may have wrapped (see prompt.ts). */
|
|
299
|
+
const homeClear = `\u001b[H${clearDown}`;
|
|
300
|
+
/**
|
|
301
|
+
* Raw-mode tabbed browser (Status / Usage / Jobs / Config) on the select.ts
|
|
302
|
+
* loop: Left/Right switch tabs, Up/Down scroll or select, Enter opens a job
|
|
303
|
+
* in place, Space toggles a Config setting, `q`/Esc close, resize re-renders.
|
|
304
|
+
* Non-TTY streams fall back to the static panels (callers normally gate on
|
|
305
|
+
* TTY already).
|
|
306
|
+
*/
|
|
307
|
+
export async function runTabsBrowser(opts) {
|
|
308
|
+
const input = opts.input ?? stdin;
|
|
309
|
+
const output = opts.output ?? stdout;
|
|
310
|
+
const data = opts.data;
|
|
311
|
+
if (!input.isTTY || !output.isTTY) {
|
|
312
|
+
const layout = layoutTabs(initialTabsState(opts.initial ?? "status"), data, output.columns, output.rows);
|
|
313
|
+
for (const line of layout.lines)
|
|
314
|
+
console.log(line);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
return new Promise((resolve) => {
|
|
318
|
+
let state = initialTabsState(opts.initial ?? "status");
|
|
319
|
+
let lastLayout = null;
|
|
320
|
+
let linesDrawn = 0;
|
|
321
|
+
let finished = false;
|
|
322
|
+
let detailSeq = 0;
|
|
323
|
+
let toggleSeq = 0;
|
|
324
|
+
const wasRaw = Boolean(input.isRaw);
|
|
325
|
+
readline.emitKeypressEvents(input);
|
|
326
|
+
input.setRawMode(true);
|
|
327
|
+
input.resume();
|
|
328
|
+
function render(prefix) {
|
|
329
|
+
if (prefix) {
|
|
330
|
+
output.write(prefix);
|
|
331
|
+
}
|
|
332
|
+
else if (linesDrawn > 0) {
|
|
333
|
+
output.write(`${up(linesDrawn)}\r${clearDown}`);
|
|
334
|
+
}
|
|
335
|
+
lastLayout = layoutTabs(state, data, output.columns, output.rows);
|
|
336
|
+
output.write(lastLayout.lines.join("\r\n") + "\r\n");
|
|
337
|
+
linesDrawn = lastLayout.lines.length;
|
|
338
|
+
}
|
|
339
|
+
function onResize() {
|
|
340
|
+
if (!finished)
|
|
341
|
+
render(homeClear);
|
|
342
|
+
}
|
|
343
|
+
function cleanup() {
|
|
344
|
+
input.removeListener("keypress", onKeypress);
|
|
345
|
+
output.removeListener("resize", onResize);
|
|
346
|
+
input.setRawMode(wasRaw);
|
|
347
|
+
input.pause();
|
|
348
|
+
}
|
|
349
|
+
function finish() {
|
|
350
|
+
if (finished)
|
|
351
|
+
return;
|
|
352
|
+
finished = true;
|
|
353
|
+
cleanup();
|
|
354
|
+
// Erase the frame so the shell prompt reclaims the space.
|
|
355
|
+
if (linesDrawn > 0)
|
|
356
|
+
output.write(`${up(linesDrawn)}\r${clearDown}`);
|
|
357
|
+
resolve();
|
|
358
|
+
}
|
|
359
|
+
function openJob(index) {
|
|
360
|
+
const job = data.jobs[index];
|
|
361
|
+
if (!job || !opts.loadJobDetail)
|
|
362
|
+
return;
|
|
363
|
+
const seq = ++detailSeq;
|
|
364
|
+
state = {
|
|
365
|
+
...state,
|
|
366
|
+
detail: {
|
|
367
|
+
jobId: job.id,
|
|
368
|
+
rows: [` Loading job ${job.id.slice(0, 8)}…`],
|
|
369
|
+
},
|
|
370
|
+
scroll: { ...state.scroll, detail: 0 },
|
|
371
|
+
};
|
|
372
|
+
render();
|
|
373
|
+
opts
|
|
374
|
+
.loadJobDetail(job.id)
|
|
375
|
+
.then((rows) => {
|
|
376
|
+
if (finished || seq !== detailSeq)
|
|
377
|
+
return;
|
|
378
|
+
if (!state.detail || state.detail.jobId !== job.id)
|
|
379
|
+
return;
|
|
380
|
+
state = { ...state, detail: { jobId: job.id, rows } };
|
|
381
|
+
render();
|
|
382
|
+
})
|
|
383
|
+
.catch((err) => {
|
|
384
|
+
if (finished || seq !== detailSeq)
|
|
385
|
+
return;
|
|
386
|
+
if (!state.detail || state.detail.jobId !== job.id)
|
|
387
|
+
return;
|
|
388
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
389
|
+
state = {
|
|
390
|
+
...state,
|
|
391
|
+
detail: { jobId: job.id, rows: [ansi.red(` ${message}`)] },
|
|
392
|
+
};
|
|
393
|
+
render();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function applyToggle(key, value) {
|
|
397
|
+
if (!opts.toggleSetting)
|
|
398
|
+
return;
|
|
399
|
+
const seq = ++toggleSeq;
|
|
400
|
+
data.configNote = "saving…";
|
|
401
|
+
render();
|
|
402
|
+
opts
|
|
403
|
+
.toggleSetting(key, value)
|
|
404
|
+
.then((rows) => {
|
|
405
|
+
if (finished || seq !== toggleSeq)
|
|
406
|
+
return;
|
|
407
|
+
data.config = rows;
|
|
408
|
+
delete data.configNote;
|
|
409
|
+
render();
|
|
410
|
+
})
|
|
411
|
+
.catch((err) => {
|
|
412
|
+
if (finished || seq !== toggleSeq)
|
|
413
|
+
return;
|
|
414
|
+
// Keep the stored rows; only the note reports the rejection.
|
|
415
|
+
data.configNote = err instanceof Error ? err.message : String(err);
|
|
416
|
+
render();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function onKeypress(str, key) {
|
|
420
|
+
if (finished || !key)
|
|
421
|
+
return;
|
|
422
|
+
if (key.ctrl && key.name === "c") {
|
|
423
|
+
finish();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let name = null;
|
|
427
|
+
if (key.name === "left" || key.name === "right" || key.name === "up" || key.name === "down") {
|
|
428
|
+
name = key.name;
|
|
429
|
+
}
|
|
430
|
+
else if (key.name === "return") {
|
|
431
|
+
name = "return";
|
|
432
|
+
}
|
|
433
|
+
else if (key.name === "space" || str === " ") {
|
|
434
|
+
name = "space";
|
|
435
|
+
}
|
|
436
|
+
else if (key.name === "escape") {
|
|
437
|
+
name = "escape";
|
|
438
|
+
}
|
|
439
|
+
else if (key.name === "q" || str === "q") {
|
|
440
|
+
name = "q";
|
|
441
|
+
}
|
|
442
|
+
if (!name)
|
|
443
|
+
return;
|
|
444
|
+
const step = reduceTabsKey(state, name, {
|
|
445
|
+
jobsCount: data.jobs.length,
|
|
446
|
+
maxScroll: lastLayout ? tabsMaxScroll(lastLayout) : 0,
|
|
447
|
+
config: data.config ?? [],
|
|
448
|
+
});
|
|
449
|
+
if (step.kind === "close") {
|
|
450
|
+
finish();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (step.kind === "open-job") {
|
|
454
|
+
openJob(step.index);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (step.kind === "toggle-setting") {
|
|
458
|
+
applyToggle(step.key, step.value);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
state = step.state;
|
|
462
|
+
try {
|
|
463
|
+
render();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
finish();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
input.on("keypress", onKeypress);
|
|
470
|
+
output.on("resize", onResize);
|
|
471
|
+
try {
|
|
472
|
+
// First paint homes so we do not write from the idle input row
|
|
473
|
+
// (mid-pane) and scroll the welcome chrome.
|
|
474
|
+
render(homeClear);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
finish();
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
package/dist/ui/usage.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export declare function nextUtcMonthStart(now?: Date): string;
|
|
|
20
20
|
* Falls back to next UTC month start when `iso` is missing/invalid.
|
|
21
21
|
*/
|
|
22
22
|
export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
|
|
23
|
+
/** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
|
|
24
|
+
export declare function formatDaysLeft(iso: string | null | undefined, now?: Date): string;
|
|
23
25
|
/** Alias kept for callers that still import `formatResetDate`. */
|
|
24
26
|
export declare function formatResetDate(iso: string | null | undefined): string;
|
|
25
27
|
/** Bar cells so ` [bar] 100% used` stays inside the terminal. */
|
|
@@ -38,6 +40,8 @@ export type UsagePanelInput = {
|
|
|
38
40
|
limit: number | null;
|
|
39
41
|
resetsAt: string | null | undefined;
|
|
40
42
|
jobs: UsagePanelJob[];
|
|
43
|
+
/** When the jobs fetch failed, show this instead of "No review jobs yet." */
|
|
44
|
+
jobsError?: string;
|
|
41
45
|
columns: number;
|
|
42
46
|
color?: boolean;
|
|
43
47
|
};
|
package/dist/ui/usage.js
CHANGED
|
@@ -30,7 +30,7 @@ export function usageBar(used, limit, opts = {}) {
|
|
|
30
30
|
if (!color) {
|
|
31
31
|
return `[${filledSeg}${emptySeg}]${suffix}`;
|
|
32
32
|
}
|
|
33
|
-
let fillColor = ansi.
|
|
33
|
+
let fillColor = ansi.brightGreen;
|
|
34
34
|
if (ratio >= 0.9)
|
|
35
35
|
fillColor = ansi.red;
|
|
36
36
|
else if (ratio >= 0.75)
|
|
@@ -63,6 +63,26 @@ export function formatResetLabel(iso, now = new Date()) {
|
|
|
63
63
|
const mm = String(m).padStart(2, "0");
|
|
64
64
|
return `Resets ${month} ${day}, ${h}:${mm}${ampm} (UTC)`;
|
|
65
65
|
}
|
|
66
|
+
/** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
|
|
67
|
+
export function formatDaysLeft(iso, now = new Date()) {
|
|
68
|
+
if (!iso)
|
|
69
|
+
return "";
|
|
70
|
+
const d = new Date(iso);
|
|
71
|
+
if (Number.isNaN(d.getTime()))
|
|
72
|
+
return "";
|
|
73
|
+
const ms = d.getTime() - now.getTime();
|
|
74
|
+
if (ms <= 0)
|
|
75
|
+
return "reset due";
|
|
76
|
+
const days = Math.floor(ms / 86_400_000);
|
|
77
|
+
const hours = Math.floor((ms % 86_400_000) / 3_600_000);
|
|
78
|
+
if (days >= 2)
|
|
79
|
+
return `${days} days left`;
|
|
80
|
+
if (days === 1)
|
|
81
|
+
return "1 day left";
|
|
82
|
+
if (hours >= 1)
|
|
83
|
+
return `${hours}h left`;
|
|
84
|
+
return "resets soon";
|
|
85
|
+
}
|
|
66
86
|
/** Alias kept for callers that still import `formatResetDate`. */
|
|
67
87
|
export function formatResetDate(iso) {
|
|
68
88
|
return formatResetLabel(iso);
|
|
@@ -95,7 +115,7 @@ export function formatUsagePanel(input) {
|
|
|
95
115
|
"",
|
|
96
116
|
];
|
|
97
117
|
if (input.jobs.length === 0) {
|
|
98
|
-
lines.push(" No review jobs yet.");
|
|
118
|
+
lines.push(input.jobsError ? ` ${input.jobsError}` : " No review jobs yet.");
|
|
99
119
|
return lines.map((line) => clipLine(line, columns));
|
|
100
120
|
}
|
|
101
121
|
const inner = Math.max(20, columns - 2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mergestorm",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
|
+
"skill",
|
|
35
36
|
"LICENSE",
|
|
36
37
|
"README.md"
|
|
37
38
|
],
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mergestorm-review
|
|
3
|
+
description: Review a local branch with Mergestorm Vortex specialists before opening a PR, then apply the findings yourself.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Mergestorm review
|
|
7
|
+
|
|
8
|
+
Use this skill to review a local git branch with Mergestorm, then apply the findings yourself.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
Before opening a PR; after a large refactor; when the user says review, audit, second opinion, or "is this safe to merge".
|
|
13
|
+
|
|
14
|
+
Not for merging, landing, or pushing. For a PR that is already open on GitHub, use the `mergestorm-pr-loop` skill instead.
|
|
15
|
+
|
|
16
|
+
## Install (Claude Code / Cursor)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
mg skill install --claude
|
|
20
|
+
mg skill install --cursor
|
|
21
|
+
# or both: mg skill install --claude --cursor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That writes `.claude/skills/mergestorm-review/SKILL.md` and/or `.cursor/skills/mergestorm-review/SKILL.md` in the current repo.
|
|
25
|
+
|
|
26
|
+
Hand-copy still works: Claude Code project skill `.claude/skills/mergestorm-review/SKILL.md` (or `~/.claude/skills/mergestorm-review/SKILL.md`). Cursor: the same file under `.cursor/skills/mergestorm-review/SKILL.md`; otherwise a rule at `.cursor/rules/mergestorm-review.mdc` with this description and `alwaysApply: false`.
|
|
27
|
+
|
|
28
|
+
MCP server:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -fsSL https://mergestorm.ai/install.sh | bash -s -- --mcp
|
|
32
|
+
# or: npm i -g mergestorm mergestorm-mcp
|
|
33
|
+
claude mcp add mergestorm -- npx -y mergestorm-mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Cursor `~/.cursor/mcp.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"mergestorm": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "mergestorm-mcp"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Preconditions
|
|
50
|
+
|
|
51
|
+
1. Prefer the `mergestorm` MCP server. Call `whoami`. If MCP is missing, require `mg` on PATH with `mg whoami --json` exit 0. If neither is available, stop and print the install one-liner above.
|
|
52
|
+
2. Call `credits` (or `mg credits --json`). If remaining standard credits are 0, say so and stop.
|
|
53
|
+
|
|
54
|
+
## Hire policy
|
|
55
|
+
|
|
56
|
+
Default: omit `router` (account setting).
|
|
57
|
+
|
|
58
|
+
- `router: "off"` for tiny diffs or docs-only
|
|
59
|
+
- `manual` with pins when the change is clearly one domain: `security` (auth/crypto/input), `frontend` (components), `data` (migrations), `api` (route contracts), `tests` (mostly tests), `performance` (hot paths), `architecture` (cross-module moves)
|
|
60
|
+
- `max` only when the user asks for a full pass
|
|
61
|
+
- Never request `governance` or `seam`. Neither exists for local reviews. Seam runs automatically during Mergestorm stack promotes and is controlled by the Agents settings toggle (default on).
|
|
62
|
+
|
|
63
|
+
## Submit
|
|
64
|
+
|
|
65
|
+
Call `review_submit` on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review — never from outside it. Do not paste the diff into context.
|
|
66
|
+
|
|
67
|
+
Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`. Default wait is 300s; on timeout the tool returns `status: in_progress` and the job id — then call `review_wait`.
|
|
68
|
+
|
|
69
|
+
Use `review_get` or `review_list` to resume a known job.
|
|
70
|
+
|
|
71
|
+
Status handling:
|
|
72
|
+
|
|
73
|
+
- status is `no_changes`: success. The branch matches the selected base, no review job was submitted, and no review credit was used. Stop unless the user expected a different base.
|
|
74
|
+
- status is `rate_limited`: wait `retry_after_seconds`. Use `review_list`, `review_get`, or `review_wait` for an existing in-flight job before resubmitting. Do not immediately resubmit the same diff.
|
|
75
|
+
|
|
76
|
+
## Apply findings
|
|
77
|
+
|
|
78
|
+
Read `findings.inline` (and `off_diff` / `offDiff`) sorted by severity. Fix or explain each. Never silence a finding by deleting the test. Then submit again on the same branch so the thread chains, with `context` stating what was addressed.
|
|
79
|
+
|
|
80
|
+
## Never
|
|
81
|
+
|
|
82
|
+
- land, merge, push, or open a PR
|
|
83
|
+
- call stack authoring or land commands
|
|
84
|
+
- wrap the CLI in a shell escape hatch
|
|
85
|
+
- claim a finding is fixed without a diff that touches its path
|
|
86
|
+
- resubmit an identical failing diff (that burns a credit)
|
|
87
|
+
- treat `no_changes` or `rate_limited` as a failed review that needs an immediate resubmit
|
|
88
|
+
- pass `--context-file` or `--context` contents from outside the repo under review
|
|
89
|
+
|
|
90
|
+
## Fallback (no MCP)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mg review --json --context "…"
|
|
94
|
+
# optional: --router … --specialists … --context-file … --thread … --wait / --no-wait --timeout
|
|
95
|
+
# context files must live inside the repo under review
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Parse stdout as one JSON object (`mergestorm.review_job/v1`). Exit 0 includes `no_changes`. Exit 5 means detach and poll with `mg status <id> --json --wait`. Exit 7 means `rate_limited`: wait `retry_after_seconds` before trying again.
|