mergestorm 0.3.9 → 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.
Files changed (76) hide show
  1. package/README.md +67 -24
  2. package/dist/api.d.ts +106 -40
  3. package/dist/api.js +245 -9
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/branches.d.ts +6 -2
  7. package/dist/commands/branches.js +29 -14
  8. package/dist/commands/browse.d.ts +39 -0
  9. package/dist/commands/browse.js +259 -0
  10. package/dist/commands/credits.js +30 -12
  11. package/dist/commands/help.d.ts +5 -0
  12. package/dist/commands/help.js +93 -0
  13. package/dist/commands/jobs.d.ts +11 -2
  14. package/dist/commands/jobs.js +64 -15
  15. package/dist/commands/login.js +32 -10
  16. package/dist/commands/logout.js +2 -1
  17. package/dist/commands/pr.d.ts +22 -0
  18. package/dist/commands/pr.js +127 -0
  19. package/dist/commands/queue.d.ts +12 -0
  20. package/dist/commands/queue.js +160 -0
  21. package/dist/commands/registry.d.ts +1 -1
  22. package/dist/commands/registry.js +115 -33
  23. package/dist/commands/review-client.d.ts +106 -0
  24. package/dist/commands/review-client.js +399 -0
  25. package/dist/commands/review.d.ts +36 -6
  26. package/dist/commands/review.js +395 -145
  27. package/dist/commands/settings.d.ts +17 -0
  28. package/dist/commands/settings.js +89 -0
  29. package/dist/commands/skill.d.ts +20 -0
  30. package/dist/commands/skill.js +145 -0
  31. package/dist/commands/stack.d.ts +36 -2
  32. package/dist/commands/stack.js +341 -113
  33. package/dist/commands/status.d.ts +18 -2
  34. package/dist/commands/status.js +236 -21
  35. package/dist/commands/whoami.d.ts +3 -1
  36. package/dist/commands/whoami.js +21 -7
  37. package/dist/errors.d.ts +18 -2
  38. package/dist/errors.js +22 -1
  39. package/dist/git-stack.d.ts +2 -0
  40. package/dist/git-stack.js +4 -0
  41. package/dist/git.d.ts +12 -1
  42. package/dist/git.js +52 -7
  43. package/dist/shell.d.ts +1 -1
  44. package/dist/shell.js +39 -28
  45. package/dist/stack-dto.d.ts +266 -0
  46. package/dist/stack-dto.js +7 -0
  47. package/dist/submit-pr-base.d.ts +31 -0
  48. package/dist/submit-pr-base.js +69 -0
  49. package/dist/ui/ansi.d.ts +2 -0
  50. package/dist/ui/ansi.js +2 -0
  51. package/dist/ui/banner.d.ts +34 -2
  52. package/dist/ui/banner.js +170 -79
  53. package/dist/ui/box.d.ts +13 -9
  54. package/dist/ui/box.js +28 -44
  55. package/dist/ui/findings.d.ts +4 -1
  56. package/dist/ui/findings.js +14 -7
  57. package/dist/ui/job-envelope.d.ts +68 -0
  58. package/dist/ui/job-envelope.js +35 -0
  59. package/dist/ui/line-tabs.d.ts +27 -0
  60. package/dist/ui/line-tabs.js +170 -0
  61. package/dist/ui/logo.d.ts +10 -2
  62. package/dist/ui/logo.js +13 -9
  63. package/dist/ui/present.d.ts +10 -0
  64. package/dist/ui/present.js +13 -0
  65. package/dist/ui/prompt.d.ts +148 -0
  66. package/dist/ui/prompt.js +433 -71
  67. package/dist/ui/select.js +1 -1
  68. package/dist/ui/tabs.d.ts +125 -0
  69. package/dist/ui/tabs.js +480 -0
  70. package/dist/ui/usage.d.ts +29 -1
  71. package/dist/ui/usage.js +71 -2
  72. package/dist/ui/width.d.ts +26 -0
  73. package/dist/ui/width.js +262 -0
  74. package/package.json +6 -1
  75. package/skill/SKILL.md +98 -0
  76. package/skill/mergestorm-pr-loop/SKILL.md +76 -0
package/dist/ui/banner.js CHANGED
@@ -3,9 +3,9 @@ import { CommandError } from "../errors.js";
3
3
  import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { cliVersion } from "../version.js";
