mergestorm 0.1.1 → 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 +64 -8
- package/dist/api.d.ts +85 -0
- package/dist/api.js +85 -0
- package/dist/cli.js +72 -353
- package/dist/commands/branches.d.ts +10 -0
- package/dist/commands/branches.js +85 -0
- package/dist/commands/credits.d.ts +1 -0
- package/dist/commands/credits.js +28 -0
- package/dist/commands/jobs.d.ts +6 -0
- package/dist/commands/jobs.js +107 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +103 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/review.d.ts +11 -0
- package/dist/commands/review.js +129 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +39 -0
- package/dist/commands/whoami.d.ts +1 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +41 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +18 -0
- package/dist/git.d.ts +9 -0
- package/dist/git.js +66 -0
- package/dist/shell.d.ts +8 -0
- package/dist/shell.js +183 -0
- package/dist/ui/ansi.d.ts +12 -0
- package/dist/ui/ansi.js +27 -0
- package/dist/ui/banner.d.ts +4 -0
- package/dist/ui/banner.js +98 -0
- 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/dist/version.d.ts +1 -0
- package/dist/version.js +11 -0
- package/package.json +5 -4
package/dist/shell.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { stdout as output } from "node:process";
|
|
2
|
+
import { loadConfig, resolveApiKey } from "./config.js";
|
|
3
|
+
import { CommandError, DetachedError } from "./errors.js";
|
|
4
|
+
import { cmdBranches, cmdChain } from "./commands/branches.js";
|
|
5
|
+
import { cmdCredits } from "./commands/credits.js";
|
|
6
|
+
import { cmdJobs, cmdThread } from "./commands/jobs.js";
|
|
7
|
+
import { cmdLogin } from "./commands/login.js";
|
|
8
|
+
import { cmdLogout } from "./commands/logout.js";
|
|
9
|
+
import { cmdReview } from "./commands/review.js";
|
|
10
|
+
import { cmdStatus } from "./commands/status.js";
|
|
11
|
+
import { cmdWhoami } from "./commands/whoami.js";
|
|
12
|
+
import { ansi } from "./ui/ansi.js";
|
|
13
|
+
import { printBannerHeader, shellPrompt } from "./ui/banner.js";
|
|
14
|
+
import { askLine, PromptClosedError } from "./ui/prompt.js";
|
|
15
|
+
export const COMMANDS = [
|
|
16
|
+
{ name: "help", summary: "Show available commands" },
|
|
17
|
+
{ name: "login", summary: "Sign in via browser (or paste a key)" },
|
|
18
|
+
{ name: "logout", summary: "Remove the stored API key" },
|
|
19
|
+
{ name: "review", summary: "Review git diff (default main...HEAD)" },
|
|
20
|
+
{ name: "status", summary: "Show a review job" },
|
|
21
|
+
{ name: "credits", summary: "Credit balance with usage bars" },
|
|
22
|
+
{ name: "usage", summary: "Credit balance with usage bars" },
|
|
23
|
+
{ name: "jobs", summary: "Recent review jobs (default 10)" },
|
|
24
|
+
{ name: "branches", summary: "Pick a recently reviewed branch" },
|
|
25
|
+
{ name: "chains", summary: "Alias for branches" },
|
|
26
|
+
{ name: "chain", summary: "Show a branch review-chain timeline" },
|
|
27
|
+
{ name: "whoami", summary: "Key + plan + API base" },
|
|
28
|
+
{ name: "thread", summary: "Jobs in a review thread" },
|
|
29
|
+
{ name: "clear", summary: "Clear screen and reprint banner" },
|
|
30
|
+
{ name: "exit", summary: "Leave the shell" },
|
|
31
|
+
{ name: "quit", summary: "Leave the shell" },
|
|
32
|
+
];
|
|
33
|
+
function printHelp() {
|
|
34
|
+
const rows = COMMANDS.map((c) => ` ${ansi.green("/" + c.name.padEnd(10))} ${c.summary}`).join("\n");
|
|
35
|
+
console.log(`
|
|
36
|
+
${ansi.bold("Commands")}
|
|
37
|
+
${rows}
|
|
38
|
+
|
|
39
|
+
Dashboard: https://mergestorm.ai/dashboard/api
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
/** Strips an optional leading "/" so both "review" and "/review" work. */
|
|
43
|
+
export function parseLine(line) {
|
|
44
|
+
let trimmed = line.trim();
|
|
45
|
+
if (!trimmed)
|
|
46
|
+
return null;
|
|
47
|
+
if (trimmed.startsWith("/"))
|
|
48
|
+
trimmed = trimmed.slice(1).trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
return null;
|
|
51
|
+
const parts = trimmed.split(/\s+/);
|
|
52
|
+
const cmd = parts[0].toLowerCase();
|
|
53
|
+
return { cmd, args: parts.slice(1) };
|
|
54
|
+
}
|
|
55
|
+
export async function runShell() {
|
|
56
|
+
try {
|
|
57
|
+
await printBannerHeader();
|
|
58
|
+
}
|
|
59
|
+
catch { /* banner is best-effort */ }
|
|
60
|
+
const history = [];
|
|
61
|
+
let busyAbort = null;
|
|
62
|
+
let sigintHint = false;
|
|
63
|
+
const onSigint = () => {
|
|
64
|
+
if (busyAbort) {
|
|
65
|
+
busyAbort.abort();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!sigintHint) {
|
|
69
|
+
output.write(`\n${ansi.dim("(type exit or press ctrl+d to quit)")}\n`);
|
|
70
|
+
sigintHint = true;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
output.write("\n");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
process.on("SIGINT", onSigint);
|
|
77
|
+
try {
|
|
78
|
+
while (true) {
|
|
79
|
+
const cfg = await loadConfig();
|
|
80
|
+
const loggedIn = Boolean(resolveApiKey(cfg));
|
|
81
|
+
let line;
|
|
82
|
+
try {
|
|
83
|
+
line = await askLine({
|
|
84
|
+
prompt: shellPrompt(loggedIn),
|
|
85
|
+
history,
|
|
86
|
+
commands: COMMANDS,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof PromptClosedError) {
|
|
91
|
+
console.log("");
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
sigintHint = false;
|
|
97
|
+
const parsed = parseLine(line);
|
|
98
|
+
if (!parsed)
|
|
99
|
+
continue;
|
|
100
|
+
if (line.trim())
|
|
101
|
+
history.push(line);
|
|
102
|
+
const { cmd, args } = parsed;
|
|
103
|
+
if (cmd === "exit" || cmd === "quit")
|
|
104
|
+
break;
|
|
105
|
+
if (cmd === "help" || cmd === "?") {
|
|
106
|
+
printHelp();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (cmd === "clear") {
|
|
110
|
+
output.write("[2J[H");
|
|
111
|
+
try {
|
|
112
|
+
await printBannerHeader();
|
|
113
|
+
}
|
|
114
|
+
catch { /* banner is best-effort */ }
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
busyAbort = new AbortController();
|
|
118
|
+
try {
|
|
119
|
+
if (cmd === "login") {
|
|
120
|
+
await cmdLogin(args);
|
|
121
|
+
await printBannerHeader();
|
|
122
|
+
}
|
|
123
|
+
else if (cmd === "logout") {
|
|
124
|
+
await cmdLogout();
|
|
125
|
+
await printBannerHeader();
|
|
126
|
+
}
|
|
127
|
+
else if (cmd === "review") {
|
|
128
|
+
await cmdReview(args, { signal: busyAbort.signal, interactive: true });
|
|
129
|
+
}
|
|
130
|
+
else if (cmd === "status") {
|
|
131
|
+
if (!args[0]) {
|
|
132
|
+
throw new CommandError('usage: status <job_id>');
|
|
133
|
+
}
|
|
134
|
+
await cmdStatus(args[0], { format: "pretty", signal: busyAbort.signal });
|
|
135
|
+
}
|
|
136
|
+
else if (cmd === "credits" || cmd === "usage") {
|
|
137
|
+
await cmdCredits(args);
|
|
138
|
+
}
|
|
139
|
+
else if (cmd === "jobs") {
|
|
140
|
+
await cmdJobs(args);
|
|
141
|
+
}
|
|
142
|
+
else if (cmd === "branches" || cmd === "chains") {
|
|
143
|
+
await cmdBranches(args);
|
|
144
|
+
}
|
|
145
|
+
else if (cmd === "chain") {
|
|
146
|
+
await cmdChain(args);
|
|
147
|
+
}
|
|
148
|
+
else if (cmd === "whoami") {
|
|
149
|
+
await cmdWhoami(args);
|
|
150
|
+
}
|
|
151
|
+
else if (cmd === "thread") {
|
|
152
|
+
if (!args[0]) {
|
|
153
|
+
throw new CommandError("usage: thread <slug>");
|
|
154
|
+
}
|
|
155
|
+
await cmdThread(args[0], args.slice(1));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
console.error(ansi.dim(`unknown command "${cmd}" — type help`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (err instanceof DetachedError) {
|
|
163
|
+
console.log(`\n${ansi.yellow(err.message)}`);
|
|
164
|
+
}
|
|
165
|
+
else if (err instanceof CommandError) {
|
|
166
|
+
console.error(ansi.red(err.message));
|
|
167
|
+
}
|
|
168
|
+
else if (err instanceof Error && err.name === "AbortError") {
|
|
169
|
+
console.log(ansi.dim("\naborted"));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
console.error(ansi.red(err instanceof Error ? err.message : String(err)));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
busyAbort = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
process.off("SIGINT", onSigint);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const ansi: {
|
|
2
|
+
readonly enabled: boolean;
|
|
3
|
+
green: (t: string) => string;
|
|
4
|
+
bold: (t: string) => string;
|
|
5
|
+
dim: (t: string) => string;
|
|
6
|
+
red: (t: string) => string;
|
|
7
|
+
yellow: (t: string) => string;
|
|
8
|
+
gray: (t: string) => string;
|
|
9
|
+
brightGreen: (t: string) => string;
|
|
10
|
+
invert: (t: string) => string;
|
|
11
|
+
boldGreen: (t: string) => string;
|
|
12
|
+
};
|
package/dist/ui/ansi.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { stdout } from "node:process";
|
|
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
|
+
}
|
|
9
|
+
function wrap(code, text) {
|
|
10
|
+
if (!computeEnabled())
|
|
11
|
+
return text;
|
|
12
|
+
return `[${code}m${text}[0m`;
|
|
13
|
+
}
|
|
14
|
+
export const ansi = {
|
|
15
|
+
get enabled() {
|
|
16
|
+
return computeEnabled();
|
|
17
|
+
},
|
|
18
|
+
green: (t) => wrap("32", t),
|
|
19
|
+
bold: (t) => wrap("1", t),
|
|
20
|
+
dim: (t) => wrap("2", t),
|
|
21
|
+
red: (t) => wrap("31", t),
|
|
22
|
+
yellow: (t) => wrap("33", t),
|
|
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),
|
|
27
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { getMe } from "../api.js";
|
|
2
|
+
import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
|
|
3
|
+
import { cliVersion } from "../version.js";
|
|
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";
|
|
8
|
+
const WORDMARK = `
|
|
9
|
+
__ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
|
|
10
|
+
| \\/ | __| _ \\/ __| __/ __|_ _/ _ \\| _ \\ \\/ |
|
|
11
|
+
| |\\/| | _|| / (_ | _|\\__ \\ | || (_) | / |\\/| |
|
|
12
|
+
|_| |_|___|_|_\\\\___|___|___/ |_| \\___/|_|_\\_| |_|
|
|
13
|
+
`.trimEnd();
|
|
14
|
+
/** Used by the non-interactive `usage()` path in cli.ts — the boxed banner is shell-only. */
|
|
15
|
+
export function printWordmark() {
|
|
16
|
+
console.log(ansi.boldGreen(WORDMARK));
|
|
17
|
+
console.log(ansi.dim(` review local diffs fast · mergestorm.ai · v${cliVersion()}`));
|
|
18
|
+
console.log("");
|
|
19
|
+
}
|
|
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) {
|
|
32
|
+
if (!key) {
|
|
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
|
+
];
|
|
37
|
+
}
|
|
38
|
+
const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
|
|
39
|
+
if (!me) {
|
|
40
|
+
return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
|
|
41
|
+
}
|
|
42
|
+
const plan = me.plan_label_key ?? me.plan_key;
|
|
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)}`;
|
|
71
|
+
}
|
|
72
|
+
export async function printBannerHeader() {
|
|
73
|
+
const cfg = await loadConfig();
|
|
74
|
+
const key = resolveApiKey(cfg);
|
|
75
|
+
let me = null;
|
|
76
|
+
if (key) {
|
|
77
|
+
me = await getMe(cfg);
|
|
78
|
+
}
|
|
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
|
+
}
|
|
94
|
+
console.log("");
|
|
95
|
+
}
|
|
96
|
+
export function shellPrompt(loggedIn) {
|
|
97
|
+
return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
|
|
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>;
|