mergestorm 0.3.9 → 0.3.13

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 (43) hide show
  1. package/README.md +57 -13
  2. package/dist/api.d.ts +27 -38
  3. package/dist/api.js +81 -4
  4. package/dist/client/index.d.ts +5 -0
  5. package/dist/client/index.js +5 -0
  6. package/dist/commands/credits.js +33 -12
  7. package/dist/commands/login.js +28 -7
  8. package/dist/commands/registry.d.ts +1 -1
  9. package/dist/commands/registry.js +26 -19
  10. package/dist/commands/review-client.d.ts +93 -0
  11. package/dist/commands/review-client.js +289 -0
  12. package/dist/commands/review.d.ts +30 -6
  13. package/dist/commands/review.js +385 -146
  14. package/dist/commands/stack.d.ts +34 -2
  15. package/dist/commands/stack.js +252 -58
  16. package/dist/commands/status.d.ts +16 -2
  17. package/dist/commands/status.js +221 -21
  18. package/dist/errors.d.ts +18 -2
  19. package/dist/errors.js +22 -1
  20. package/dist/git-stack.d.ts +2 -0
  21. package/dist/git-stack.js +4 -0
  22. package/dist/git.d.ts +12 -1
  23. package/dist/git.js +52 -7
  24. package/dist/shell.d.ts +1 -1
  25. package/dist/shell.js +6 -3
  26. package/dist/stack-dto.d.ts +185 -0
  27. package/dist/stack-dto.js +7 -0
  28. package/dist/submit-pr-base.d.ts +31 -0
  29. package/dist/submit-pr-base.js +69 -0
  30. package/dist/ui/banner.js +7 -0
  31. package/dist/ui/box.d.ts +13 -9
  32. package/dist/ui/box.js +28 -44
  33. package/dist/ui/findings.d.ts +2 -0
  34. package/dist/ui/findings.js +2 -1
  35. package/dist/ui/job-envelope.d.ts +68 -0
  36. package/dist/ui/job-envelope.js +35 -0
  37. package/dist/ui/prompt.d.ts +91 -0
  38. package/dist/ui/prompt.js +241 -62
  39. package/dist/ui/usage.d.ts +25 -1
  40. package/dist/ui/usage.js +50 -1
  41. package/dist/ui/width.d.ts +26 -0
  42. package/dist/ui/width.js +262 -0
  43. package/package.json +5 -1
package/dist/ui/prompt.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as readline from "node:readline";
2
2
  import { createInterface } from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
3
+ import { stdin, stdout } from "node:process";
4
4
  import { ansi } from "./ansi.js";
5
- import { frameWidth, preferBoxedUi, roundedBox, visibleWidth } from "./box.js";
5
+ import { frameWidth, preferBoxedUi, roundedBox, sliceVisible, terminalColumns, visibleWidth, } from "./box.js";
6
6
  import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ctrl-c-exit.js";
7
7
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
8
8
  export class PromptClosedError extends Error {
@@ -11,11 +11,171 @@ export class PromptClosedError extends Error {
11
11
  this.name = "PromptClosedError";
12
12
  }
13
13
  }