5
5
  import { ansi } from "./ansi.js";
6
- import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, visibleWidth, } from "./box.js";
7
- import { TORNADO_LOGO } from "./logo.js";
8
- import { usageBar } from "./usage.js";
6
+ import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
7
+ import { STORM_MARK, STORM_MARK_WIDTH } from "./logo.js";
8
+ import { formatResetLabel, usageBar } from "./usage.js";
9
9
  const WORDMARK = `
10
10
  __ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
11
11
  | \\/ | __| _ \\/ __| __/ __|_ _/ _ \\| _ \\ \\/ |
@@ -14,7 +14,7 @@ const WORDMARK = `
14
14
  `.trimEnd();
15
15
  /** Used by the non-interactive `usage()` path in cli.ts — the boxed banner is shell-only. */
16
16
  export function printWordmark() {
17
- console.log(ansi.boldGreen(WORDMARK));
17
+ console.log(ansi.boldBrightGreen(WORDMARK));
18
18
  console.log(ansi.dim(` review local diffs fast · mergestorm.ai · v${cliVersion()}`));
19
19
  console.log("");
20
20
  }
@@ -66,53 +66,173 @@ const WHATS_NEW = {
66
66
  "npm description points at MIT build-from-source URL",
67
67
  "Dashboard links → Settings → API / Work (product URL cleanup)",
68
68
  ],
69
+ "0.3.10": [
70
+ "README: verify public git SHA + npm integrity (build from mergestorm-cli)",
71
+ ],
72
+ "0.3.12": [
73
+ "review --router off|standard|max|manual (same smart router as Agents)",
74
+ "review --specialists security,frontend,… to pin or invoke lanes",
75
+ ],
76
+ "0.3.14": [
77
+ "Status / Usage / Jobs tabs (open with usage)",
78
+ "resize-safe home screen",
79
+ ],
80
+ "0.3.15": [
81
+ "mg pr --wait --after-sha, mg settings, mergestorm-pr-loop skill on npm",
82
+ "mg skill install copies review + pr-loop from the packed tarball",
83
+ ],
69
84
  };
70
- function formatStatusLines(me, key, barWidth, keyInvalid = false) {
71
- if (!key) {
72
- return [
73
- `${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in`,
74
- ` or ${ansi.green("login --key")} to paste an API key`,
75
- ];
85
+ function densityFromVariant(variant) {
86
+ if (variant === "mini" || variant === "nano")
87
+ return variant;
88
+ return variant ? "compact" : "full";
89
+ }
90
+ /** Home usage bars stay compact (Claude-style), not full-bleed. */
91
+ const BANNER_BAR_MAX = 30;
92
+ /** `[` + `]` + ` 100% used` around the bar cells. */
93
+ const BAR_CHROME_CELLS = 12;
94
+ function statusLine(state) {
95
+ if (!state.key) {
96
+ return `${ansi.dim("○")} not logged in · type ${ansi.brightGreen("login")} to sign in`;
76
97
  }
77
- if (keyInvalid) {
78
- return [
79
- `${ansi.dim("○")} API key invalid or revoked — type ${ansi.green("login")} to sign in again`,
80
- ];
98
+ if (state.keyInvalid) {
99
+ return `${ansi.dim("○")} API key invalid or revoked · type ${ansi.brightGreen("login")} to sign in again`;
81
100
  }
82
- const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
83
- if (!me) {
84
- return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
101
+ const display = state.me?.key.prefix ?? keyDisplay(state.key) ?? "msk_live_…";
102
+ if (!state.me) {
103
+ return `${ansi.brightGreen("●")} ${ansi.bold(display)} ${ansi.dim("· account details unavailable")}`;
85
104
  }
86
- const plan = me.plan_label_key ?? me.plan_key;
87
- const s = me.usage.standard;
88
- return [
89
- `${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
90
- usageBar(s.used, s.limit, { width: barWidth }),
91
- ];
105
+ const plan = state.me.plan_label_key ?? state.me.plan_key;
106
+ return `${ansi.brightGreen("●")} ${ansi.bold(display)} · ${plan}`;
92
107
  }
