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
@@ -0,0 +1,125 @@
1
+ import { type UsagePanelInput } from "./usage.js";
2
+ export type TabId = "status" | "usage" | "jobs" | "config";
3
+ export declare const TAB_ORDER: readonly TabId[];
4
+ export type TabsJob = {
5
+ id: string;
6
+ cells: {
7
+ job: string;
8
+ status: string;
9
+ verdict: string;
10
+ thread: string;
11
+ when: string;
12
+ };
13
+ };
14
+ /** One Config-tab row: a writable on/off toggle or a read-only connection. */
15
+ export type TabsConfigRow = {
16
+ /** Settings key sent on PATCH (writable rows only). */
17
+ key: string;
18
+ label: string;
19
+ value: boolean;
20
+ writable: boolean;
21
+ };
22
+ export type TabsData = {
23
+ /** Prebuilt whoami + credits lines for the Status tab. */
24
+ statusLines: string[];
25
+ /** Right-aligned chrome note (reachability). Lives on the hint row. */
26
+ footerNote?: string;
27
+ /** Usage panel input minus width concerns; null when the API degraded. */
28
+ usage: Omit<UsagePanelInput, "columns" | "color"> | null;
29
+ /** Usage-tab body when the panel is unavailable (overrides the default). */
30
+ usageEmptyText?: string;
31
+ jobs: TabsJob[];
32
+ /** Jobs-tab body when the list is empty (e.g. the list call failed). */
33
+ jobsEmptyText?: string;
34
+ /** Config tab rows; null when GET /api/v1/settings degraded. */
35
+ config: TabsConfigRow[] | null;
36
+ /** Config-tab body when settings are unavailable (overrides the default). */
37
+ configEmptyText?: string;
38
+ /** Transient Config-tab status line (PATCH in flight or rejected). */
39
+ configNote?: string;
40
+ color?: boolean;
41
+ };
42
+ export type TabsDetail = {
43
+ jobId: string;
44
+ rows: string[];
45
+ };
46
+ export type TabsState = {
47
+ tab: TabId;
48
+ /** Body scroll per scrollable view; jobs and config scroll by selection. */
49
+ scroll: {
50
+ status: number;
51
+ usage: number;
52
+ detail: number;
53
+ };
54
+ jobsIndex: number;
55
+ configIndex: number;
56
+ /** In-place `status <id>` view on the Jobs tab; Esc returns to the list. */
57
+ detail: TabsDetail | null;
58
+ };
59
+ export declare function initialTabsState(tab?: TabId): TabsState;
60
+ export type TabsKeyName = "left" | "right" | "up" | "down" | "return" | "space" | "escape" | "q";
61
+ export type TabsStep = {
62
+ kind: "state";
63
+ state: TabsState;
64
+ } | {
65
+ kind: "close";
66
+ } | {
67
+ kind: "open-job";
68
+ index: number;
69
+ state: TabsState;
70
+ } | {
71
+ kind: "toggle-setting";
72
+ key: string;
73
+ value: boolean;
74
+ state: TabsState;
75
+ };
76
+ export type TabsReduceCtx = {
77
+ jobsCount: number;
78
+ /** Max scroll offset of the active view (content rows minus body rows). */
79
+ maxScroll: number;
80
+ /** Config rows so Space knows which key to flip (read-only rows no-op). */
81
+ config?: readonly TabsConfigRow[];
82
+ };
83
+ /**
84
+ * One keypress against the browser state. Pure so tests can drive the whole
85
+ * key map without a TTY. Tabs clamp at the ends (no wrap-around, so "which
86
+ * tab am I on" never gets ambiguous); Esc backs out of a job detail before
87
+ * it closes the browser; `q` always closes.
88
+ */
89
+ export declare function reduceTabsKey(state: TabsState, key: TabsKeyName, ctx: TabsReduceCtx): TabsStep;
90
+ /** Body rows available inside the frame: total rows minus chrome + hint. */
91
+ export declare function tabsBodyRows(rows: number | undefined): number;
92
+ export type TabsLayout = {
93
+ /** Full frame (border, body, hint); no line wider than `columns - 1`. */
94
+ lines: string[];
95
+ bodyRows: number;
96
+ /** Content rows of the active view before clipping (drives max scroll). */
97
+ contentRows: number;
98
+ };
99
+ export declare function tabsMaxScroll(layout: TabsLayout): number;
100
+ /** Key hint on the left, optional reachability note on the right. */
101
+ export declare function tabsHintLine(left: string, right: string | undefined, columns: number, color: boolean): string;
102
+ /**
103
+ * The whole browser frame for one render: tab strip in the top border, the
104
+ * active tab's body clipped to `rows - 4`, and a dim key hint. Pure; the
105
+ * raw-mode loop and tests both consume it.
106
+ */
107
+ export declare function layoutTabs(state: TabsState, data: TabsData, columns?: number, rows?: number): TabsLayout;
108
+ export type TabsBrowserOptions = {
109
+ initial?: TabId;
110
+ data: TabsData;
111
+ /** Fetches `status <id>` rows for the Jobs tab's Enter action. */
112
+ loadJobDetail?: (jobId: string) => Promise<string[]>;
113
+ /** PATCHes one setting for the Config tab's Space action; resolves to the refreshed rows. */
114
+ toggleSetting?: (key: string, value: boolean) => Promise<TabsConfigRow[]>;
115
+ input?: NodeJS.ReadStream;
116
+ output?: NodeJS.WriteStream;
117
+ };
118
+ /**
119
+ * Raw-mode tabbed browser (Status / Usage / Jobs / Config) on the select.ts
120
+ * loop: Left/Right switch tabs, Up/Down scroll or select, Enter opens a job
121
+ * in place, Space toggles a Config setting, `q`/Esc close, resize re-renders.
122
+ * Non-TTY streams fall back to the static panels (callers normally gate on
123
+ * TTY already).
124
+ */
125
+ export declare function runTabsBrowser(opts: TabsBrowserOptions): Promise<void>;
@@ -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
+ }
@@ -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;
@@ -20,5 +20,33 @@ 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;
27
+ /** Bar cells so ` [bar] 100% used` stays inside the terminal. */
28
+ export declare function usageBarWidth(columns: number): number;
29
+ export type UsagePanelJob = {
30
+ job: string;
31
+ verdict: string;
32
+ thread: string;
33
+ credits: string;
34
+ when: string;
35
+ };
36
+ export type UsagePanelInput = {
37
+ keyPrefix: string;
38
+ plan: string;
39
+ used: number;
40
+ limit: number | null;
41
+ resetsAt: string | null | undefined;
42
+ jobs: UsagePanelJob[];
43
+ /** When the jobs fetch failed, show this instead of "No review jobs yet." */
44
+ jobsError?: string;
45
+ columns: number;
46
+ color?: boolean;
47
+ };
48
+ /**
49
+ * Static `/usage` panel: identity, a bar sized from `frameWidth()`, reset
50
+ * line, then the last five jobs. Every row is clipped to `columns`.
51
+ */
52
+ export declare function formatUsagePanel(input: UsagePanelInput): string[];