mergestorm 0.3.13 → 0.3.16

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 (66) hide show
  1. package/README.md +16 -17
  2. package/dist/api.d.ts +103 -6
  3. package/dist/api.js +195 -9
  4. package/dist/automation-catalog.d.ts +37 -0
  5. package/dist/automation-catalog.js +13 -0
  6. package/dist/client/index.d.ts +3 -2
  7. package/dist/client/index.js +3 -2
  8. package/dist/commands/branches.d.ts +6 -2
  9. package/dist/commands/branches.js +29 -14
  10. package/dist/commands/browse.d.ts +39 -0
  11. package/dist/commands/browse.js +252 -0
  12. package/dist/commands/credits.js +7 -10
  13. package/dist/commands/help.d.ts +5 -0
  14. package/dist/commands/help.js +93 -0
  15. package/dist/commands/jobs.d.ts +11 -2
  16. package/dist/commands/jobs.js +64 -15
  17. package/dist/commands/login.js +4 -3
  18. package/dist/commands/logout.js +2 -1
  19. package/dist/commands/pr.d.ts +22 -0
  20. package/dist/commands/pr.js +127 -0
  21. package/dist/commands/queue.d.ts +12 -0
  22. package/dist/commands/queue.js +160 -0
  23. package/dist/commands/registry.js +98 -21
  24. package/dist/commands/review-client.d.ts +13 -0
  25. package/dist/commands/review-client.js +110 -0
  26. package/dist/commands/review.d.ts +6 -0
  27. package/dist/commands/review.js +18 -7
  28. package/dist/commands/settings.d.ts +17 -0
  29. package/dist/commands/settings.js +82 -0
  30. package/dist/commands/skill.d.ts +20 -0
  31. package/dist/commands/skill.js +145 -0
  32. package/dist/commands/stack.d.ts +57 -2
  33. package/dist/commands/stack.js +352 -87
  34. package/dist/commands/status.d.ts +2 -0
  35. package/dist/commands/status.js +30 -15
  36. package/dist/commands/whoami.d.ts +3 -1
  37. package/dist/commands/whoami.js +21 -7
  38. package/dist/errors.d.ts +1 -1
  39. package/dist/repo-identity.d.ts +1 -1
  40. package/dist/repo-identity.js +13 -2
  41. package/dist/shell.js +39 -31
  42. package/dist/stack-dto.d.ts +92 -1
  43. package/dist/stack-meta.d.ts +12 -0
  44. package/dist/stack-meta.js +32 -3
  45. package/dist/ui/ansi.d.ts +2 -0
  46. package/dist/ui/ansi.js +2 -0
  47. package/dist/ui/banner.d.ts +34 -2
  48. package/dist/ui/banner.js +166 -79
  49. package/dist/ui/findings.d.ts +2 -1
  50. package/dist/ui/findings.js +13 -7
  51. package/dist/ui/line-tabs.d.ts +27 -0
  52. package/dist/ui/line-tabs.js +170 -0
  53. package/dist/ui/logo.d.ts +10 -2
  54. package/dist/ui/logo.js +13 -9
  55. package/dist/ui/present.d.ts +10 -0
  56. package/dist/ui/present.js +13 -0
  57. package/dist/ui/prompt.d.ts +58 -1
  58. package/dist/ui/prompt.js +225 -42
  59. package/dist/ui/select.js +1 -1
  60. package/dist/ui/tabs.d.ts +125 -0
  61. package/dist/ui/tabs.js +480 -0
  62. package/dist/ui/usage.d.ts +4 -0
  63. package/dist/ui/usage.js +22 -2
  64. package/package.json +2 -1
  65. package/skill/SKILL.md +98 -0
  66. package/skill/mergestorm-pr-loop/SKILL.md +84 -0
package/dist/ui/banner.js CHANGED
@@ -3,9 +3,9 @@ import { CommandError } from "../errors.js";
3
3
  import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { cliVersion } from "../version.js";
5
5
  import { ansi } from "./ansi.js";
