mergestorm 0.2.0 → 0.3.0
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 +35 -6
- package/dist/api.d.ts +41 -4
- package/dist/api.js +14 -0
- package/dist/cli.js +8 -1
- package/dist/commands/branches.d.ts +10 -0
- package/dist/commands/branches.js +85 -0
- package/dist/commands/credits.js +10 -4
- package/dist/commands/jobs.d.ts +4 -0
- package/dist/commands/jobs.js +43 -5
- package/dist/commands/login.js +2 -0
- package/dist/commands/review.d.ts +5 -0
- package/dist/commands/review.js +22 -2
- package/dist/shell.d.ts +7 -0
- package/dist/shell.js +50 -54
- package/dist/ui/ansi.d.ts +4 -1
- package/dist/ui/ansi.js +16 -9
- package/dist/ui/banner.d.ts +1 -0
- package/dist/ui/banner.js +65 -14
- package/dist/ui/box.d.ts +11 -0
- package/dist/ui/box.js +36 -0
- package/dist/ui/logo.d.ts +5 -0
- package/dist/ui/logo.js +12 -0
- package/dist/ui/prompt.d.ts +18 -0
- package/dist/ui/prompt.js +220 -0
- package/dist/ui/select.d.ts +13 -0
- package/dist/ui/select.js +103 -0
- package/dist/ui/usage.d.ts +24 -0
- package/dist/ui/usage.js +67 -0
- package/package.json +4 -3
package/dist/ui/ansi.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { stdout } from "node:process";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
function computeEnabled() {
|
|
3
|
+
return process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0"
|
|
4
|
+
? true
|
|
5
|
+
: Boolean(stdout.isTTY) &&
|
|
6
|
+
!process.env.NO_COLOR &&
|
|
7
|
+
process.env.TERM !== "dumb";
|
|
8
|
+
}
|
|
7
9
|
function wrap(code, text) {
|
|
8
|
-
if (!
|
|
10
|
+
if (!computeEnabled())
|
|
9
11
|
return text;
|
|
10
|
-
return
|
|
12
|
+
return `[${code}m${text}[0m`;
|
|
11
13
|
}
|
|
12
14
|
export const ansi = {
|
|
13
|
-
enabled
|
|
15
|
+
get enabled() {
|
|
16
|
+
return computeEnabled();
|
|
17
|
+
},
|
|
14
18
|
green: (t) => wrap("32", t),
|
|
15
19
|
bold: (t) => wrap("1", t),
|
|
16
20
|
dim: (t) => wrap("2", t),
|
|
17
21
|
red: (t) => wrap("31", t),
|
|
18
22
|
yellow: (t) => wrap("33", t),
|
|
19
|
-
|
|
23
|
+
gray: (t) => wrap("90", t),
|
|
24
|
+
brightGreen: (t) => wrap("92", t),
|
|
25
|
+
invert: (t) => wrap("7", t),
|
|
26
|
+
boldGreen: (t) => (computeEnabled() ? `[1;32m${t}[0m` : t),
|
|
20
27
|
};
|
package/dist/ui/banner.d.ts
CHANGED
package/dist/ui/banner.js
CHANGED
|
@@ -2,46 +2,97 @@ import { getMe } from "../api.js";
|
|
|
2
2
|
import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
|
|
3
3
|
import { cliVersion } from "../version.js";
|
|
4
4
|
import { ansi } from "./ansi.js";
|
|
5
|
+
import { roundedBox, visibleWidth } from "./box.js";
|
|
6
|
+
import { TORNADO_LOGO } from "./logo.js";
|
|
7
|
+
import { usageBar } from "./usage.js";
|
|
5
8
|
const WORDMARK = `
|
|
6
9
|
__ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
|
|
7
10
|
| \\/ | __| _ \\/ __| __/ __|_ _/ _ \\| _ \\ \\/ |
|
|
8
11
|
| |\\/| | _|| / (_ | _|\\__ \\ | || (_) | / |\\/| |
|
|
9
12
|
|_| |_|___|_|_\\\\___|___|___/ |_| \\___/|_|_\\_| |_|
|
|
10
13
|
`.trimEnd();
|
|
14
|
+
/** Used by the non-interactive `usage()` path in cli.ts — the boxed banner is shell-only. */
|
|
11
15
|
export function printWordmark() {
|
|
12
16
|
console.log(ansi.boldGreen(WORDMARK));
|
|
13
17
|
console.log(ansi.dim(` review local diffs fast · mergestorm.ai · v${cliVersion()}`));
|
|
14
18
|
console.log("");
|
|
15
19
|
}
|
|
16
|
-
|
|
20
|
+
// Keyed by CLI version so the banner surfaces exactly what shipped in that release.
|
|
21
|
+
const WHATS_NEW = {
|
|
22
|
+
"0.2.0": [
|
|
23
|
+
"Boxed welcome banner with a fresh look",
|
|
24
|
+
"Live `/` slash-command autocomplete in the shell",
|
|
25
|
+
],
|
|
26
|
+
"0.3.0": [
|
|
27
|
+
"Claude-style usage meter (N% used) + credit reset time",
|
|
28
|
+
"`branches` / `chain` — explore branch review timelines",
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
function formatStatusLines(me, key) {
|
|
17
32
|
if (!key) {
|
|
18
|
-
return
|
|
19
|
-
|
|
33
|
+
return [
|
|
34
|
+
`${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in via browser,`,
|
|
35
|
+
` or ${ansi.green("login --key")} to paste an API key`,
|
|
36
|
+
];
|
|
20
37
|
}
|
|
21
38
|
const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
|
|
22
39
|
if (!me) {
|
|
23
|
-
return
|
|
40
|
+
return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
|
|
24
41
|
}
|
|
25
42
|
const plan = me.plan_label_key ?? me.plan_key;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return
|
|
29
|
-
|
|
43
|
+
const s = me.usage.standard;
|
|
44
|
+
// Compact Claude meter in the welcome panel (shorter than the credits view).
|
|
45
|
+
return [
|
|
46
|
+
`${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
|
|
47
|
+
` ${usageBar(s.used, s.limit, { width: 32 })}`,
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
function tipsLines() {
|
|
51
|
+
return [
|
|
52
|
+
ansi.dim("Tips for getting started:"),
|
|
53
|
+
` ${ansi.green("review")} review the diff for main...HEAD`,
|
|
54
|
+
` ${ansi.green("branches")} pick a recently reviewed branch`,
|
|
55
|
+
` ${ansi.green("/help")} list all commands`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
function whatsNewLines(version) {
|
|
59
|
+
const entries = WHATS_NEW[version];
|
|
60
|
+
if (!entries || !entries.length)
|
|
61
|
+
return [];
|
|
62
|
+
return [
|
|
63
|
+
ansi.dim(`What's new in v${version}:`),
|
|
64
|
+
...entries.map((entry) => ` ${ansi.green("•")} ${entry}`),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
function centerLine(line, width) {
|
|
68
|
+
const room = Math.max(0, width - visibleWidth(line));
|
|
69
|
+
const left = Math.floor(room / 2);
|
|
70
|
+
return `${" ".repeat(left)}${line}${" ".repeat(room - left)}`;
|
|
30
71
|
}
|
|
31
72
|
export async function printBannerHeader() {
|
|
32
|
-
printWordmark();
|
|
33
73
|
const cfg = await loadConfig();
|
|
34
74
|
const key = resolveApiKey(cfg);
|
|
35
75
|
let me = null;
|
|
36
76
|
if (key) {
|
|
37
77
|
me = await getMe(cfg);
|
|
38
78
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
79
|
+
const version = cliVersion();
|
|
80
|
+
const textLines = [
|
|
81
|
+
...formatStatusLines(me, key),
|
|
82
|
+
"",
|
|
83
|
+
...tipsLines(),
|
|
84
|
+
...whatsNewLines(version),
|
|
85
|
+
];
|
|
86
|
+
const contentWidth = Math.max(0, ...textLines.map(visibleWidth), ...TORNADO_LOGO.map(visibleWidth));
|
|
87
|
+
const logoLines = TORNADO_LOGO.map((line) => centerLine(ansi.brightGreen(line), contentWidth));
|
|
88
|
+
const panel = roundedBox([...logoLines, "", ...textLines], {
|
|
89
|
+
title: `mergestorm v${version}`,
|
|
90
|
+
});
|
|
91
|
+
for (const line of panel) {
|
|
92
|
+
console.log(line);
|
|
93
|
+
}
|
|
42
94
|
console.log("");
|
|
43
95
|
}
|
|
44
96
|
export function shellPrompt(loggedIn) {
|
|
45
|
-
|
|
46
|
-
return `${name}> `;
|
|
97
|
+
return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
|
|
47
98
|
}
|
package/dist/ui/box.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function visibleWidth(str: string): number;
|
|
2
|
+
export interface RoundedBoxOptions {
|
|
3
|
+
title?: string;
|
|
4
|
+
padding?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Frames `lines` in a box sized to the widest visible line (ANSI-aware).
|
|
8
|
+
* Falls back to ASCII `+ - |` when color is disabled or TERM=dumb, since
|
|
9
|
+
* box-drawing glyphs can render incorrectly on such terminals.
|
|
10
|
+
*/
|
|
11
|
+
export declare function roundedBox(lines: string[], opts?: RoundedBoxOptions): string[];
|
package/dist/ui/box.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ansi } from "./ansi.js";
|
|
2
|
+
const ANSI_PATTERN = /\[[0-9;]*m/g;
|
|
3
|
+
export function visibleWidth(str) {
|
|
4
|
+
return str.replace(ANSI_PATTERN, "").length;
|
|
5
|
+
}
|
|
6
|
+
const ROUNDED = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
7
|
+
const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
8
|
+
function boxChars() {
|
|
9
|
+
return !ansi.enabled || process.env.TERM === "dumb" ? ASCII : ROUNDED;
|
|
10
|
+
}
|
|
11
|
+
function centerLabel(label, width, fill) {
|
|
12
|
+
const room = Math.max(0, width - visibleWidth(label));
|
|
13
|
+
const left = Math.floor(room / 2);
|
|
14
|
+
const right = room - left;
|
|
15
|
+
return `${fill.repeat(left)}${label}${fill.repeat(right)}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Frames `lines` in a box sized to the widest visible line (ANSI-aware).
|
|
19
|
+
* Falls back to ASCII `+ - |` when color is disabled or TERM=dumb, since
|
|
20
|
+
* box-drawing glyphs can render incorrectly on such terminals.
|
|
21
|
+
*/
|
|
22
|
+
export function roundedBox(lines, opts = {}) {
|
|
23
|
+
const padding = Math.max(0, opts.padding ?? 1);
|
|
24
|
+
const chars = boxChars();
|
|
25
|
+
const title = opts.title ? ` ${opts.title} ` : "";
|
|
26
|
+
const innerWidth = Math.max(0, ...lines.map(visibleWidth), visibleWidth(title)) + padding * 2;
|
|
27
|
+
const top = ansi.gray(title
|
|
28
|
+
? `${chars.tl}${centerLabel(title, innerWidth, chars.h)}${chars.tr}`
|
|
29
|
+
: `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`);
|
|
30
|
+
const bottom = ansi.gray(`${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`);
|
|
31
|
+
const body = lines.map((line) => {
|
|
32
|
+
const gap = Math.max(0, innerWidth - padding - visibleWidth(line));
|
|
33
|
+
return `${ansi.gray(chars.v)}${" ".repeat(padding)}${line}${" ".repeat(gap)}${ansi.gray(chars.v)}`;
|
|
34
|
+
});
|
|
35
|
+
return [top, ...body, bottom];
|
|
36
|
+
}
|
package/dist/ui/logo.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
export const TORNADO_LOGO = [
|
|
6
|
+
"▟██████████▛",
|
|
7
|
+
" ▜████████▛",
|
|
8
|
+
" ▝▜████▛▘",
|
|
9
|
+
" ▜██▛",
|
|
10
|
+
" ██",
|
|
11
|
+
" ▝▘",
|
|
12
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CommandSpec {
|
|
2
|
+
name: string;
|
|
3
|
+
summary: string;
|
|
4
|
+
}
|
|
5
|
+
export interface AskLineOptions {
|
|
6
|
+
prompt?: string;
|
|
7
|
+
history?: string[];
|
|
8
|
+
commands?: CommandSpec[];
|
|
9
|
+
}
|
|
10
|
+
/** Thrown when the user asks to close the prompt (Ctrl+D on an empty line). */
|
|
11
|
+
export declare class PromptClosedError extends Error {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Hand-rolled raw-mode line editor: bordered input box, history, and a live
|
|
16
|
+
* slash-command dropdown. Falls back to plain readline on a non-TTY stream.
|
|
17
|
+
*/
|
|
18
|
+
export declare function askLine(opts: AskLineOptions): Promise<string>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { ansi } from "./ansi.js";
|
|
5
|
+
import { roundedBox, visibleWidth } from "./box.js";
|
|
6
|
+
/** Thrown when the user asks to close the prompt (Ctrl+D on an empty line). */
|
|
7
|
+
export class PromptClosedError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("prompt closed");
|
|
10
|
+
this.name = "PromptClosedError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const up = (n) => `[${n}A`;
|
|
14
|
+
const down = (n) => `[${n}B`;
|
|
15
|
+
const col = (n) => `[${n}G`;
|
|
16
|
+
const clearDown = "[0J";
|
|
17
|
+
async function fallbackAskLine(promptLabel) {
|
|
18
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
19
|
+
try {
|
|
20
|
+
return await rl.question(promptLabel ? `${promptLabel}> ` : "> ");
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
rl.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Hand-rolled raw-mode line editor: bordered input box, history, and a live
|
|
28
|
+
* slash-command dropdown. Falls back to plain readline on a non-TTY stream.
|
|
29
|
+
*/
|
|
30
|
+
export async function askLine(opts) {
|
|
31
|
+
const commands = opts.commands ?? [];
|
|
32
|
+
const history = opts.history ?? [];
|
|
33
|
+
if (!input.isTTY || !output.isTTY) {
|
|
34
|
+
return fallbackAskLine(opts.prompt);
|
|
35
|
+
}
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let buffer = "";
|
|
38
|
+
let cursor = 0;
|
|
39
|
+
let historyIndex = history.length;
|
|
40
|
+
let dropdownIndex = 0;
|
|
41
|
+
let linesDrawn = 0;
|
|
42
|
+
let finished = false;
|
|
43
|
+
const wasRaw = Boolean(input.isRaw);
|
|
44
|
+
readline.emitKeypressEvents(input);
|
|
45
|
+
input.setRawMode(true);
|
|
46
|
+
input.resume();
|
|
47
|
+
// Row (0-based) of the "› buffer" content line among the rows we draw each frame.
|
|
48
|
+
const inputRow = (opts.prompt ? 1 : 0) + 1;
|
|
49
|
+
function currentDropdown() {
|
|
50
|
+
if (!buffer.startsWith("/"))
|
|
51
|
+
return [];
|
|
52
|
+
const term = buffer.slice(1).toLowerCase();
|
|
53
|
+
return commands.filter((c) => c.name.toLowerCase().startsWith(term));
|
|
54
|
+
}
|
|
55
|
+
function render() {
|
|
56
|
+
if (linesDrawn > 0) {
|
|
57
|
+
output.write(`${up(inputRow)}${clearDown}`);
|
|
58
|
+
}
|
|
59
|
+
const rows = [];
|
|
60
|
+
if (opts.prompt)
|
|
61
|
+
rows.push(ansi.dim(opts.prompt));
|
|
62
|
+
rows.push(...roundedBox([`${ansi.green("›")} ${buffer}`], { padding: 1 }));
|
|
63
|
+
const dropdown = currentDropdown();
|
|
64
|
+
if (dropdown.length) {
|
|
65
|
+
if (dropdownIndex >= dropdown.length)
|
|
66
|
+
dropdownIndex = dropdown.length - 1;
|
|
67
|
+
dropdown.forEach((c, i) => {
|
|
68
|
+
const label = ` ⁄${c.name.padEnd(12)}${c.summary}`;
|
|
69
|
+
rows.push(i === dropdownIndex ? ansi.invert(label) : ansi.gray(label));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
dropdownIndex = 0;
|
|
74
|
+
}
|
|
75
|
+
output.write(rows.join("\r\n") + "\r\n");
|
|
76
|
+
linesDrawn = rows.length;
|
|
77
|
+
const prefixWidth = visibleWidth(`${ansi.green("›")} `);
|
|
78
|
+
const targetCol = 1 + 1 + prefixWidth + cursor + 1;
|
|
79
|
+
output.write(`${up(linesDrawn - inputRow)}${col(targetCol)}`);
|
|
80
|
+
}
|
|
81
|
+
function cleanup() {
|
|
82
|
+
input.removeListener("keypress", onKeypress);
|
|
83
|
+
input.setRawMode(wasRaw);
|
|
84
|
+
// emitKeypressEvents leaves stdin flowing; pause it so an idle shell
|
|
85
|
+
// (no command running) doesn't keep the process alive after exit/EOF.
|
|
86
|
+
input.pause();
|
|
87
|
+
}
|
|
88
|
+
function finish(value, err) {
|
|
89
|
+
if (finished)
|
|
90
|
+
return;
|
|
91
|
+
finished = true;
|
|
92
|
+
cleanup();
|
|
93
|
+
const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
|
|
94
|
+
if (rowsBelow > 0)
|
|
95
|
+
output.write(down(rowsBelow));
|
|
96
|
+
output.write("\n");
|
|
97
|
+
if (err)
|
|
98
|
+
reject(err);
|
|
99
|
+
else
|
|
100
|
+
resolve(value ?? "");
|
|
101
|
+
}
|
|
102
|
+
function acceptDropdown(dropdown) {
|
|
103
|
+
buffer = `/${dropdown[dropdownIndex].name} `;
|
|
104
|
+
cursor = buffer.length;
|
|
105
|
+
dropdownIndex = 0;
|
|
106
|
+
}
|
|
107
|
+
function onKeypress(str, key) {
|
|
108
|
+
try {
|
|
109
|
+
if (finished || !key)
|
|
110
|
+
return;
|
|
111
|
+
const dropdown = currentDropdown();
|
|
112
|
+
if (key.ctrl && key.name === "c") {
|
|
113
|
+
buffer = "";
|
|
114
|
+
cursor = 0;
|
|
115
|
+
historyIndex = history.length;
|
|
116
|
+
render();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.ctrl && key.name === "d") {
|
|
120
|
+
if (!buffer)
|
|
121
|
+
finish(null, new PromptClosedError());
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (key.name === "return") {
|
|
125
|
+
if (dropdown.length)
|
|
126
|
+
buffer = `/${dropdown[dropdownIndex].name}`;
|
|
127
|
+
finish(buffer);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (key.name === "tab") {
|
|
131
|
+
if (dropdown.length)
|
|
132
|
+
acceptDropdown(dropdown);
|
|
133
|
+
render();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (key.name === "right") {
|
|
137
|
+
if (dropdown.length)
|
|
138
|
+
acceptDropdown(dropdown);
|
|
139
|
+
else
|
|
140
|
+
cursor = Math.min(buffer.length, cursor + 1);
|
|
141
|
+
render();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (key.name === "left") {
|
|
145
|
+
cursor = Math.max(0, cursor - 1);
|
|
146
|
+
render();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (key.name === "up") {
|
|
150
|
+
if (dropdown.length) {
|
|
151
|
+
dropdownIndex = (dropdownIndex - 1 + dropdown.length) % dropdown.length;
|
|
152
|
+
}
|
|
153
|
+
else if (history.length) {
|
|
154
|
+
historyIndex = Math.max(0, historyIndex - 1);
|
|
155
|
+
buffer = history[historyIndex] ?? "";
|
|
156
|
+
cursor = buffer.length;
|
|
157
|
+
}
|
|
158
|
+
render();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (key.name === "down") {
|
|
162
|
+
if (dropdown.length) {
|
|
163
|
+
dropdownIndex = (dropdownIndex + 1) % dropdown.length;
|
|
164
|
+
}
|
|
165
|
+
else if (history.length) {
|
|
166
|
+
historyIndex = Math.min(history.length, historyIndex + 1);
|
|
167
|
+
buffer = historyIndex >= history.length ? "" : history[historyIndex] ?? "";
|
|
168
|
+
cursor = buffer.length;
|
|
169
|
+
}
|
|
170
|
+
render();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (key.name === "home" || (key.ctrl && key.name === "a")) {
|
|
174
|
+
cursor = 0;
|
|
175
|
+
render();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.name === "end" || (key.ctrl && key.name === "e")) {
|
|
179
|
+
cursor = buffer.length;
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key.ctrl && key.name === "u") {
|
|
184
|
+
buffer = buffer.slice(cursor);
|
|
185
|
+
cursor = 0;
|
|
186
|
+
render();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (key.ctrl && key.name === "k") {
|
|
190
|
+
buffer = buffer.slice(0, cursor);
|
|
191
|
+
render();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (key.name === "backspace") {
|
|
195
|
+
if (cursor > 0) {
|
|
196
|
+
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
|
|
197
|
+
cursor -= 1;
|
|
198
|
+
}
|
|
199
|
+
render();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key.name === "delete") {
|
|
203
|
+
buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
|
|
204
|
+
render();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (str && !key.ctrl && !key.meta) {
|
|
208
|
+
buffer = buffer.slice(0, cursor) + str + buffer.slice(cursor);
|
|
209
|
+
cursor += str.length;
|
|
210
|
+
render();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
finish(null, err);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
input.on("keypress", onKeypress);
|
|
218
|
+
render();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type SelectOptions<T> = {
|
|
2
|
+
title?: string;
|
|
3
|
+
items: T[];
|
|
4
|
+
render: (item: T, index: number) => string;
|
|
5
|
+
/** Hint printed under the list (default: arrow/enter/esc keys). */
|
|
6
|
+
hint?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Raw-mode arrow-key list selector (Claude-style). Returns the chosen item, or
|
|
10
|
+
* `null` if cancelled / empty. Non-TTY: prints a numbered list and returns null
|
|
11
|
+
* (caller should fall back to a table / prompt).
|
|
12
|
+
*/
|
|
13
|
+
export declare function selectFromList<T>(opts: SelectOptions<T>): Promise<T | null>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { ansi } from "./ansi.js";
|
|
4
|
+
const up = (n) => `\u001b[${n}A`;
|
|
5
|
+
const clearDown = "\u001b[0J";
|
|
6
|
+
/**
|
|
7
|
+
* Raw-mode arrow-key list selector (Claude-style). Returns the chosen item, or
|
|
8
|
+
* `null` if cancelled / empty. Non-TTY: prints a numbered list and returns null
|
|
9
|
+
* (caller should fall back to a table / prompt).
|
|
10
|
+
*/
|
|
11
|
+
export async function selectFromList(opts) {
|
|
12
|
+
const items = opts.items;
|
|
13
|
+
if (items.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
if (!input.isTTY || !output.isTTY) {
|
|
16
|
+
if (opts.title)
|
|
17
|
+
console.log(ansi.bold(` ${opts.title}`));
|
|
18
|
+
items.forEach((item, i) => {
|
|
19
|
+
console.log(` ${String(i + 1).padStart(2)}. ${opts.render(item, i)}`);
|
|
20
|
+
});
|
|
21
|
+
console.log(ansi.dim(opts.hint ?? " (non-interactive — use `chain <slug>` or re-run on a TTY to select)"));
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let index = 0;
|
|
26
|
+
let linesDrawn = 0;
|
|
27
|
+
let finished = false;
|
|
28
|
+
const wasRaw = Boolean(input.isRaw);
|
|
29
|
+
readline.emitKeypressEvents(input);
|
|
30
|
+
input.setRawMode(true);
|
|
31
|
+
input.resume();
|
|
32
|
+
function render() {
|
|
33
|
+
if (linesDrawn > 0) {
|
|
34
|
+
output.write(`${up(linesDrawn)}${clearDown}`);
|
|
35
|
+
}
|
|
36
|
+
const rows = [];
|
|
37
|
+
if (opts.title)
|
|
38
|
+
rows.push(ansi.bold(opts.title));
|
|
39
|
+
items.forEach((item, i) => {
|
|
40
|
+
const body = opts.render(item, i);
|
|
41
|
+
const marker = i === index ? ansi.green("›") : " ";
|
|
42
|
+
const line = ` ${marker} ${body}`;
|
|
43
|
+
rows.push(i === index ? ansi.invert(line) : line);
|
|
44
|
+
});
|
|
45
|
+
rows.push(ansi.dim(opts.hint ?? "↑↓ navigate · enter select · esc cancel"));
|
|
46
|
+
output.write(rows.join("\r\n") + "\r\n");
|
|
47
|
+
linesDrawn = rows.length;
|
|
48
|
+
}
|
|
49
|
+
function cleanup() {
|
|
50
|
+
input.removeListener("keypress", onKeypress);
|
|
51
|
+
input.setRawMode(wasRaw);
|
|
52
|
+
input.pause();
|
|
53
|
+
}
|
|
54
|
+
function finish(value) {
|
|
55
|
+
if (finished)
|
|
56
|
+
return;
|
|
57
|
+
finished = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
output.write("\n");
|
|
60
|
+
resolve(value);
|
|
61
|
+
}
|
|
62
|
+
function onKeypress(_str, key) {
|
|
63
|
+
if (finished || !key)
|
|
64
|
+
return;
|
|
65
|
+
if (key.name === "up") {
|
|
66
|
+
index = (index - 1 + items.length) % items.length;
|
|
67
|
+
try {
|
|
68
|
+
render();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
finish(null);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.name === "down") {
|
|
76
|
+
index = (index + 1) % items.length;
|
|
77
|
+
try {
|
|
78
|
+
render();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
finish(null);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (key.name === "return") {
|
|
86
|
+
finish(items[index]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (key.name === "escape" ||
|
|
90
|
+
key.name === "q" ||
|
|
91
|
+
(key.ctrl && key.name === "c")) {
|
|
92
|
+
finish(null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
input.on("keypress", onKeypress);
|
|
96
|
+
try {
|
|
97
|
+
render();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
finish(null);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type UsageBarOpts = {
|
|
2
|
+
/** Bar width in cells (default 56 — Claude-length). */
|
|
3
|
+
width?: number;
|
|
4
|
+
/** When false, return plain text with no ANSI (default: respect ansi.enabled). */
|
|
5
|
+
color?: boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Claude-style usage meter — long thin bar + percent only:
|
|
9
|
+
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
10
|
+
*
|
|
11
|
+
* No used/limit clutter. Edge cases:
|
|
12
|
+
* - `limit === 0` → dim "not included" (premium on non-Maelstrom)
|
|
13
|
+
* - `limit == null` → "unlimited" (dev-bypass)
|
|
14
|
+
*/
|
|
15
|
+
export declare function usageBar(used: number, limit: number | null | undefined, opts?: UsageBarOpts): string;
|
|
16
|
+
/** Start of the next UTC calendar month (matches API `resets_at`). */
|
|
17
|
+
export declare function nextUtcMonthStart(now?: Date): string;
|
|
18
|
+
/**
|
|
19
|
+
* Claude-style reset footer: `Resets Aug 1, 12:00am (UTC)`.
|
|
20
|
+
* Falls back to next UTC month start when `iso` is missing/invalid.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
|
|
23
|
+
/** Alias kept for callers that still import `formatResetDate`. */
|
|
24
|
+
export declare function formatResetDate(iso: string | null | undefined): string;
|
package/dist/ui/usage.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ansi } from "./ansi.js";
|
|
2
|
+
/**
|
|
3
|
+
* Claude-style usage meter — long thin bar + percent only:
|
|
4
|
+
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
5
|
+
*
|
|
6
|
+
* No used/limit clutter. Edge cases:
|
|
7
|
+
* - `limit === 0` → dim "not included" (premium on non-Maelstrom)
|
|
8
|
+
* - `limit == null` → "unlimited" (dev-bypass)
|
|
9
|
+
*/
|
|
10
|
+
export function usageBar(used, limit, opts = {}) {
|
|
11
|
+
const width = Math.max(8, opts.width ?? 56);
|
|
12
|
+
const color = opts.color ?? ansi.enabled;
|
|
13
|
+
if (limit == null) {
|
|
14
|
+
return color ? ansi.dim("unlimited") : "unlimited";
|
|
15
|
+
}
|
|
16
|
+
if (limit === 0) {
|
|
17
|
+
return color ? ansi.dim("not included") : "not included";
|
|
18
|
+
}
|
|
19
|
+
const safeUsed = Math.max(0, Number.isFinite(used) ? used : 0);
|
|
20
|
+
const ratio = Math.min(1, safeUsed / limit);
|
|
21
|
+
const filled = Math.round(ratio * width);
|
|
22
|
+
const empty = Math.max(0, width - filled);
|
|
23
|
+
const pct = Math.min(100, Math.round((safeUsed / limit) * 100));
|
|
24
|
+
// Filled segment colored; empty + label stay dim — matches Claude's quiet meter.
|
|
25
|
+
const filledSeg = "█".repeat(filled);
|
|
26
|
+
const emptySeg = "░".repeat(empty);
|
|
27
|
+
const suffix = ` ${pct}% used`;
|
|
28
|
+
if (!color) {
|
|
29
|
+
return `[${filledSeg}${emptySeg}]${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
let fillColor = ansi.green;
|
|
32
|
+
if (ratio >= 0.9)
|
|
33
|
+
fillColor = ansi.red;
|
|
34
|
+
else if (ratio >= 0.75)
|
|
35
|
+
fillColor = ansi.yellow;
|
|
36
|
+
return (`[${fillColor(filledSeg)}${ansi.dim(emptySeg)}]` + ansi.dim(suffix));
|
|
37
|
+
}
|
|
38
|
+
/** Start of the next UTC calendar month (matches API `resets_at`). */
|
|
39
|
+
export function nextUtcMonthStart(now = new Date()) {
|
|
40
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Claude-style reset footer: `Resets Aug 1, 12:00am (UTC)`.
|
|
44
|
+
* Falls back to next UTC month start when `iso` is missing/invalid.
|
|
45
|
+
*/
|
|
46
|
+
export function formatResetLabel(iso, now = new Date()) {
|
|
47
|
+
const d = iso ? new Date(iso) : new Date(nextUtcMonthStart(now));
|
|
48
|
+
const when = Number.isNaN(d.getTime()) ? new Date(nextUtcMonthStart(now)) : d;
|
|
49
|
+
const months = [
|
|
50
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
51
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
52
|
+
];
|
|
53
|
+
const month = months[when.getUTCMonth()];
|
|
54
|
+
const day = when.getUTCDate();
|
|
55
|
+
let h = when.getUTCHours();
|
|
56
|
+
const m = when.getUTCMinutes();
|
|
57
|
+
const ampm = h >= 12 ? "pm" : "am";
|
|
58
|
+
h = h % 12;
|
|
59
|
+
if (h === 0)
|
|
60
|
+
h = 12;
|
|
61
|
+
const mm = String(m).padStart(2, "0");
|
|
62
|
+
return `Resets ${month} ${day}, ${h}:${mm}${ampm} (UTC)`;
|
|
63
|
+
}
|
|
64
|
+
/** Alias kept for callers that still import `formatResetDate`. */
|
|
65
|
+
export function formatResetDate(iso) {
|
|
66
|
+
return formatResetLabel(iso);
|
|
67
|
+
}
|