mergestorm 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -15
- package/dist/api.d.ts +82 -5
- package/dist/api.js +166 -7
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/commands/branches.d.ts +6 -2
- package/dist/commands/branches.js +29 -14
- package/dist/commands/browse.d.ts +39 -0
- package/dist/commands/browse.js +259 -0
- package/dist/commands/credits.js +7 -10
- package/dist/commands/help.d.ts +5 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/jobs.d.ts +11 -2
- package/dist/commands/jobs.js +64 -15
- package/dist/commands/login.js +4 -3
- package/dist/commands/logout.js +2 -1
- package/dist/commands/pr.d.ts +22 -0
- package/dist/commands/pr.js +127 -0
- package/dist/commands/queue.d.ts +12 -0
- package/dist/commands/queue.js +160 -0
- package/dist/commands/registry.js +93 -18
- package/dist/commands/review-client.d.ts +13 -0
- package/dist/commands/review-client.js +110 -0
- package/dist/commands/review.d.ts +6 -0
- package/dist/commands/review.js +18 -7
- package/dist/commands/settings.d.ts +17 -0
- package/dist/commands/settings.js +89 -0
- package/dist/commands/skill.d.ts +20 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/stack.d.ts +2 -0
- package/dist/commands/stack.js +91 -57
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +30 -15
- package/dist/commands/whoami.d.ts +3 -1
- package/dist/commands/whoami.js +21 -7
- package/dist/errors.d.ts +1 -1
- package/dist/shell.js +39 -31
- package/dist/stack-dto.d.ts +82 -1
- package/dist/ui/ansi.d.ts +2 -0
- package/dist/ui/ansi.js +2 -0
- package/dist/ui/banner.d.ts +34 -2
- package/dist/ui/banner.js +163 -79
- package/dist/ui/findings.d.ts +2 -1
- package/dist/ui/findings.js +13 -7
- package/dist/ui/line-tabs.d.ts +27 -0
- package/dist/ui/line-tabs.js +170 -0
- package/dist/ui/logo.d.ts +10 -2
- package/dist/ui/logo.js +13 -9
- package/dist/ui/present.d.ts +10 -0
- package/dist/ui/present.js +13 -0
- package/dist/ui/prompt.d.ts +58 -1
- package/dist/ui/prompt.js +225 -42
- package/dist/ui/select.js +1 -1
- package/dist/ui/tabs.d.ts +125 -0
- package/dist/ui/tabs.js +480 -0
- package/dist/ui/usage.d.ts +4 -0
- package/dist/ui/usage.js +22 -2
- package/package.json +2 -1
- package/skill/SKILL.md +98 -0
- package/skill/mergestorm-pr-loop/SKILL.md +76 -0
package/dist/ui/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,
|
|
7
|
-
import {
|
|
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.
|
|
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
|
}
|
|
@@ -73,53 +73,166 @@ const WHATS_NEW = {
|
|
|
73
73
|
"review --router off|standard|max|manual (same smart router as Agents)",
|
|
74
74
|
"review --specialists security,frontend,… to pin or invoke lanes",
|
|
75
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
|
+
],
|
|
76
84
|
};
|
|
77
|
-
function
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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`;
|
|
83
97
|
}
|
|
84
|
-
if (keyInvalid) {
|
|
85
|
-
return
|
|
86
|
-
`${ansi.dim("○")} API key invalid or revoked — type ${ansi.green("login")} to sign in again`,
|
|
87
|
-
];
|
|
98
|
+
if (state.keyInvalid) {
|
|
99
|
+
return `${ansi.dim("○")} API key invalid or revoked · type ${ansi.brightGreen("login")} to sign in again`;
|
|
88
100
|
}
|
|
89
|
-
const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
|
|
90
|
-
if (!me) {
|
|
91
|
-
return
|
|
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")}`;
|
|
92
104
|
}
|
|
93
|
-
const plan = me.plan_label_key ?? me.plan_key;
|
|
94
|
-
|
|
95
|
-
return [
|
|
96
|
-
`${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
|
|
97
|
-
usageBar(s.used, s.limit, { width: barWidth }),
|
|
98
|
-
];
|
|
105
|
+
const plan = state.me.plan_label_key ?? state.me.plan_key;
|
|
106
|
+
return `${ansi.brightGreen("●")} ${ansi.bold(display)} · ${plan}`;
|
|
99
107
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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;
|
|
107
124
|
}
|
|
108
|
-
function
|
|
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);
|
|
143
|
+
}
|
|
144
|
+
/** Shorten until it fits. Never clip mid-word (that was "…Jobs tabs (open with usa"). */
|
|
145
|
+
function whatsNewLine(version, cells) {
|
|
109
146
|
const entries = WHATS_NEW[version];
|
|
110
147
|
if (!entries || !entries.length)
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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}`,
|
|
115
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;
|
|
116
162
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
230
|
}
|
|
122
|
-
|
|
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) {
|
|
123
236
|
const cfg = await loadConfig();
|
|
124
237
|
const key = resolveApiKey(cfg);
|
|
125
238
|
let me = null;
|
|
@@ -138,46 +251,17 @@ export async function printBannerHeader() {
|
|
|
138
251
|
}
|
|
139
252
|
}
|
|
140
253
|
}
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const inner = Math.max(40, outer - 4);
|
|
145
|
-
// Two columns: left = logo + status, right = tips + what's new.
|
|
146
|
-
const leftColW = Math.max(28, Math.floor(inner * 0.42));
|
|
147
|
-
const rightColW = Math.max(24, inner - leftColW - 2);
|
|
148
|
-
const barWidth = Math.max(12, leftColW - 4);
|
|
149
|
-
const logoLines = TORNADO_LOGO.map((line) => centerLine(ansi.brightGreen(line), leftColW));
|
|
150
|
-
const leftCol = [
|
|
151
|
-
...logoLines,
|
|
152
|
-
"",
|
|
153
|
-
...formatStatusLines(me, key, barWidth, keyInvalid),
|
|
154
|
-
];
|
|
155
|
-
const rightCol = [
|
|
156
|
-
...tipsLines(),
|
|
157
|
-
"",
|
|
158
|
-
...whatsNewLines(version),
|
|
159
|
-
];
|
|
160
|
-
// Clip right column lines that overflow the right pane.
|
|
161
|
-
const rightClipped = rightCol.map((line) => visibleWidth(line) <= rightColW ? line : padVisible(line, rightColW));
|
|
162
|
-
const rows = sideBySide(leftCol, rightClipped, leftColW, 2);
|
|
163
|
-
if (!preferBoxedUi()) {
|
|
164
|
-
// Narrow panes — plain lines beat wrapped box frames.
|
|
165
|
-
console.log(ansi.boldGreen(`mergestorm v${version}`));
|
|
166
|
-
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)) {
|
|
167
257
|
console.log(line);
|
|
168
|
-
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const panel = roundedBox(rows, {
|
|
172
|
-
title: `mergestorm v${version}`,
|
|
173
|
-
width: outer,
|
|
174
|
-
padding: 1,
|
|
175
|
-
});
|
|
176
|
-
for (const line of panel) {
|
|
177
|
-
console.log(line);
|
|
258
|
+
}
|
|
178
259
|
}
|
|
179
|
-
|
|
260
|
+
return {
|
|
261
|
+
rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
|
|
262
|
+
};
|
|
180
263
|
}
|
|
181
|
-
export function shellPrompt(
|
|
182
|
-
|
|
264
|
+
export function shellPrompt(_loggedIn) {
|
|
265
|
+
// Bright mark green. Never dim, never 32, never the word "mergestorm".
|
|
266
|
+
return ansi.boldBrightGreen("mg");
|
|
183
267
|
}
|
package/dist/ui/findings.d.ts
CHANGED
|
@@ -26,5 +26,6 @@ export type RenderFindingsOptions = {
|
|
|
26
26
|
/** When true and both lists are empty, print the empty message. */
|
|
27
27
|
emptyMessage?: boolean;
|
|
28
28
|
};
|
|
29
|
-
|
|
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`. */
|
|
30
31
|
export declare function renderFindings(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): void;
|
package/dist/ui/findings.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
6
|
const lane = c.specialist ? `[${c.specialist}]` : "";
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
lines.push(`[${c.severity}]${lane} ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
|
|
8
|
+
lines.push(c.body);
|
|
9
9
|
}
|
|
10
10
|
for (const c of off) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
lines.push(`[${c.severity}] off-diff ${c.path ?? ""}`);
|
|
12
|
+
lines.push(c.body);
|
|
13
13
|
}
|
|
14
14
|
if (opts.emptyMessage && inline.length === 0 && off.length === 0) {
|
|
15
|
-
|
|
15
|
+
lines.push("No findings.");
|
|
16
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);
|
|
17
23
|
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,170 @@
|
|
|
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 { tabsBodyRows, tabsHintLine } from "./tabs.js";
|
|
7
|
+
export function initialLineTabsState(tabCount, start = 0) {
|
|
8
|
+
const n = Math.max(1, tabCount);
|
|
9
|
+
return {
|
|
10
|
+
index: Math.max(0, Math.min(start, n - 1)),
|
|
11
|
+
scroll: Array.from({ length: n }, () => 0),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function reduceLineTabsKey(state, key, tabCount, maxScroll) {
|
|
15
|
+
if (key === "q" || key === "escape")
|
|
16
|
+
return { kind: "close" };
|
|
17
|
+
const last = Math.max(0, tabCount - 1);
|
|
18
|
+
if (key === "left") {
|
|
19
|
+
return { kind: "state", state: { ...state, index: Math.max(0, state.index - 1) } };
|
|
20
|
+
}
|
|
21
|
+
if (key === "right") {
|
|
22
|
+
return { kind: "state", state: { ...state, index: Math.min(last, state.index + 1) } };
|
|
23
|
+
}
|
|
24
|
+
const scroll = state.scroll.slice();
|
|
25
|
+
const at = state.index;
|
|
26
|
+
if (key === "up")
|
|
27
|
+
scroll[at] = Math.max(0, (scroll[at] ?? 0) - 1);
|
|
28
|
+
if (key === "down")
|
|
29
|
+
scroll[at] = Math.min(maxScroll, (scroll[at] ?? 0) + 1);
|
|
30
|
+
return { kind: "state", state: { ...state, scroll } };
|
|
31
|
+
}
|
|
32
|
+
function titleOf(tabs, index, color) {
|
|
33
|
+
const parts = tabs.map((tab, i) => {
|
|
34
|
+
if (!color)
|
|
35
|
+
return i === index ? `[${tab.label}]` : ` ${tab.label} `;
|
|
36
|
+
return i === index ? ansi.boldBrightGreen(tab.label) : ansi.dim(tab.label);
|
|
37
|
+
});
|
|
38
|
+
return parts.join(color ? ansi.dim(" · ") : " · ");
|
|
39
|
+
}
|
|
40
|
+
export function layoutLineTabs(tabs, state, columns, rows, color = false) {
|
|
41
|
+
const boxed = preferBoxedUi(columns);
|
|
42
|
+
const cols = terminalColumns(80, columns);
|
|
43
|
+
const outer = frameWidth(80, columns);
|
|
44
|
+
const contentCells = boxed ? Math.max(20, outer - 4) : Math.max(20, cols - 1);
|
|
45
|
+
const bodyRows = tabsBodyRows(rows);
|
|
46
|
+
const tab = tabs[state.index] ?? tabs[0];
|
|
47
|
+
const content = tab ? tab.lines : [];
|
|
48
|
+
const start = Math.min(Math.max(0, state.scroll[state.index] ?? 0), Math.max(0, content.length - bodyRows));
|
|
49
|
+
const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
|
|
50
|
+
const body = content.slice(start, start + bodyRows).map(clip);
|
|
51
|
+
while (body.length < bodyRows)
|
|
52
|
+
body.push("");
|
|
53
|
+
const hint = tabsHintLine("←→ tabs · ↑↓ scroll · esc close", undefined, cols, color);
|
|
54
|
+
const title = titleOf(tabs, state.index, color);
|
|
55
|
+
if (!boxed) {
|
|
56
|
+
const plainTitle = visibleWidth(title) > cols - 1 ? padVisible(title, cols - 1) : title;
|
|
57
|
+
return { lines: [plainTitle, ...body, hint], bodyRows, contentRows: content.length };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
lines: [...roundedBox(body, { title, width: outer, padding: 1 }), hint],
|
|
61
|
+
bodyRows,
|
|
62
|
+
contentRows: content.length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const up = (n) => `\u001b[${n}A`;
|
|
66
|
+
const clearDown = "\u001b[0J";
|
|
67
|
+
const homeClear = `\u001b[H${clearDown}`;
|
|
68
|
+
/** Hold-to-read panel. TTY: tabbed frame. Pipe: print the lines. */
|
|
69
|
+
export async function showLinePanel(label, lines) {
|
|
70
|
+
const input = stdin;
|
|
71
|
+
const output = stdout;
|
|
72
|
+
if (!input.isTTY || !output.isTTY) {
|
|
73
|
+
for (const line of lines)
|
|
74
|
+
console.log(line);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "page";
|
|
78
|
+
await runLineTabsBrowser({ tabs: [{ id, label, lines }] });
|
|
79
|
+
}
|
|
80
|
+
export async function runLineTabsBrowser(opts) {
|
|
81
|
+
const input = opts.input ?? stdin;
|
|
82
|
+
const output = opts.output ?? stdout;
|
|
83
|
+
const tabs = opts.tabs;
|
|
84
|
+
const color = opts.color ?? ansi.enabled;
|
|
85
|
+
if (!input.isTTY || !output.isTTY) {
|
|
86
|
+
for (const tab of tabs) {
|
|
87
|
+
console.log(` ${tab.label}`);
|
|
88
|
+
for (const line of tab.lines)
|
|
89
|
+
console.log(line);
|
|
90
|
+
console.log("");
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let state = initialLineTabsState(tabs.length, opts.initial ?? 0);
|
|
96
|
+
let lastLayout = null;
|
|
97
|
+
let linesDrawn = 0;
|
|
98
|
+
let finished = false;
|
|
99
|
+
const wasRaw = Boolean(input.isRaw);
|
|
100
|
+
readline.emitKeypressEvents(input);
|
|
101
|
+
input.setRawMode(true);
|
|
102
|
+
input.resume();
|
|
103
|
+
function render(prefix) {
|
|
104
|
+
if (prefix)
|
|
105
|
+
output.write(prefix);
|
|
106
|
+
else if (linesDrawn > 0)
|
|
107
|
+
output.write(`${up(linesDrawn)}\r${clearDown}`);
|
|
108
|
+
lastLayout = layoutLineTabs(tabs, state, output.columns, output.rows, color);
|
|
109
|
+
output.write(lastLayout.lines.join("\r\n") + "\r\n");
|
|
110
|
+
linesDrawn = lastLayout.lines.length;
|
|
111
|
+
}
|
|
112
|
+
function finish() {
|
|
113
|
+
if (finished)
|
|
114
|
+
return;
|
|
115
|
+
finished = true;
|
|
116
|
+
input.removeListener("keypress", onKeypress);
|
|
117
|
+
output.removeListener("resize", onResize);
|
|
118
|
+
input.setRawMode(wasRaw);
|
|
119
|
+
input.pause();
|
|
120
|
+
if (linesDrawn > 0)
|
|
121
|
+
output.write(`${up(linesDrawn)}\r${clearDown}`);
|
|
122
|
+
resolve();
|
|
123
|
+
}
|
|
124
|
+
function onResize() {
|
|
125
|
+
if (!finished)
|
|
126
|
+
render(homeClear);
|
|
127
|
+
}
|
|
128
|
+
function onKeypress(str, key) {
|
|
129
|
+
if (finished || !key)
|
|
130
|
+
return;
|
|
131
|
+
if (key.ctrl && key.name === "c") {
|
|
132
|
+
finish();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let name = null;
|
|
136
|
+
if (key.name === "left" || key.name === "right" || key.name === "up" || key.name === "down") {
|
|
137
|
+
name = key.name;
|
|
138
|
+
}
|
|
139
|
+
else if (key.name === "escape")
|
|
140
|
+
name = "escape";
|
|
141
|
+
else if (key.name === "q" || str === "q")
|
|
142
|
+
name = "q";
|
|
143
|
+
if (!name)
|
|
144
|
+
return;
|
|
145
|
+
const maxScroll = lastLayout
|
|
146
|
+
? Math.max(0, lastLayout.contentRows - lastLayout.bodyRows)
|
|
147
|
+
: 0;
|
|
148
|
+
const step = reduceLineTabsKey(state, name, tabs.length, maxScroll);
|
|
149
|
+
if (step.kind === "close") {
|
|
150
|
+
finish();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
state = step.state;
|
|
154
|
+
try {
|
|
155
|
+
render();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
finish();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
input.on("keypress", onKeypress);
|
|
162
|
+
output.on("resize", onResize);
|
|
163
|
+
try {
|
|
164
|
+
render(homeClear);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
finish();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
package/dist/ui/logo.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
|
|
3
|
+
* Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
|
|
4
4
|
*/
|
|
5
|
+
export declare const STORM_MARK: string[];
|
|
6
|
+
/** Widest row of the mark, in cells (every mark glyph is single-cell). */
|
|
7
|
+
export declare const STORM_MARK_WIDTH: number;
|
|
8
|
+
/** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
|
|
5
9
|
export declare const TORNADO_LOGO: string[];
|
|
10
|
+
/** @deprecated Alias. */
|
|
11
|
+
export declare const TORNADO_LOGO_WIDTH: number;
|
|
12
|
+
/** @deprecated Compact home is the full two-row mark. */
|
|
13
|
+
export declare const TORNADO_LOGO_COMPACT: string[];
|
package/dist/ui/logo.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
|
|
3
|
+
* Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
|
|
4
4
|
*/
|
|
5
|
-
export const
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
" ▝▜████▛▘",
|
|
9
|
-
" ▜██▛",
|
|
10
|
-
" ██",
|
|
11
|
-
" ▝▘",
|
|
5
|
+
export const STORM_MARK = [
|
|
6
|
+
"▟██▛",
|
|
7
|
+
" ▜▙",
|
|
12
8
|
];
|
|
9
|
+
/** Widest row of the mark, in cells (every mark glyph is single-cell). */
|
|
10
|
+
export const STORM_MARK_WIDTH = Math.max(...STORM_MARK.map((line) => line.length));
|
|
11
|
+
/** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
|
|
12
|
+
export const TORNADO_LOGO = STORM_MARK;
|
|
13
|
+
/** @deprecated Alias. */
|
|
14
|
+
export const TORNADO_LOGO_WIDTH = STORM_MARK_WIDTH;
|
|
15
|
+
/** @deprecated Compact home is the full two-row mark. */
|
|
16
|
+
export const TORNADO_LOGO_COMPACT = STORM_MARK;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only way a shell command may show a result and return to home.
|
|
3
|
+
* TTY: hold-to-read panel (q). Pipe: print the lines.
|
|
4
|
+
*
|
|
5
|
+
* Do not `console.log` then return from a shell command — the next
|
|
6
|
+
* askLine homes + ED 0 and wipes it. Live waits (login poll, review
|
|
7
|
+
* progress) may still write; the *final* result goes through here
|
|
8
|
+
* or through `openTabsBrowser`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function present(title: string, lines: string[]): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { showLinePanel } from "./line-tabs.js";
|
|
2
|
+
/**
|
|
3
|
+
* The only way a shell command may show a result and return to home.
|
|
4
|
+
* TTY: hold-to-read panel (q). Pipe: print the lines.
|
|
5
|
+
*
|
|
6
|
+
* Do not `console.log` then return from a shell command — the next
|
|
7
|
+
* askLine homes + ED 0 and wipes it. Live waits (login poll, review
|
|
8
|
+
* progress) may still write; the *final* result goes through here
|
|
9
|
+
* or through `openTabsBrowser`.
|
|
10
|
+
*/
|
|
11
|
+
export async function present(title, lines) {
|
|
12
|
+
await showLinePanel(title, lines);
|
|
13
|
+
}
|