6
- import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, visibleWidth, } from "./box.js";
7
- import { TORNADO_LOGO } from "./logo.js";
8
- import { usageBar } from "./usage.js";
6
+ import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
7
+ import { STORM_MARK, STORM_MARK_WIDTH } from "./logo.js";
8
+ import { formatResetLabel, usageBar } from "./usage.js";
9
9
  const WORDMARK = `
10
10
  __ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
11
11
  | \\/ | __| _ \\/ __| __/ __|_ _/ _ \\| _ \\ \\/ |
@@ -14,7 +14,7 @@ const WORDMARK = `
14
14
  `.trimEnd();
15
15
  /** Used by the non-interactive `usage()` path in cli.ts — the boxed banner is shell-only. */
16
16
  export function printWordmark() {
17
- console.log(ansi.boldGreen(WORDMARK));
17
+ console.log(ansi.boldBrightGreen(WORDMARK));
18
18
  console.log(ansi.dim(` review local diffs fast · mergestorm.ai · v${cliVersion()}`));
19
19
  console.log("");
20
20
  }
@@ -73,53 +73,169 @@ 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
+ ],
84
+ "0.3.16": [
85
+ "PR loop: verify each finding, prefer a small diff, post mergestorm-loop: dismiss on skip",
86
+ ],
76
87
  };
77
- function formatStatusLines(me, key, barWidth, keyInvalid = false) {
78
- if (!key) {
79
- return [
80
- `${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in`,
81
- ` or ${ansi.green("login --key")} to paste an API key`,
82
- ];
88
+ function densityFromVariant(variant) {
89
+ if (variant === "mini" || variant === "nano")
90
+ return variant;
91
+ return variant ? "compact" : "full";
92
+ }
93
+ /** Home usage bars stay compact (Claude-style), not full-bleed. */
94
+ const BANNER_BAR_MAX = 30;
95
+ /** `[` + `]` + ` 100% used` around the bar cells. */
96
+ const BAR_CHROME_CELLS = 12;
97
+ function statusLine(state) {
98
+ if (!state.key) {
99
+ return `${ansi.dim("○")} not logged in · type ${ansi.brightGreen("login")} to sign in`;
83
100
  }
84
- if (keyInvalid) {
85
- return [
86
- `${ansi.dim("○")} API key invalid or revoked — type ${ansi.green("login")} to sign in again`,
87
- ];
101
+ if (state.keyInvalid) {
102
+ return `${ansi.dim("○")} API key invalid or revoked · type ${ansi.brightGreen("login")} to sign in again`;
88
103
  }
89
- const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
90
- if (!me) {
91
- return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
104
+ const display = state.me?.key.prefix ?? keyDisplay(state.key) ?? "msk_live_…";
105
+ if (!state.me) {
106
+ return `${ansi.brightGreen("●")} ${ansi.bold(display)} ${ansi.dim("· account details unavailable")}`;
92
107
  }
93
- const plan = me.plan_label_key ?? me.plan_key;
94
- const s = me.usage.standard;
95
- return [
96
- `${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
97
- usageBar(s.used, s.limit, { width: barWidth }),
98
- ];
108
+ const plan = state.me.plan_label_key ?? state.me.plan_key;
109
+ return `${ansi.brightGreen("●")} ${ansi.bold(display)} · ${plan}`;
99
110
  }
