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.
- package/README.md +67 -24
- package/dist/api.d.ts +106 -40
- package/dist/api.js +245 -9
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- 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 +30 -12
- 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 +32 -10
- 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.d.ts +1 -1
- package/dist/commands/registry.js +115 -33
- package/dist/commands/review-client.d.ts +106 -0
- package/dist/commands/review-client.js +399 -0
- package/dist/commands/review.d.ts +36 -6
- package/dist/commands/review.js +395 -145
- 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 +36 -2
- package/dist/commands/stack.js +341 -113
- package/dist/commands/status.d.ts +18 -2
- package/dist/commands/status.js +236 -21
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.js +21 -7
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +22 -1
- package/dist/git-stack.d.ts +2 -0
- package/dist/git-stack.js +4 -0
- package/dist/git.d.ts +12 -1
- package/dist/git.js +52 -7
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +39 -28
- package/dist/stack-dto.d.ts +266 -0
- package/dist/stack-dto.js +7 -0
- package/dist/submit-pr-base.d.ts +31 -0
- package/dist/submit-pr-base.js +69 -0
- 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 +170 -79
- package/dist/ui/box.d.ts +13 -9
- package/dist/ui/box.js +28 -44
- package/dist/ui/findings.d.ts +4 -1
- package/dist/ui/findings.js +14 -7
- package/dist/ui/job-envelope.d.ts +68 -0
- package/dist/ui/job-envelope.js +35 -0
- 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 +148 -0
- package/dist/ui/prompt.js +433 -71
- 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 +29 -1
- package/dist/ui/usage.js +71 -2
- package/dist/ui/width.d.ts +26 -0
- package/dist/ui/width.js +262 -0
- package/package.json +6 -1
- package/skill/SKILL.md +98 -0
- 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>;
|
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
|
@@ -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"
|
|
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[];
|