93
- function tipsLines() {
94
- return [
95
- ansi.dim("Tips for getting started"),
96
- ` ${ansi.green("review")} review origin/HEAD or main`,
97
- ` ${ansi.green("stack")} create → submit → restack → land`,
98
- ` ${ansi.green("/help")} list all commands`,
99
- ];
108
+ function usageLine(me, contentCells, indent) {
109
+ const room = Math.max(8, contentCells - indent - BAR_CHROME_CELLS);
110
+ const bar = usageBar(me.usage.standard.used, me.usage.standard.limit, {
111
+ width: Math.min(BANNER_BAR_MAX, room),
112
+ });
113
+ const reset = ansi.dim(`· ${formatResetLabel(me.resets_at)}`);
114
+ const combined = `${bar} ${reset}`;
115
+ return visibleWidth(combined) <= contentCells - indent ? combined : bar;
116
+ }
117
+ /** First candidate that fits. Never mid-word clip — omit if nothing fits. */
118
+ function firstFit(options, cells) {
119
+ for (const opt of options) {
120
+ if (visibleWidth(opt) <= cells)
121
+ return opt;
122
+ }
123
+ return null;
124
+ }
125
+ function tipsLine(cells) {
126
+ const sep = ansi.dim(" · ");
127
+ return firstFit([
128
+ [
129
+ `${ansi.brightGreen("review")} ${ansi.dim("a diff")}`,
130
+ `${ansi.brightGreen("usage")} ${ansi.dim("for tabs")}`,
131
+ `${ansi.brightGreen("/help")} ${ansi.dim("for all commands")}`,
132
+ ].join(sep),
133
+ [ansi.brightGreen("review"), ansi.brightGreen("usage"), ansi.brightGreen("/help")].join(sep),
134
+ [ansi.brightGreen("review"), ansi.brightGreen("/help")].join(sep),
135
+ ], cells);
136
+ }
137
+ function tagline(cells) {
138
+ return firstFit([
139
+ ansi.dim("local reviews + stacked PRs · mergestorm.ai"),
140
+ ansi.dim("reviews + stacked PRs"),
141
+ ansi.dim("reviews · stacks"),
142
+ ], cells);
100
143
  }
101
- function whatsNewLines(version) {
144
+ /** Shorten until it fits. Never clip mid-word (that was "…Jobs tabs (open with usa"). */
145
+ function whatsNewLine(version, cells) {
102
146
  const entries = WHATS_NEW[version];
103
147
  if (!entries || !entries.length)
104
- return [];
105
- return [
106
- ansi.dim(`What's new in v${version}`),
107
- ...entries.map((entry) => ` ${ansi.green("•")} ${entry}`),
148
+ return null;
149
+ const first = entries[0];
150
+ const noParen = first.replace(/\s*\([^)]*\)\s*$/, "").trim();
151
+ const candidates = [
152
+ `New in v${version}: ${first}`,
153
+ `New in v${version}: ${noParen}`,
154
+ `New: ${noParen}`,
155
+ `New in v${version}`,
108
156
  ];
157
+ const extra = entries.slice(1);
158
+ const withExtra = `${candidates[0]} · ${extra.join(" · ")}`;
159
+ const options = extra.length > 0 ? [withExtra, ...candidates] : candidates;
160
+ const hit = firstFit(options, cells);
161
+ return hit ? ansi.dim(hit) : null;
109
162
  }
110
- function centerLine(line, width) {
111
- const room = Math.max(0, width - visibleWidth(line));
112
- const left = Math.floor(room / 2);
113
- return `${" ".repeat(left)}${line}${" ".repeat(room - left)}`;
163
+ /**
164
+ * The whole welcome panel as rows, reflowed to `columns`. Every row fits in
165
+ * `columns - 1` cells so a redraw's cursor math stays honest; below
166
+ * MIN_BOXED_COLUMNS the frame drops away instead of wrapping.
167
+ */
168
+ export function buildBannerRows(state, columns, opts) {
169
+ const boxed = preferBoxedUi(columns);
170
+ const density = opts?.density ?? (opts?.compact ? "compact" : "full");
171
+ const outer = frameWidth(80, columns);
172
+ const contentCells = boxed
173
+ ? Math.max(20, outer - 4)
174
+ : Math.max(20, terminalColumns(80, columns) - 1);
175
+ const indent = STORM_MARK_WIDTH + 2;
176
+ const pad = " ".repeat(indent);
177
+ const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
178
+ const mark = (row) => padVisible(ansi.brightGreen(STORM_MARK[row] ?? ""), indent);
179
+ const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
180
+ if (density === "nano") {
181
+ const one = `${mark(0)}${title} ${statusLine(state)}`;
182
+ return [clip(one)];
183
+ }
184
+ if (density === "mini") {
185
+ // Short pane: mark + who you are, then the same cues as the full home.
186
+ const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
187
+ const line1 = state.me && !state.keyInvalid
188
+ ? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
189
+ : clip(mark(1));
190
+ const room = contentCells - indent;
191
+ const blurb = tagline(room);
192
+ const tips = tipsLine(room);
193
+ const fresh = whatsNewLine(state.version, room);
194
+ return [
195
+ line0,
196
+ line1,
197
+ ...(blurb ? [clip(`${pad}${blurb}`)] : []),
198
+ ...(tips ? [clip(`${pad}${tips}`)] : []),
199
+ ...(fresh ? [clip(`${pad}${fresh}`)] : []),
200
+ ];
201
+ }
202
+ const content = [
203
+ clip(`${pad}${title}`),
204
+ clip(`${mark(0)}${statusLine(state)}`),
205
+ ];
206
+ if (state.me && !state.keyInvalid) {
207
+ content.push(clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`));
208
+ }
209
+ else {
210
+ content.push(clip(mark(1)));
211
+ }
212
+ const room = contentCells - indent;
213
+ const blurb = tagline(room);
214
+ const tips = tipsLine(room);
215
+ const fresh = whatsNewLine(state.version, room);
216
+ if (blurb)
217
+ content.push(clip(`${pad}${blurb}`));
218
+ if (tips)
219
+ content.push(clip(`${pad}${tips}`));
220
+ if (fresh)
221
+ content.push(clip(`${pad}${fresh}`));
222
+ if (boxed && density === "full") {
223
+ content.splice(3, 0, "");
224
+ return [...roundedBox(content, { width: outer, padding: 1 })];
225
+ }
226
+ if (boxed) {
227
+ return [...roundedBox(content, { width: outer, padding: 1 })];
228
+ }
229
+ return content.map(clip);
114
230
  }
115
- export async function printBannerHeader() {
231
+ /**
232
+ * Print the welcome panel and return a handle whose `rows()` reflows the
233
+ * same data for a new width (used by the prompt's resize redraw).
234
+ */
235
+ export async function printBannerHeader(opts) {
116
236
  const cfg = await loadConfig();
117
237
  const key = resolveApiKey(cfg);
118
238
  let me = null;
@@ -131,46 +251,17 @@ export async function printBannerHeader() {
131
251
  }
132
252
  }
133
253
  }
134
- const version = cliVersion();
135
- const outer = frameWidth();
136
- // Inner content width inside the box (borders + padding).
137
- const inner = Math.max(40, outer - 4);
138
- // Two columns: left = logo + status, right = tips + what's new.
139
- const leftColW = Math.max(28, Math.floor(inner * 0.42));
140
- const rightColW = Math.max(24, inner - leftColW - 2);
141
- const barWidth = Math.max(12, leftColW - 4);
142
- const logoLines = TORNADO_LOGO.map((line) => centerLine(ansi.brightGreen(line), leftColW));
143
- const leftCol = [
144
- ...logoLines,
145
- "",
146
- ...formatStatusLines(me, key, barWidth, keyInvalid),
147
- ];
148
- const rightCol = [
149
- ...tipsLines(),
150
- "",
151
- ...whatsNewLines(version),
152
- ];
153
- // Clip right column lines that overflow the right pane.
154
- const rightClipped = rightCol.map((line) => visibleWidth(line) <= rightColW ? line : padVisible(line, rightColW));
155
- const rows = sideBySide(leftCol, rightClipped, leftColW, 2);
156
- if (!preferBoxedUi()) {
157
- // Narrow panes — plain lines beat wrapped box frames.
158
- console.log(ansi.boldGreen(`mergestorm v${version}`));
159
- for (const line of rows)
254
+ const state = { version: cliVersion(), me, key, keyInvalid };
255
+ if (opts?.paint !== false) {
256
+ for (const line of buildBannerRows(state)) {
160
257
  console.log(line);
161
- console.log("");
162
- return;
163
- }
164
- const panel = roundedBox(rows, {
165
- title: `mergestorm v${version}`,
166
- width: outer,
167
- padding: 1,
168
- });
169
- for (const line of panel) {
170
- console.log(line);
258
+ }
171
259
  }
172
- console.log("");
260
+ return {
261
+ rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
262
+ };
173
263
  }
174
- export function shellPrompt(loggedIn) {
175
- return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
264
+ export function shellPrompt(_loggedIn) {
265
+ // Bright mark green. Never dim, never 32, never the word "mergestorm".
266
+ return ansi.boldBrightGreen("mg");
176
267
  }
package/dist/ui/box.d.ts CHANGED
@@ -1,24 +1,30 @@
1
- export declare function visibleWidth(str: string): number;
1
+ import { padVisible, sliceVisible, visibleWidth } from "./width.js";
2
+ export { padVisible, sliceVisible, visibleWidth };
2
3
  /** Below this width, boxed chrome wraps on real TTYs — prefer plain output. */
3
4
  export declare const MIN_BOXED_COLUMNS = 48;
4
- /** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
5
- export declare function terminalColumns(fallback?: number): number;
5
+ /**
6
+ * Usable terminal columns (TTY), with a sensible fallback for pipes/tests.
7
+ * Pass `columns` to measure a stream other than process.stdout (or a
8
+ * post-resize value); `undefined` reads stdout at call time.
9
+ */
10
+ export declare function terminalColumns(fallback?: number, columns?: number): number;
6
11
  /**
7
12
  * Whether rounded/full-bleed boxes fit without wrapping.
8
13
  * Non-TTY (unknown columns) keeps boxes for tests and CI logs.
9
14
  */
10
- export declare function preferBoxedUi(): boolean;
15
+ export declare function preferBoxedUi(columns?: number): boolean;
11
16
  /**
12
17
  * Outer box width for Claude-style full-bleed frames.
13
18
  * Never wider than the terminal (minus 1 so the right border doesn't wrap).
14
19
  */
15
- export declare function frameWidth(fallback?: number): number;
20
+ export declare function frameWidth(fallback?: number, columns?: number): number;
16
21
  export interface RoundedBoxOptions {
17
22
  title?: string;
18
23
  padding?: number;
19
24
  /**
20
- * Exact outer width (including borders). When set, content is padded to fill.
21
- * Prefer this for full-width Claude-style frames.
25
+ * Exact outer width (including borders). When set, content is padded to
26
+ * fill and *clipped* to fit: no row is ever wider than this, because a row
27
+ * that wraps is a row the redraw math doesn't know about.
22
28
  */
23
29
  width?: number;
24
30
  /** Grow to at least this outer width (content may still be wider). */
@@ -29,8 +35,6 @@ export interface RoundedBoxOptions {
29
35
  * `minWidth` to stretch to the terminal like Claude Code.
30
36
  */
31
37
  export declare function roundedBox(lines: string[], opts?: RoundedBoxOptions): string[];
32
- /** Pad/truncate a line to an exact visible width (ANSI-safe). */
33
- export declare function padVisible(line: string, width: number): string;
34
38
  /**
35
39
  * Join left/right columns into rows of equal left-column width.
36
40
  * Extra rows on either side are blank-padded.
package/dist/ui/box.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { stdout } from "node:process";
2
2
  import { ansi } from "./ansi.js";
3
- const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
4
- export function visibleWidth(str) {
5
- return str.replace(ANSI_PATTERN, "").length;
6
- }
3
+ import { padVisible, sliceVisible, visibleWidth } from "./width.js";
4
+ // Cell arithmetic lives in width.ts; re-exported so existing `./box.js`
5
+ // importers (banner, prompt) keep working.
6
+ export { padVisible, sliceVisible, visibleWidth };
7
7
  /** Below this width, boxed chrome wraps on real TTYs — prefer plain output. */
8
8
  export const MIN_BOXED_COLUMNS = 48;
9
- /** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
10
- export function terminalColumns(fallback = 80) {
11
- const cols = stdout.columns;
9
+ /**
10
+ * Usable terminal columns (TTY), with a sensible fallback for pipes/tests.
11
+ * Pass `columns` to measure a stream other than process.stdout (or a
12
+ * post-resize value); `undefined` reads stdout at call time.
13
+ */
14
+ export function terminalColumns(fallback = 80, columns) {
15
+ const cols = columns ?? stdout.columns;
12
16
  if (typeof cols === "number" && Number.isFinite(cols) && cols >= 1) {
13
17
  return cols;
14
18
  }
@@ -18,8 +22,8 @@ export function terminalColumns(fallback = 80) {
18
22
  * Whether rounded/full-bleed boxes fit without wrapping.
19
23
  * Non-TTY (unknown columns) keeps boxes for tests and CI logs.
20
24
  */
21
- export function preferBoxedUi() {
22
- const cols = stdout.columns;
25
+ export function preferBoxedUi(columns) {
26
+ const cols = columns ?? stdout.columns;
23
27
  if (typeof cols !== "number" || !Number.isFinite(cols))
24
28
  return true;
25
29
  return cols >= MIN_BOXED_COLUMNS;
@@ -28,8 +32,8 @@ export function preferBoxedUi() {
28
32
  * Outer box width for Claude-style full-bleed frames.
29
33
  * Never wider than the terminal (minus 1 so the right border doesn't wrap).
30
34
  */
31
- export function frameWidth(fallback = 80) {
32
- return Math.max(20, terminalColumns(fallback) - 1);
35
+ export function frameWidth(fallback = 80, columns) {
36
+ return Math.max(20, terminalColumns(fallback, columns) - 1);
33
37
  }
34
38
  const ROUNDED = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
35
39
  const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
@@ -49,12 +53,17 @@ function centerLabel(label, width, fill) {
49
53
  export function roundedBox(lines, opts = {}) {
50
54
  const padding = Math.max(0, opts.padding ?? 1);
51
55
  const chars = boxChars();
52
- const title = opts.title ? ` ${opts.title} ` : "";
56
+ let title = opts.title ? ` ${opts.title} ` : "";
53
57
  const contentInner = Math.max(0, ...lines.map(visibleWidth), visibleWidth(title)) + padding * 2;
54
58
  let innerWidth = contentInner;
59
+ let body = lines;
55
60
  if (opts.width !== undefined) {
56
- // outer = inner + 2 border cols
57
- innerWidth = Math.max(contentInner, opts.width - 2);
61
+ // outer = inner + 2 border cols; clamp, never grow.
62
+ innerWidth = Math.max(0, opts.width - 2);
63
+ const contentCells = Math.max(0, innerWidth - padding * 2);
64
+ body = lines.map((line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line);
65
+ if (visibleWidth(title) > innerWidth)
66
+ title = sliceVisible(title, 0, innerWidth);
58
67
  }
59
68
  else if (opts.minWidth !== undefined) {
60
69
  innerWidth = Math.max(contentInner, opts.minWidth - 2);
@@ -63,37 +72,12 @@ export function roundedBox(lines, opts = {}) {
63
72
  ? `${chars.tl}${centerLabel(title, innerWidth, chars.h)}${chars.tr}`
64
73
  : `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`);
65
74
  const bottom = ansi.gray(`${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`);
66
- const body = lines.map((line) => {
67
- const gap = Math.max(0, innerWidth - padding - visibleWidth(line));
68
- return `${ansi.gray(chars.v)}${" ".repeat(padding)}${line}${" ".repeat(gap)}${ansi.gray(chars.v)}`;
75
+ const leftPad = Math.min(padding, innerWidth);
76
+ const rows = body.map((line) => {
77
+ const gap = Math.max(0, innerWidth - leftPad - visibleWidth(line));
78
+ return `${ansi.gray(chars.v)}${" ".repeat(leftPad)}${line}${" ".repeat(gap)}${ansi.gray(chars.v)}`;
69
79
  });
70
- return [top, ...body, bottom];
71
- }
72
- /** Pad/truncate a line to an exact visible width (ANSI-safe). */
73
- export function padVisible(line, width) {
74
- const w = visibleWidth(line);
75
- if (w === width)
76
- return line;
77
- if (w < width)
78
- return `${line}${" ".repeat(width - w)}`;
79
- // Truncate while preserving ANSI codes: walk the string counting only
80
- // visible characters, then slice the original at that position.
81
- let visible = 0;
82
- let i = 0;
83
- const re = /\u001b\[[0-9;]*m/g;
84
- while (visible < width && i < line.length) {
85
- re.lastIndex = i;
86
- const m = re.exec(line);
87
- if (m && m.index === i) {
88
- i = re.lastIndex;
89
- }
90
- else {
91
- const code = line.charCodeAt(i);
92
- i += code >= 0xd800 && code <= 0xdbff ? 2 : 1;
93
- visible++;
94
- }
95
- }
96
- return line.slice(0, i);
80
+ return [top, ...rows, bottom];
97
81
  }
98
82
  /**
99
83
  * Join left/right columns into rows of equal left-column width.
@@ -1,11 +1,13 @@
1
1
  /** Shared review-job findings payload (API may send snake or camel for off-diff). */
2
2
  export type ReviewFindings = {
3
+ specialists_run?: string[];
3
4
  inline?: {
4
5
  path: string;
5
6
  line: number;
6
7
  severity: string;
7
8
  body: string;
8
9
  title?: string;
10
+ specialist?: string;
9
11
  }[];
10
12
  off_diff?: {
11
13
  path?: string;
@@ -24,5 +26,6 @@ export type RenderFindingsOptions = {
24
26
  /** When true and both lists are empty, print the empty message. */
25
27
  emptyMessage?: boolean;
26
28
  };
27
- /** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
29
+ export declare function formatFindingsLines(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): string[];
30
+ /** Print inline + off-diff findings the same way for oneshot `review` / `status --pretty`. */
28
31
  export declare function renderFindings(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): void;
@@ -1,16 +1,23 @@
1
- /** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
2
- export function renderFindings(findings, opts = {}) {
1
+ export function formatFindingsLines(findings, opts = {}) {
2
+ const lines = [];
3
3
  const inline = findings?.inline ?? [];
4
4
  const off = findings?.off_diff ?? findings?.offDiff ?? [];
5
5
  for (const c of inline) {
6
- console.log(`\n[${c.severity}] ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
7
- console.log(c.body);
6
+ const lane = c.specialist ? `[${c.specialist}]` : "";
7
+ lines.push(`[${c.severity}]${lane} ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
8
+ lines.push(c.body);
8
9
  }
9
10
  for (const c of off) {
10
- console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
11
- console.log(c.body);
11
+ lines.push(`[${c.severity}] off-diff ${c.path ?? ""}`);
12
+ lines.push(c.body);
12
13
  }
13
14
  if (opts.emptyMessage && inline.length === 0 && off.length === 0) {
14
- console.log("\nNo findings.");
15
+ lines.push("No findings.");
15
16
  }
17
+ return lines;
18
+ }
19
+ /** Print inline + off-diff findings the same way for oneshot `review` / `status --pretty`. */
20
+ export function renderFindings(findings, opts = {}) {
21
+ for (const line of formatFindingsLines(findings, opts))
22
+ console.log(line);
16
23
  }
@@ -0,0 +1,68 @@
1
+ import type { ReviewFindings } from "./findings.js";
2
+ export declare const REVIEW_JOB_ENVELOPE_SCHEMA: "mergestorm.review_job/v1";
3
+ export type ReviewJobRow = {
4
+ job_id?: string | null;
5
+ id?: string | null;
6
+ status?: string | null;
7
+ thread_slug?: string | null;
8
+ thread_job_number?: number | null;
9
+ base_label?: string | null;
10
+ head_label?: string | null;
11
+ router_mode?: string | null;
12
+ specialists_requested?: string[] | null;
13
+ specialists_run?: string[] | null;
14
+ verdict?: string | null;
15
+ summary?: string | null;
16
+ findings?: ReviewFindings | null;
17
+ truncated?: boolean | null;
18
+ error?: string | null;
19
+ credits?: {
20
+ standard: number;
21
+ } | null;
22
+ webhook_secret?: string | null;
23
+ created_at?: string | null;
24
+ finished_at?: string | null;
25
+ retry_after_seconds?: number | null;
26
+ };
27
+ export type ReviewJobEnvelope = {
28
+ schema: typeof REVIEW_JOB_ENVELOPE_SCHEMA;
29
+ job_id: string | null;
30
+ status: string;
31
+ thread: {
32
+ slug: string;
33
+ job_number: number | null;
34
+ } | null;
35
+ base_label: string | null;
36
+ head_label: string | null;
37
+ router_mode: string | null;
38
+ specialists_requested: string[];
39
+ specialists_run: string[];
40
+ verdict: string | null;
41
+ summary: string | null;
42
+ findings: ReviewFindings | null;
43
+ truncated: boolean;
44
+ error: string | null;
45
+ credits: {
46
+ standard: number;
47
+ } | null;
48
+ webhook_secret: string | null;
49
+ created_at: string | null;
50
+ finished_at: string | null;
51
+ retry_after_seconds?: number;
52
+ };
53
+ export type ReviewEnvelopeFallbacks = {
54
+ jobId?: string | null;
55
+ status?: string;
56
+ threadSlug?: string | null;
57
+ baseLabel?: string | null;
58
+ headLabel?: string | null;
59
+ routerMode?: string | null;
60
+ specialistsRequested?: string[];
61
+ error?: string | null;
62
+ retryAfterSeconds?: number | null;
63
+ };
64
+ /**
65
+ * Stable machine envelope shared by submit and terminal review output.
66
+ * Fields added by the API-envelope work are nullable until that API lands.
67
+ */
68
+ export declare function toReviewJobEnvelope(row: ReviewJobRow | null | undefined, fallback?: ReviewEnvelopeFallbacks): ReviewJobEnvelope;
@@ -0,0 +1,35 @@
1
+ export const REVIEW_JOB_ENVELOPE_SCHEMA = "mergestorm.review_job/v1";
2
+ /**
3
+ * Stable machine envelope shared by submit and terminal review output.
4
+ * Fields added by the API-envelope work are nullable until that API lands.
5
+ */
6
+ export function toReviewJobEnvelope(row, fallback = {}) {
7
+ const slug = row?.thread_slug ?? fallback.threadSlug ?? null;
8
+ const findings = row?.findings ?? null;
9
+ const retryAfter = fallback.retryAfterSeconds ?? row?.retry_after_seconds ?? undefined;
10
+ return {
11
+ schema: REVIEW_JOB_ENVELOPE_SCHEMA,
12
+ job_id: row?.job_id ?? row?.id ?? fallback.jobId ?? null,
13
+ status: row?.status ?? fallback.status ?? "failed",
14
+ thread: slug
15
+ ? { slug, job_number: row?.thread_job_number ?? null }
16
+ : null,
17
+ base_label: row?.base_label ?? fallback.baseLabel ?? null,
18
+ head_label: row?.head_label ?? fallback.headLabel ?? null,
19
+ router_mode: row?.router_mode ?? fallback.routerMode ?? null,
20
+ specialists_requested: row?.specialists_requested ?? fallback.specialistsRequested ?? [],
21
+ specialists_run: row?.specialists_run ?? findings?.specialists_run ?? [],
22
+ verdict: row?.verdict ?? null,
23
+ summary: row?.summary ?? null,
24
+ findings,
25
+ truncated: row?.truncated ?? false,
26
+ error: fallback.error !== undefined ? fallback.error : (row?.error ?? null),
27
+ credits: row?.credits ?? null,
28
+ webhook_secret: row?.webhook_secret ?? null,
29
+ created_at: row?.created_at ?? null,
30
+ finished_at: row?.finished_at ?? null,
31
+ ...(retryAfter != null && Number.isFinite(retryAfter)
32
+ ? { retry_after_seconds: retryAfter }
33
+ : {}),
34
+ };
35
+ }
@@ -0,0 +1,27 @@
1
+ import { type TabsLayout } from "./tabs.js";
2
+ export type LineTab = {
3
+ id: string;
4
+ label: string;
5
+ lines: string[];
6
+ };
7
+ export type LineTabsState = {
8
+ index: number;
9
+ scroll: number[];
10
+ };
11
+ export declare function initialLineTabsState(tabCount: number, start?: number): LineTabsState;
12
+ export declare function reduceLineTabsKey(state: LineTabsState, key: "left" | "right" | "up" | "down" | "escape" | "q", tabCount: number, maxScroll: number): {
13
+ kind: "close";
14
+ } | {
15
+ kind: "state";
16
+ state: LineTabsState;
17
+ };
18
+ export declare function layoutLineTabs(tabs: LineTab[], state: LineTabsState, columns?: number, rows?: number, color?: boolean): TabsLayout;
19
+ /** Hold-to-read panel. TTY: tabbed frame. Pipe: print the lines. */
20
+ export declare function showLinePanel(label: string, lines: string[]): Promise<void>;
21
+ export declare function runLineTabsBrowser(opts: {
22
+ tabs: LineTab[];
23
+ initial?: number;
24
+ input?: NodeJS.ReadStream;
25
+ output?: NodeJS.WriteStream;
26
+ color?: boolean;
27
+ }): Promise<void>;