100
- function tipsLines() {
101
- return [
102
- ansi.dim("Tips for getting started"),
103
- ` ${ansi.green("review")} review origin/HEAD or main`,
104
- ` ${ansi.green("stack")} create → submit → restack → land`,
105
- ` ${ansi.green("/help")} list all commands`,
106
- ];
111
+ function usageLine(me, contentCells, indent) {
112
+ const room = Math.max(8, contentCells - indent - BAR_CHROME_CELLS);
113
+ const bar = usageBar(me.usage.standard.used, me.usage.standard.limit, {
114
+ width: Math.min(BANNER_BAR_MAX, room),
115
+ });
116
+ const reset = ansi.dim(`· ${formatResetLabel(me.resets_at)}`);
117
+ const combined = `${bar} ${reset}`;
118
+ return visibleWidth(combined) <= contentCells - indent ? combined : bar;
119
+ }
120
+ /** First candidate that fits. Never mid-word clip — omit if nothing fits. */
121
+ function firstFit(options, cells) {
122
+ for (const opt of options) {
123
+ if (visibleWidth(opt) <= cells)
124
+ return opt;
125
+ }
126
+ return null;
127
+ }
128
+ function tipsLine(cells) {
129
+ const sep = ansi.dim(" · ");
130
+ return firstFit([
131
+ [
132
+ `${ansi.brightGreen("review")} ${ansi.dim("a diff")}`,
133
+ `${ansi.brightGreen("usage")} ${ansi.dim("for tabs")}`,
134
+ `${ansi.brightGreen("/help")} ${ansi.dim("for all commands")}`,
135
+ ].join(sep),
136
+ [ansi.brightGreen("review"), ansi.brightGreen("usage"), ansi.brightGreen("/help")].join(sep),
137
+ [ansi.brightGreen("review"), ansi.brightGreen("/help")].join(sep),
138
+ ], cells);
139
+ }
140
+ function tagline(cells) {
141
+ return firstFit([
142
+ ansi.dim("local reviews + stacked PRs · mergestorm.ai"),
143
+ ansi.dim("reviews + stacked PRs"),
144
+ ansi.dim("reviews · stacks"),
145
+ ], cells);
107
146
  }
108
- function whatsNewLines(version) {
147
+ /** Shorten until it fits. Never clip mid-word (that was "…Jobs tabs (open with usa"). */
148
+ function whatsNewLine(version, cells) {
109
149
  const entries = WHATS_NEW[version];
110
150
  if (!entries || !entries.length)
111
- return [];
112
- return [
113
- ansi.dim(`What's new in v${version}`),
114
- ...entries.map((entry) => ` ${ansi.green("•")} ${entry}`),
151
+ return null;
152
+ const first = entries[0];
153
+ const noParen = first.replace(/\s*\([^)]*\)\s*$/, "").trim();
154
+ const candidates = [
155
+ `New in v${version}: ${first}`,
156
+ `New in v${version}: ${noParen}`,
157
+ `New: ${noParen}`,
158
+ `New in v${version}`,
115
159
  ];
160
+ const extra = entries.slice(1);
161
+ const withExtra = `${candidates[0]} · ${extra.join(" · ")}`;
162
+ const options = extra.length > 0 ? [withExtra, ...candidates] : candidates;
163
+ const hit = firstFit(options, cells);
164
+ return hit ? ansi.dim(hit) : null;
116
165
  }
117
- function centerLine(line, width) {
118
- const room = Math.max(0, width - visibleWidth(line));
119
- const left = Math.floor(room / 2);
120
- return `${" ".repeat(left)}${line}${" ".repeat(room - left)}`;
166
+ /**
167
+ * The whole welcome panel as rows, reflowed to `columns`. Every row fits in
168
+ * `columns - 1` cells so a redraw's cursor math stays honest; below
169
+ * MIN_BOXED_COLUMNS the frame drops away instead of wrapping.
170
+ */
171
+ export function buildBannerRows(state, columns, opts) {
172
+ const boxed = preferBoxedUi(columns);
173
+ const density = opts?.density ?? (opts?.compact ? "compact" : "full");
174
+ const outer = frameWidth(80, columns);
175
+ const contentCells = boxed
176
+ ? Math.max(20, outer - 4)
177
+ : Math.max(20, terminalColumns(80, columns) - 1);
178
+ const indent = STORM_MARK_WIDTH + 2;
179
+ const pad = " ".repeat(indent);
180
+ const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
181
+ const mark = (row) => padVisible(ansi.brightGreen(STORM_MARK[row] ?? ""), indent);
182
+ const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
183
+ if (density === "nano") {
184
+ const one = `${mark(0)}${title} ${statusLine(state)}`;
185
+ return [clip(one)];
186
+ }
187
+ if (density === "mini") {
188
+ // Short pane: mark + who you are, then the same cues as the full home.
189
+ const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
190
+ const line1 = state.me && !state.keyInvalid
191
+ ? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
192
+ : clip(mark(1));
193
+ const room = contentCells - indent;
194
+ const blurb = tagline(room);
195
+ const tips = tipsLine(room);
196
+ const fresh = whatsNewLine(state.version, room);
197
+ return [
198
+ line0,
199
+ line1,
200
+ ...(blurb ? [clip(`${pad}${blurb}`)] : []),
201
+ ...(tips ? [clip(`${pad}${tips}`)] : []),
202
+ ...(fresh ? [clip(`${pad}${fresh}`)] : []),
203
+ ];
204
+ }
205
+ const content = [
206
+ clip(`${pad}${title}`),
207
+ clip(`${mark(0)}${statusLine(state)}`),
208
+ ];
209
+ if (state.me && !state.keyInvalid) {
210
+ content.push(clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`));
211
+ }
212
+ else {
213
+ content.push(clip(mark(1)));
214
+ }
215
+ const room = contentCells - indent;
216
+ const blurb = tagline(room);
217
+ const tips = tipsLine(room);
218
+ const fresh = whatsNewLine(state.version, room);
219
+ if (blurb)
220
+ content.push(clip(`${pad}${blurb}`));
221
+ if (tips)
222
+ content.push(clip(`${pad}${tips}`));
223
+ if (fresh)
224
+ content.push(clip(`${pad}${fresh}`));
225
+ if (boxed && density === "full") {
226
+ content.splice(3, 0, "");
227
+ return [...roundedBox(content, { width: outer, padding: 1 })];
228
+ }
229
+ if (boxed) {
230
+ return [...roundedBox(content, { width: outer, padding: 1 })];
231
+ }
232
+ return content.map(clip);
121
233
  }
122
- export async function printBannerHeader() {
234
+ /**
235
+ * Print the welcome panel and return a handle whose `rows()` reflows the
236
+ * same data for a new width (used by the prompt's resize redraw).
237
+ */
238
+ export async function printBannerHeader(opts) {
123
239
  const cfg = await loadConfig();
124
240
  const key = resolveApiKey(cfg);
125
241
  let me = null;
@@ -138,46 +254,17 @@ export async function printBannerHeader() {
138
254
  }
139
255
  }
140
256
  }
141
- const version = cliVersion();
142
- const outer = frameWidth();
143
- // Inner content width inside the box (borders + padding).
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)
257
+ const state = { version: cliVersion(), me, key, keyInvalid };
258
+ if (opts?.paint !== false) {
259
+ for (const line of buildBannerRows(state)) {
167
260
  console.log(line);
168
- console.log("");
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);
261
+ }
178
262
  }
179
- console.log("");
263
+ return {
264
+ rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
265
+ };
180
266
  }
181
- export function shellPrompt(loggedIn) {
182
- return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
267
+ export function shellPrompt(_loggedIn) {
268
+ // Bright mark green. Never dim, never 32, never the word "mergestorm".
269
+ return ansi.boldBrightGreen("mg");
183
270
  }
@@ -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
- /** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
29
+ export declare function formatFindingsLines(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): string[];
30
+ /** Print inline + off-diff findings the same way for oneshot `review` / `status --pretty`. */
30
31
  export declare function renderFindings(findings: ReviewFindings | null | undefined, opts?: RenderFindingsOptions): void;
@@ -1,17 +1,23 @@
1
- /** Print inline + off-diff findings the same way for `review` and `status --pretty`. */
2
- export function renderFindings(findings, opts = {}) {
1
+ export function formatFindingsLines(findings, opts = {}) {
2
+ const lines = [];
3
3
  const inline = findings?.inline ?? [];
4
4
  const off = findings?.off_diff ?? findings?.offDiff ?? [];
5
5
  for (const c of inline) {
6
6
  const lane = c.specialist ? `[${c.specialist}]` : "";
7
- console.log(`\n[${c.severity}]${lane} ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
8
- console.log(c.body);
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
- console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
12
- console.log(c.body);
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
- console.log("\nNo findings.");
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
- * Pixelated funnel logo, colored by the caller (see banner.ts).
3
- * Kept as plain glyphs here so consumers can measure/center it with visibleWidth.
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
- * Pixelated funnel logo, colored by the caller (see banner.ts).
3
- * Kept as plain glyphs here so consumers can measure/center it with visibleWidth.
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 TORNADO_LOGO = [
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
+ }