14
- const up = (n) => `[${n}A`;
15
- const down = (n) => `[${n}B`;
16
- const col = (n) => `[${n}G`;
17
- const clearDown = "";
18
- async function fallbackAskLine(promptLabel) {
14
+ const up = (n) => `\u001b[${n}A`;
15
+ const down = (n) => `\u001b[${n}B`;
16
+ const col = (n) => `\u001b[${n}G`;
17
+ const clearDown = "\u001b[0J";
18
+ /**
19
+ * Redraw prefix from the parked content-row cursor.
20
+ * CUU keeps the column, so CR must come before ED 0 or leftover
21
+ * prompt cells survive (`merg` + `mergestorm` -> `mergmmmmmergestorm`).
22
+ */
23
+ export function promptRedrawPrefix(inputRow) {
24
+ return `${up(inputRow)}\r${clearDown}`;
25
+ }
26
+ /**
27
+ * From the content row, move to the first row after the input chrome
28
+ * and erase the slash overlay. Boxed chrome is top + content + bottom
29
+ * (`down(2)`). Plain chrome is the content line (`down(1)`).
30
+ */
31
+ export function promptClearOverlay(boxed) {
32
+ return `${down(boxed ? 2 : 1)}\r${clearDown}`;
33
+ }
34
+ export const DROPDOWN_MAX_COMMAND_ROWS = 8;
35
+ export function formatSlashLabel(spec) {
36
+ const extra = spec.aliases?.length
37
+ ? ` \u00b7 ${spec.aliases.join(" \u00b7 ")}`
38
+ : "";
39
+ return `/${spec.name}${extra}`;
40
+ }
41
+ export function specMatchesTerm(spec, term) {
42
+ if (spec.name.toLowerCase().startsWith(term))
43
+ return true;
44
+ return (spec.aliases ?? []).some((alias) => alias.toLowerCase().startsWith(term));
45
+ }
46
+ export function slashToken(buffer) {
47
+ if (!buffer.startsWith("/"))
48
+ return null;
49
+ return buffer.slice(1).split(/\s/, 1)[0].toLowerCase();
50
+ }
51
+ export function matchSlashCommands(buffer, specs, dismissed = false) {
52
+ if (dismissed)
53
+ return [];
54
+ const token = slashToken(buffer);
55
+ if (token == null)
56
+ return [];
57
+ return specs.filter((spec) => specMatchesTerm(spec, token));
58
+ }
59
+ export function isExactSlashCommand(buffer, specs) {
60
+ const token = slashToken(buffer);
61
+ if (!token)
62
+ return false;
63
+ return specs.some((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
64
+ }
65
+ export function dropdownMaxRows(terminalRows, inputRow) {
66
+ const rows = typeof terminalRows === "number" && Number.isFinite(terminalRows) && terminalRows >= 1
67
+ ? terminalRows
68
+ : 24;
69
+ return Math.max(1, Math.min(DROPDOWN_MAX_COMMAND_ROWS, rows - inputRow - 3));
70
+ }
71
+ export function dropdownViewport(count, index, maxRows) {
72
+ const max = Math.max(1, maxRows);
73
+ if (count <= max)
74
+ return { start: 0, moreAbove: 0, moreBelow: 0 };
75
+ const clampedIndex = Math.max(0, Math.min(index, count - 1));
76
+ let start = 0;
77
+ if (clampedIndex >= max)
78
+ start = clampedIndex - max + 1;
79
+ if (start + max > count)
80
+ start = Math.max(0, count - max);
81
+ return {
82
+ start,
83
+ moreAbove: start,
84
+ moreBelow: Math.max(0, count - start - max),
85
+ };
86
+ }
87
+ /** Overlay rows (no invert). At most `maxRows` commands plus edge markers. */
88
+ export function dropdownOverlayRows(dropdown, index, maxRows) {
89
+ const view = dropdownViewport(dropdown.length, index, maxRows);
90
+ const rows = [];
91
+ if (view.moreAbove > 0)
92
+ rows.push({ text: ` \u2191 ${view.moreAbove} more` });
93
+ const end = Math.min(dropdown.length, view.start + Math.max(1, maxRows));
94
+ for (let i = view.start; i < end; i++) {
95
+ const spec = dropdown[i];
96
+ rows.push({
97
+ text: ` ${formatSlashLabel(spec).padEnd(22)}${spec.summary}`,
98
+ selected: i === index,
99
+ });
100
+ }
101
+ if (view.moreBelow > 0)
102
+ rows.push({ text: ` \u2193 ${view.moreBelow} more` });
103
+ return rows;
104
+ }
105
+ /** Input prefix glyph (U+203A), kept as an escape so editors can't mangle it. */
106
+ export const INPUT_PREFIX_GLYPH = "\u203a";
107
+ /** Cells the `> ` prefix occupies before the buffer. */
108
+ export const INPUT_PREFIX_CELLS = 2;
109
+ /** Padding inside the boxed input frame. */
110
+ export const INPUT_BOX_PADDING = 1;
111
+ /**
112
+ * Horizontal scroll for a one-row editor: pick the `cells`-wide window of
113
+ * `buffer` that keeps the cursor on screen, moving the previous window only
114
+ * when the cursor leaves it. Never wraps onto a second physical row.
115
+ */
116
+ export function inputWindow(buffer, cursor, cells, prevStart = 0) {
117
+ if (cells <= 0)
118
+ return { start: 0, text: "", cursorCol: 0 };
119
+ const cursorCells = visibleWidth(buffer.slice(0, Math.max(0, cursor)));
120
+ const totalCells = visibleWidth(buffer);
121
+ // The cursor needs a cell of its own at end-of-line, so a full window
122
+ // shows at most `cells - 1` glyphs past the scroll origin.
123
+ const maxStart = Math.max(0, totalCells - (cells - 1));
124
+ let start = Math.min(Math.max(0, prevStart), maxStart);
125
+ if (cursorCells < start)
126
+ start = cursorCells;
127
+ if (cursorCells > start + cells - 1)
128
+ start = cursorCells - (cells - 1);
129
+ return {
130
+ start,
131
+ text: sliceVisible(buffer, start, cells),
132
+ cursorCol: cursorCells - start,
133
+ };
134
+ }
135
+ /** Cells available to the buffer (after the prefix) on a content row. */
136
+ export function inputCells(columns) {
137
+ const boxed = preferBoxedUi(columns);
138
+ const contentCells = boxed
139
+ ? frameWidth(80, columns) - 2 - INPUT_BOX_PADDING * 2
140
+ : terminalColumns(80, columns) - 1;
141
+ return { boxed, cells: Math.max(0, contentCells - INPUT_PREFIX_CELLS) };
142
+ }
143
+ /**
144
+ * Everything one prompt redraw writes, computed from the current terminal
145
+ * width. Every row fits in `columns - 1` cells so nothing wraps and the
146
+ * CUU count on the next redraw stays honest.
147
+ */
148
+ export function buildPromptFrame(params) {
149
+ const { boxed, cells } = inputCells(params.columns);
150
+ const cols = terminalColumns(80, params.columns);
151
+ const win = inputWindow(params.buffer, params.cursor, cells, params.scrollStart ?? 0);
152
+ const inputRow = (params.prompt ? 1 : 0) + (boxed ? 1 : 0);
153
+ // Nothing we emit may wrap: a wrapped row is one the CUU count can't see.
154
+ const clip = (text) => visibleWidth(text) > cols - 1 ? sliceVisible(text, 0, cols - 1) : text;
155
+ const rows = [];
156
+ if (params.prompt)
157
+ rows.push(ansi.dim(clip(params.prompt)));
158
+ const inputLine = `${ansi.green(INPUT_PREFIX_GLYPH)} ${win.text}`;
159
+ if (boxed) {
160
+ // Full terminal width input frame, not content-sized.
161
+ rows.push(...roundedBox([inputLine], {
162
+ padding: INPUT_BOX_PADDING,
163
+ width: frameWidth(80, params.columns),
164
+ }));
165
+ }
166
+ else {
167
+ // Plain prompt on narrow TTYs (avoids wrapped borders).
168
+ rows.push(inputLine);
169
+ }
170
+ for (const row of params.overlay ?? []) {
171
+ const text = clip(row.text);
172
+ rows.push(row.selected ? ansi.invert(text) : ansi.gray(text));
173
+ }
174
+ // Boxed: border col + padding; plain: column 1.
175
+ const targetCol = (boxed ? 1 + INPUT_BOX_PADDING : 0) + INPUT_PREFIX_CELLS + win.cursorCol + 1;
176
+ return { rows, boxed, inputRow, targetCol, scrollStart: win.start };
177
+ }
178
+ async function fallbackAskLine(promptLabel, input, output) {
19
179
  const rl = createInterface({ input, output, terminal: false });
20
180
  try {
21
181
  return await rl.question(promptLabel ? `${promptLabel}> ` : "> ");
@@ -31,68 +191,80 @@ async function fallbackAskLine(promptLabel) {
31
191
  export async function askLine(opts) {
32
192
  const commands = opts.commands ?? [];
33
193
  const history = opts.history ?? [];
194
+ const input = opts.input ?? stdin;
195
+ const output = opts.output ?? stdout;
34
196
  if (!input.isTTY || !output.isTTY) {
35
- return fallbackAskLine(opts.prompt);
197
+ return fallbackAskLine(opts.prompt, input, output);
36
198
  }
37
199
  return new Promise((resolve, reject) => {
38
200
  let buffer = "";
39
201
  let cursor = 0;
40
202
  let historyIndex = history.length;
41
203
  let dropdownIndex = 0;
204
+ let dropdownDismissed = false;
42
205
  let linesDrawn = 0;
206
+ // Geometry of the frame currently on screen. The redraw prefix and the
207
+ // final overlay clear must use *these*, not a fresh measurement, because
208
+ // the cursor is parked relative to what was actually drawn.
209
+ let drawnInputRow = 0;
210
+ let drawnBoxed = true;
211
+ let scrollStart = 0;
43
212
  let finished = false;
44
213
  const ctrlCExit = new CtrlCExitGate();
45
214
  const wasRaw = Boolean(input.isRaw);
46
215
  readline.emitKeypressEvents(input);
47
216
  input.setRawMode(true);
48
217
  input.resume();
49
- const boxed = preferBoxedUi();
50
- // Row (0-based) of the "› buffer" content line among the rows we draw each frame.
51
- // Boxed: optional prompt + top border + content. Plain: optional prompt + content.
52
- const inputRow = (opts.prompt ? 1 : 0) + (boxed ? 1 : 0);
53
218
  function currentDropdown() {
54
- if (!buffer.startsWith("/"))
55
- return [];
56
- const term = buffer.slice(1).toLowerCase();
57
- return commands.filter((c) => c.name.toLowerCase().startsWith(term));
219
+ return matchSlashCommands(buffer, commands, dropdownDismissed);
220
+ }
221
+ function setBuffer(next) {
222
+ if (next !== buffer)
223
+ dropdownDismissed = false;
224
+ buffer = next;
58
225
  }
59
226
  function render() {
60
227
  if (linesDrawn > 0) {
61
- output.write(`${up(inputRow)}${clearDown}`);
62
- }
63
- const rows = [];
64
- if (opts.prompt)
65
- rows.push(ansi.dim(opts.prompt));
66
- const inputLine = `${ansi.green("›")} ${buffer}`;
67
- if (boxed) {
68
- // Full terminal width input frame, not content-sized.
69
- rows.push(...roundedBox([inputLine], { padding: 1, width: frameWidth() }));
70
- }
71
- else {
72
- // Plain prompt on narrow TTYs (avoids wrapped borders).
73
- rows.push(inputLine);
228
+ output.write(promptRedrawPrefix(drawnInputRow));
74
229
  }
230
+ // Re-measured every frame: the terminal may have been resized since
231
+ // the last draw, flipping boxed/plain or changing the frame width.
232
+ const columns = opts.columns ?? output.columns;
233
+ const boxedNow = preferBoxedUi(columns);
234
+ const inputRowNow = (opts.prompt ? 1 : 0) + (boxedNow ? 1 : 0);
75
235
  const dropdown = currentDropdown();
236
+ let overlay = [];
76
237
  if (dropdown.length) {
77
238
  if (dropdownIndex >= dropdown.length)
78
239
  dropdownIndex = dropdown.length - 1;
79
- dropdown.forEach((c, i) => {
80
- const label = ` /${c.name.padEnd(12)}${c.summary}`;
81
- rows.push(i === dropdownIndex ? ansi.invert(label) : ansi.gray(label));
82
- });
240
+ const maxRows = dropdownMaxRows(opts.rows ?? output.rows, inputRowNow);
241
+ overlay = dropdownOverlayRows(dropdown, dropdownIndex, maxRows);
83
242
  }
84
243
  else {
85
244
  dropdownIndex = 0;
86
245
  }
87
- output.write(rows.join("\r\n") + "\r\n");
88
- linesDrawn = rows.length;
89
- const prefixWidth = visibleWidth(`${ansi.green("›")} `);
90
- // Boxed: border col + padding; plain: column 1.
91
- const targetCol = (boxed ? 1 + 1 : 0) + prefixWidth + cursor + 1;
92
- output.write(`${up(linesDrawn - inputRow)}${col(targetCol)}`);
246
+ const frame = buildPromptFrame({
247
+ prompt: opts.prompt,
248
+ buffer,
249
+ cursor,
250
+ columns,
251
+ scrollStart,
252
+ overlay,
253
+ });
254
+ output.write(frame.rows.join("\r\n") + "\r\n");
255
+ linesDrawn = frame.rows.length;
256
+ drawnInputRow = frame.inputRow;
257
+ drawnBoxed = frame.boxed;
258
+ scrollStart = frame.scrollStart;
259
+ output.write(`${up(linesDrawn - frame.inputRow)}${col(frame.targetCol)}`);
260
+ }
261
+ function onResize() {
262
+ if (!finished)
263
+ render();
93
264
  }
94
265
  function cleanup() {
95
266
  input.removeListener("keypress", onKeypress);
267
+ output.removeListener("resize", onResize);
96
268
  input.setRawMode(wasRaw);
97
269
  // emitKeypressEvents leaves stdin flowing; pause it so an idle shell
98
270
  // (no command running) doesn't keep the process alive after exit/EOF.
@@ -103,9 +275,8 @@ export async function askLine(opts) {
103
275
  return;
104
276
  finished = true;
105
277
  cleanup();
106
- const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
107
- if (rowsBelow > 0)
108
- output.write(down(rowsBelow));
278
+ if (linesDrawn > 0)
279
+ output.write(promptClearOverlay(drawnBoxed));
109
280
  output.write("\n");
110
281
  if (err)
111
282
  reject(err);
@@ -113,7 +284,7 @@ export async function askLine(opts) {
113
284
  resolve(value ?? "");
114
285
  }
115
286
  function acceptDropdown(dropdown) {
116
- buffer = `/${dropdown[dropdownIndex].name} `;
287
+ setBuffer(`/${dropdown[dropdownIndex].name} `);
117
288
  cursor = buffer.length;
118
289
  dropdownIndex = 0;
119
290
  }
@@ -129,12 +300,13 @@ export async function askLine(opts) {
129
300
  finish(null, new PromptClosedError());
130
301
  return;
131
302
  }
132
- buffer = "";
303
+ setBuffer("");
133
304
  cursor = 0;
305
+ scrollStart = 0;
134
306
  historyIndex = history.length;
135
- const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
136
- if (rowsBelow > 0)
137
- output.write(down(rowsBelow));
307
+ dropdownIndex = 0;
308
+ if (linesDrawn > 0)
309
+ output.write(promptClearOverlay(drawnBoxed));
138
310
  output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
139
311
  linesDrawn = 0;
140
312
  render();
@@ -145,9 +317,18 @@ export async function askLine(opts) {
145
317
  finish(null, new PromptClosedError());
146
318
  return;
147
319
  }
148
- if (key.name === "return") {
320
+ if (key.name === "escape") {
149
321
  if (dropdown.length)
150
- buffer = `/${dropdown[dropdownIndex].name}`;
322
+ dropdownDismissed = true;
323
+ render();
324
+ return;
325
+ }
326
+ if (key.name === "return") {
327
+ if (dropdown.length &&
328
+ !dropdownDismissed &&
329
+ !isExactSlashCommand(buffer, dropdown)) {
330
+ setBuffer(`/${dropdown[dropdownIndex].name}`);
331
+ }
151
332
  finish(buffer);
152
333
  return;
153
334
  }
@@ -158,10 +339,7 @@ export async function askLine(opts) {
158
339
  return;
159
340
  }
160
341
  if (key.name === "right") {
161
- if (dropdown.length)
162
- acceptDropdown(dropdown);
163
- else
164
- cursor = Math.min(buffer.length, cursor + 1);
342
+ cursor = Math.min(buffer.length, cursor + 1);
165
343
  render();
166
344
  return;
167
345
  }
@@ -172,11 +350,11 @@ export async function askLine(opts) {
172
350
  }
173
351
  if (key.name === "up") {
174
352
  if (dropdown.length) {
175
- dropdownIndex = (dropdownIndex - 1 + dropdown.length) % dropdown.length;
353
+ dropdownIndex = Math.max(0, dropdownIndex - 1);
176
354
  }
177
355
  else if (history.length) {
178
356
  historyIndex = Math.max(0, historyIndex - 1);
179
- buffer = history[historyIndex] ?? "";
357
+ setBuffer(history[historyIndex] ?? "");
180
358
  cursor = buffer.length;
181
359
  }
182
360
  render();
@@ -184,11 +362,11 @@ export async function askLine(opts) {
184
362
  }
185
363
  if (key.name === "down") {
186
364
  if (dropdown.length) {
187
- dropdownIndex = (dropdownIndex + 1) % dropdown.length;
365
+ dropdownIndex = Math.min(dropdown.length - 1, dropdownIndex + 1);
188
366
  }
189
367
  else if (history.length) {
190
368
  historyIndex = Math.min(history.length, historyIndex + 1);
191
- buffer = historyIndex >= history.length ? "" : history[historyIndex] ?? "";
369
+ setBuffer(historyIndex >= history.length ? "" : history[historyIndex] ?? "");
192
370
  cursor = buffer.length;
193
371
  }
194
372
  render();
@@ -205,31 +383,31 @@ export async function askLine(opts) {
205
383
  return;
206
384
  }
207
385
  if (key.ctrl && key.name === "u") {
208
- buffer = buffer.slice(cursor);
386
+ setBuffer(buffer.slice(cursor));
209
387
  cursor = 0;
210
388
  render();
211
389
  return;
212
390
  }
213
391
  if (key.ctrl && key.name === "k") {
214
- buffer = buffer.slice(0, cursor);
392
+ setBuffer(buffer.slice(0, cursor));
215
393
  render();
216
394
  return;
217
395
  }
218
396
  if (key.name === "backspace") {
219
397
  if (cursor > 0) {
220
- buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
398
+ setBuffer(buffer.slice(0, cursor - 1) + buffer.slice(cursor));
221
399
  cursor -= 1;
222
400
  }
223
401
  render();
224
402
  return;
225
403
  }
226
404
  if (key.name === "delete") {
227
- buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
405
+ setBuffer(buffer.slice(0, cursor) + buffer.slice(cursor + 1));
228
406
  render();
229
407
  return;
230
408
  }
231
409
  if (str && !key.ctrl && !key.meta) {
232
- buffer = buffer.slice(0, cursor) + str + buffer.slice(cursor);
410
+ setBuffer(buffer.slice(0, cursor) + str + buffer.slice(cursor));
233
411
  cursor += str.length;
234
412
  render();
235
413
  }
@@ -239,6 +417,7 @@ export async function askLine(opts) {
239
417
  }
240
418
  }
241
419
  input.on("keypress", onKeypress);
420
+ output.on("resize", onResize);
242
421
  render();
243
422
  });
244
423
  }
@@ -9,7 +9,7 @@ export type UsageBarOpts = {
9
9
  * `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
10
10
  *
11
11
  * No used/limit clutter. Edge cases:
12
- * - `limit === 0` → dim "not included" (premium on non-Maelstrom)
12
+ * - `limit === 0` → dim "not included"
13
13
  * - `limit == null` → "unlimited" (dev-bypass)
14
14
  */
15
15
  export declare function usageBar(used: number, limit: number | null | undefined, opts?: UsageBarOpts): string;
@@ -22,3 +22,27 @@ export declare function nextUtcMonthStart(now?: Date): string;
22
22
  export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
23
23
  /** Alias kept for callers that still import `formatResetDate`. */
24
24
  export declare function formatResetDate(iso: string | null | undefined): string;
25
+ /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
26
+ export declare function usageBarWidth(columns: number): number;
27
+ export type UsagePanelJob = {
28
+ job: string;
29
+ verdict: string;
30
+ thread: string;
31
+ credits: string;
32
+ when: string;
33
+ };
34
+ export type UsagePanelInput = {
35
+ keyPrefix: string;
36
+ plan: string;
37
+ used: number;
38
+ limit: number | null;
39
+ resetsAt: string | null | undefined;
40
+ jobs: UsagePanelJob[];
41
+ columns: number;
42
+ color?: boolean;
43
+ };
44
+ /**
45
+ * Static `/usage` panel: identity, a bar sized from `frameWidth()`, reset
46
+ * line, then the last five jobs. Every row is clipped to `columns`.
47
+ */
48
+ export declare function formatUsagePanel(input: UsagePanelInput): string[];
package/dist/ui/usage.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { ansi } from "./ansi.js";
2
+ import { frameWidth } from "./box.js";
3
+ import { padVisible, visibleWidth } from "./width.js";
2
4
  /**
3
5
  * Usage meter — long thin bar + percent only:
4
6
  * `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
5
7
  *
6
8
  * No used/limit clutter. Edge cases:
7
- * - `limit === 0` → dim "not included" (premium on non-Maelstrom)
9
+ * - `limit === 0` → dim "not included"
8
10
  * - `limit == null` → "unlimited" (dev-bypass)
9
11
  */
10
12
  export function usageBar(used, limit, opts = {}) {
@@ -65,3 +67,50 @@ export function formatResetLabel(iso, now = new Date()) {
65
67
  export function formatResetDate(iso) {
66
68
  return formatResetLabel(iso);
67
69
  }
70
+ /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
71
+ export function usageBarWidth(columns) {
72
+ return Math.max(8, frameWidth(80, columns) - 13);
73
+ }
74
+ function clipLine(line, columns) {
75
+ if (visibleWidth(line) <= columns)
76
+ return line;
77
+ return padVisible(line, columns);
78
+ }
79
+ /**
80
+ * Static `/usage` panel: identity, a bar sized from `frameWidth()`, reset
81
+ * line, then the last five jobs. Every row is clipped to `columns`.
82
+ */
83
+ export function formatUsagePanel(input) {
84
+ const columns = Math.max(20, input.columns);
85
+ const color = input.color ?? false;
86
+ const identity = `● ${input.keyPrefix} · ${input.plan}`;
87
+ const bar = usageBar(input.used, input.limit, {
88
+ width: usageBarWidth(columns),
89
+ color,
90
+ });
91
+ const lines = [
92
+ ` ${identity}`,
93
+ ` ${bar}`,
94
+ ` ${formatResetLabel(input.resetsAt)}`,
95
+ "",
96
+ ];
97
+ if (input.jobs.length === 0) {
98
+ lines.push(" No review jobs yet.");
99
+ return lines.map((line) => clipLine(line, columns));
100
+ }
101
+ const inner = Math.max(20, columns - 2);
102
+ const jobW = 8;
103
+ const verdictW = 15;
104
+ const creditsW = 7;
105
+ const whenW = 8;
106
+ const gaps = 8;
107
+ const threadW = Math.max(6, inner - jobW - verdictW - creditsW - whenW - gaps);
108
+ const header = ["JOB", "VERDICT", "THREAD", "CREDITS", "WHEN"];
109
+ const widths = [jobW, verdictW, threadW, creditsW, whenW];
110
+ const fmt = (cols) => " " + cols.map((c, i) => padVisible(c, widths[i])).join(" ");
111
+ lines.push(fmt(header));
112
+ for (const job of input.jobs.slice(0, 5)) {
113
+ lines.push(fmt([job.job, job.verdict, job.thread, job.credits, job.when]));
114
+ }
115
+ return lines.map((line) => clipLine(line, columns));
116
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Terminal cell arithmetic. Everything that frames, pads, or parks a cursor
3
+ * must count *cells*, not UTF-16 units: CJK is two cells, combining marks
4
+ * and ZWJ are zero, an astral emoji is one grapheme. Getting this wrong is
5
+ * why the right border of the prompt box drifted on non-Latin input.
6
+ */
7
+ export declare function stripSgr(str: string): string;
8
+ /** Cells for one code point on its own (no cluster context). */
9
+ export declare function codePointWidth(cp: number): number;
10
+ /**
11
+ * Cells one grapheme cluster occupies. A cluster is as wide as its widest
12
+ * base (a ZWJ family emoji is 2, not 6); VS16 promotes a text-style symbol
13
+ * to emoji presentation (2 cells); a cluster of nothing but marks is 0.
14
+ */
15
+ export declare function graphemeWidth(cluster: string): number;
16
+ /** Terminal cells a string occupies, ignoring SGR colour codes. */
17
+ export declare function visibleWidth(str: string): number;
18
+ /**
19
+ * Take the cells `[start, start + cells)` of `str`, keeping every SGR code
20
+ * (so colours survive) and substituting spaces for a wide glyph cut by
21
+ * either edge, so the result's geometry is exactly what the caller asked
22
+ * for even when a CJK character straddles the window.
23
+ */
24
+ export declare function sliceVisible(str: string, start: number, cells: number): string;
25
+ /** Pad or truncate to an exact cell width (ANSI-safe). */
26
+ export declare function padVisible(line: string, width: number): string;