mergestorm 0.2.0 → 0.3.1
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 +43 -10
- 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 +65 -62
- 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 +84 -14
- package/dist/ui/box.d.ts +31 -0
- package/dist/ui/box.js +99 -0
- package/dist/ui/ctrl-c-exit.d.ts +13 -0
- package/dist/ui/ctrl-c-exit.js +22 -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 +237 -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/shell.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
1
|
+
import { stdout as output } from "node:process";
|
|
3
2
|
import { loadConfig, resolveApiKey } from "./config.js";
|
|
4
3
|
import { CommandError, DetachedError } from "./errors.js";
|
|
4
|
+
import { cmdBranches, cmdChain } from "./commands/branches.js";
|
|
5
5
|
import { cmdCredits } from "./commands/credits.js";
|
|
6
6
|
import { cmdJobs, cmdThread } from "./commands/jobs.js";
|
|
7
7
|
import { cmdLogin } from "./commands/login.js";
|
|
@@ -11,45 +11,42 @@ import { cmdStatus } from "./commands/status.js";
|
|
|
11
11
|
import { cmdWhoami } from "./commands/whoami.js";
|
|
12
12
|
import { ansi } from "./ui/ansi.js";
|
|
13
13
|
import { printBannerHeader, shellPrompt } from "./ui/banner.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
14
|
+
import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ui/ctrl-c-exit.js";
|
|
15
|
+
import { askLine, PromptClosedError } from "./ui/prompt.js";
|
|
16
|
+
export const COMMANDS = [
|
|
17
|
+
{ name: "help", summary: "Show available commands" },
|
|
18
|
+
{ name: "login", summary: "Sign in via browser (or paste a key)" },
|
|
19
|
+
{ name: "logout", summary: "Remove the stored API key" },
|
|
20
|
+
{ name: "review", summary: "Review git diff (default main...HEAD)" },
|
|
21
|
+
{ name: "status", summary: "Show a review job" },
|
|
22
|
+
{ name: "credits", summary: "Credit balance with usage bars" },
|
|
23
|
+
{ name: "usage", summary: "Credit balance with usage bars" },
|
|
24
|
+
{ name: "jobs", summary: "Recent review jobs (default 10)" },
|
|
25
|
+
{ name: "branches", summary: "Pick a recently reviewed branch" },
|
|
26
|
+
{ name: "chains", summary: "Alias for branches" },
|
|
27
|
+
{ name: "chain", summary: "Show a branch review-chain timeline" },
|
|
28
|
+
{ name: "whoami", summary: "Key + plan + API base" },
|
|
29
|
+
{ name: "thread", summary: "Jobs in a review thread" },
|
|
30
|
+
{ name: "clear", summary: "Clear screen and reprint banner" },
|
|
31
|
+
{ name: "exit", summary: "Leave the shell" },
|
|
32
|
+
{ name: "quit", summary: "Leave the shell" },
|
|
28
33
|
];
|
|
29
|
-
function completer(line) {
|
|
30
|
-
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
31
|
-
return [hits.length ? [...hits] : [...COMMANDS], line];
|
|
32
|
-
}
|
|
33
34
|
function printHelp() {
|
|
35
|
+
const rows = COMMANDS.map((c) => ` ${ansi.green("/" + c.name.padEnd(10))} ${c.summary}`).join("\n");
|
|
34
36
|
console.log(`
|
|
35
37
|
${ansi.bold("Commands")}
|
|
36
|
-
|
|
37
|
-
logout Remove the stored API key
|
|
38
|
-
review [base] [head] Review git diff (default main...HEAD)
|
|
39
|
-
status <job_id> Show a review job
|
|
40
|
-
credits / usage Credit balance
|
|
41
|
-
jobs [n] Recent review jobs (default 10)
|
|
42
|
-
whoami Key + plan + API base
|
|
43
|
-
thread <slug> Jobs in a review thread
|
|
44
|
-
clear Clear screen and reprint banner
|
|
45
|
-
help This help
|
|
46
|
-
exit / quit Leave the shell
|
|
38
|
+
${rows}
|
|
47
39
|
|
|
48
40
|
Dashboard: https://mergestorm.ai/dashboard/api
|
|
49
41
|
`);
|
|
50
42
|
}
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
/** Strips an optional leading "/" so both "review" and "/review" work. */
|
|
44
|
+
export function parseLine(line) {
|
|
45
|
+
let trimmed = line.trim();
|
|
46
|
+
if (!trimmed)
|
|
47
|
+
return null;
|
|
48
|
+
if (trimmed.startsWith("/"))
|
|
49
|
+
trimmed = trimmed.slice(1).trim();
|
|
53
50
|
if (!trimmed)
|
|
54
51
|
return null;
|
|
55
52
|
const parts = trimmed.split(/\s+/);
|
|
@@ -61,53 +58,54 @@ export async function runShell() {
|
|
|
61
58
|
await printBannerHeader();
|
|
62
59
|
}
|
|
63
60
|
catch { /* banner is best-effort */ }
|
|
64
|
-
const
|
|
65
|
-
input,
|
|
66
|
-
output,
|
|
67
|
-
completer,
|
|
68
|
-
historySize: 200,
|
|
69
|
-
terminal: Boolean(output.isTTY),
|
|
70
|
-
});
|
|
61
|
+
const history = [];
|
|
71
62
|
let busyAbort = null;
|
|
72
|
-
|
|
73
|
-
let
|
|
63
|
+
const idleCtrlC = new CtrlCExitGate();
|
|
64
|
+
let exiting = false;
|
|
74
65
|
const onSigint = () => {
|
|
75
|
-
|
|
66
|
+
// Busy commands: first Ctrl+C aborts/detaches only -- never exit the shell.
|
|
67
|
+
// Exit confirm is for the idle prompt (see askLine); do not arm idleCtrlC here.
|
|
76
68
|
if (busyAbort) {
|
|
77
69
|
busyAbort.abort();
|
|
78
70
|
return;
|
|
79
71
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
72
|
+
// Rare path: SIGINT outside raw-mode prompt (e.g. between turns).
|
|
73
|
+
// Idle TTY Ctrl+C is handled in askLine as a keypress.
|
|
74
|
+
if (idleCtrlC.press()) {
|
|
85
75
|
output.write("\n");
|
|
76
|
+
exiting = true;
|
|
77
|
+
return;
|
|
86
78
|
}
|
|
79
|
+
output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
|
|
87
80
|
};
|
|
88
81
|
process.on("SIGINT", onSigint);
|
|
89
82
|
try {
|
|
90
83
|
while (true) {
|
|
91
|
-
sigintDuringPrompt = false;
|
|
92
84
|
const cfg = await loadConfig();
|
|
93
85
|
const loggedIn = Boolean(resolveApiKey(cfg));
|
|
86
|
+
if (exiting)
|
|
87
|
+
break;
|
|
94
88
|
let line;
|
|
95
89
|
try {
|
|
96
|
-
line = await
|
|
90
|
+
line = await askLine({
|
|
91
|
+
prompt: shellPrompt(loggedIn),
|
|
92
|
+
history,
|
|
93
|
+
commands: COMMANDS,
|
|
94
|
+
});
|
|
97
95
|
}
|
|
98
|
-
catch {
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof PromptClosedError) {
|
|
98
|
+
console.log("");
|
|
99
|
+
break;
|
|
102
100
|
}
|
|
103
|
-
|
|
104
|
-
console.log("");
|
|
105
|
-
break;
|
|
101
|
+
throw err;
|
|
106
102
|
}
|
|
107
|
-
|
|
103
|
+
idleCtrlC.reset();
|
|
108
104
|
const parsed = parseLine(line);
|
|
109
105
|
if (!parsed)
|
|
110
106
|
continue;
|
|
107
|
+
if (line.trim())
|
|
108
|
+
history.push(line);
|
|
111
109
|
const { cmd, args } = parsed;
|
|
112
110
|
if (cmd === "exit" || cmd === "quit")
|
|
113
111
|
break;
|
|
@@ -116,7 +114,7 @@ export async function runShell() {
|
|
|
116
114
|
continue;
|
|
117
115
|
}
|
|
118
116
|
if (cmd === "clear") {
|
|
119
|
-
output.write("
|
|
117
|
+
output.write("[2J[H");
|
|
120
118
|
try {
|
|
121
119
|
await printBannerHeader();
|
|
122
120
|
}
|
|
@@ -126,7 +124,7 @@ export async function runShell() {
|
|
|
126
124
|
busyAbort = new AbortController();
|
|
127
125
|
try {
|
|
128
126
|
if (cmd === "login") {
|
|
129
|
-
await cmdLogin(args
|
|
127
|
+
await cmdLogin(args);
|
|
130
128
|
await printBannerHeader();
|
|
131
129
|
}
|
|
132
130
|
else if (cmd === "logout") {
|
|
@@ -148,6 +146,12 @@ export async function runShell() {
|
|
|
148
146
|
else if (cmd === "jobs") {
|
|
149
147
|
await cmdJobs(args);
|
|
150
148
|
}
|
|
149
|
+
else if (cmd === "branches" || cmd === "chains") {
|
|
150
|
+
await cmdBranches(args);
|
|
151
|
+
}
|
|
152
|
+
else if (cmd === "chain") {
|
|
153
|
+
await cmdChain(args);
|
|
154
|
+
}
|
|
151
155
|
else if (cmd === "whoami") {
|
|
152
156
|
await cmdWhoami(args);
|
|
153
157
|
}
|
|
@@ -158,7 +162,7 @@ export async function runShell() {
|
|
|
158
162
|
await cmdThread(args[0], args.slice(1));
|
|
159
163
|
}
|
|
160
164
|
else {
|
|
161
|
-
console.error(ansi.dim(`unknown command "${cmd}"
|
|
165
|
+
console.error(ansi.dim(`unknown command "${cmd}" -- type help`));
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
catch (err) {
|
|
@@ -182,6 +186,5 @@ export async function runShell() {
|
|
|
182
186
|
}
|
|
183
187
|
finally {
|
|
184
188
|
process.off("SIGINT", onSigint);
|
|
185
|
-
rl.close();
|
|
186
189
|
}
|
|
187
190
|
}
|
package/dist/ui/ansi.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export declare const ansi: {
|
|
2
|
-
enabled: boolean;
|
|
2
|
+
readonly enabled: boolean;
|
|
3
3
|
green: (t: string) => string;
|
|
4
4
|
bold: (t: string) => string;
|
|
5
5
|
dim: (t: string) => string;
|
|
6
6
|
red: (t: string) => string;
|
|
7
7
|
yellow: (t: string) => string;
|
|
8
|
+
gray: (t: string) => string;
|
|
9
|
+
brightGreen: (t: string) => string;
|
|
10
|
+
invert: (t: string) => string;
|
|
8
11
|
boldGreen: (t: string) => string;
|
|
9
12
|
};
|
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,116 @@ 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 { frameWidth, padVisible, roundedBox, sideBySide, 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
|
+
"Usage meter (N% used) + credit reset time",
|
|
28
|
+
"`branches` / `chain` — explore branch review timelines",
|
|
29
|
+
],
|
|
30
|
+
"0.3.1": [
|
|
31
|
+
"Full-width welcome panel + input (matches terminal columns)",
|
|
32
|
+
"Two-column layout: status left, tips/what's new right",
|
|
33
|
+
"Ctrl+C twice at the prompt to exit",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
function formatStatusLines(me, key, barWidth) {
|
|
17
37
|
if (!key) {
|
|
18
|
-
return
|
|
19
|
-
|
|
38
|
+
return [
|
|
39
|
+
`${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in`,
|
|
40
|
+
` or ${ansi.green("login --key")} to paste an API key`,
|
|
41
|
+
];
|
|
20
42
|
}
|
|
21
43
|
const display = me?.key.prefix ?? keyDisplay(key) ?? "msk_live_…";
|
|
22
44
|
if (!me) {
|
|
23
|
-
return
|
|
45
|
+
return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
|
|
24
46
|
}
|
|
25
47
|
const plan = me.plan_label_key ?? me.plan_key;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
48
|
+
const s = me.usage.standard;
|
|
49
|
+
return [
|
|
50
|
+
`${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
|
|
51
|
+
usageBar(s.used, s.limit, { width: barWidth }),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
function tipsLines() {
|
|
55
|
+
return [
|
|
56
|
+
ansi.dim("Tips for getting started"),
|
|
57
|
+
` ${ansi.green("review")} review main...HEAD`,
|
|
58
|
+
` ${ansi.green("branches")} pick a reviewed branch`,
|
|
59
|
+
` ${ansi.green("/help")} list all commands`,
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function whatsNewLines(version) {
|
|
63
|
+
const entries = WHATS_NEW[version];
|
|
64
|
+
if (!entries || !entries.length)
|
|
65
|
+
return [];
|
|
66
|
+
return [
|
|
67
|
+
ansi.dim(`What's new in v${version}`),
|
|
68
|
+
...entries.map((entry) => ` ${ansi.green("•")} ${entry}`),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
function centerLine(line, width) {
|
|
72
|
+
const room = Math.max(0, width - visibleWidth(line));
|
|
73
|
+
const left = Math.floor(room / 2);
|
|
74
|
+
return `${" ".repeat(left)}${line}${" ".repeat(room - left)}`;
|
|
30
75
|
}
|
|
31
76
|
export async function printBannerHeader() {
|
|
32
|
-
printWordmark();
|
|
33
77
|
const cfg = await loadConfig();
|
|
34
78
|
const key = resolveApiKey(cfg);
|
|
35
79
|
let me = null;
|
|
36
80
|
if (key) {
|
|
37
81
|
me = await getMe(cfg);
|
|
38
82
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
83
|
+
const version = cliVersion();
|
|
84
|
+
const outer = frameWidth();
|
|
85
|
+
// Inner content width inside the box (borders + padding).
|
|
86
|
+
const inner = Math.max(40, outer - 4);
|
|
87
|
+
// Two columns: left = logo + status, right = tips + what's new.
|
|
88
|
+
const leftColW = Math.max(28, Math.floor(inner * 0.42));
|
|
89
|
+
const rightColW = Math.max(24, inner - leftColW - 2);
|
|
90
|
+
const barWidth = Math.max(12, leftColW - 4);
|
|
91
|
+
const logoLines = TORNADO_LOGO.map((line) => centerLine(ansi.brightGreen(line), leftColW));
|
|
92
|
+
const leftCol = [
|
|
93
|
+
...logoLines,
|
|
94
|
+
"",
|
|
95
|
+
...formatStatusLines(me, key, barWidth),
|
|
96
|
+
];
|
|
97
|
+
const rightCol = [
|
|
98
|
+
...tipsLines(),
|
|
99
|
+
"",
|
|
100
|
+
...whatsNewLines(version),
|
|
101
|
+
];
|
|
102
|
+
// Clip right column lines that overflow the right pane.
|
|
103
|
+
const rightClipped = rightCol.map((line) => visibleWidth(line) <= rightColW ? line : padVisible(line, rightColW));
|
|
104
|
+
const rows = sideBySide(leftCol, rightClipped, leftColW, 2);
|
|
105
|
+
const panel = roundedBox(rows, {
|
|
106
|
+
title: `mergestorm v${version}`,
|
|
107
|
+
width: outer,
|
|
108
|
+
padding: 1,
|
|
109
|
+
});
|
|
110
|
+
for (const line of panel) {
|
|
111
|
+
console.log(line);
|
|
112
|
+
}
|
|
42
113
|
console.log("");
|
|
43
114
|
}
|
|
44
115
|
export function shellPrompt(loggedIn) {
|
|
45
|
-
|
|
46
|
-
return `${name}> `;
|
|
116
|
+
return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
|
|
47
117
|
}
|
package/dist/ui/box.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare function visibleWidth(str: string): number;
|
|
2
|
+
/** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
|
|
3
|
+
export declare function terminalColumns(fallback?: number): number;
|
|
4
|
+
/**
|
|
5
|
+
* Outer box width for Claude-style full-bleed frames.
|
|
6
|
+
* Leaves 1 col margin so the right border doesn't wrap.
|
|
7
|
+
*/
|
|
8
|
+
export declare function frameWidth(fallback?: number): number;
|
|
9
|
+
export interface RoundedBoxOptions {
|
|
10
|
+
title?: string;
|
|
11
|
+
padding?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Exact outer width (including borders). When set, content is padded to fill.
|
|
14
|
+
* Prefer this for full-width Claude-style frames.
|
|
15
|
+
*/
|
|
16
|
+
width?: number;
|
|
17
|
+
/** Grow to at least this outer width (content may still be wider). */
|
|
18
|
+
minWidth?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Frames `lines` in a box. By default sized to content; pass `width` /
|
|
22
|
+
* `minWidth` to stretch to the terminal like Claude Code.
|
|
23
|
+
*/
|
|
24
|
+
export declare function roundedBox(lines: string[], opts?: RoundedBoxOptions): string[];
|
|
25
|
+
/** Pad/truncate a line to an exact visible width (ANSI-safe). */
|
|
26
|
+
export declare function padVisible(line: string, width: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* Join left/right columns into rows of equal left-column width.
|
|
29
|
+
* Extra rows on either side are blank-padded.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sideBySide(left: string[], right: string[], leftWidth: number, gap?: number): string[];
|
package/dist/ui/box.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { stdout } from "node:process";
|
|
2
|
+
import { ansi } from "./ansi.js";
|
|
3
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
4
|
+
export function visibleWidth(str) {
|
|
5
|
+
return str.replace(ANSI_PATTERN, "").length;
|
|
6
|
+
}
|
|
7
|
+
/** Usable terminal columns (TTY), with a sensible fallback for pipes/tests. */
|
|
8
|
+
export function terminalColumns(fallback = 80) {
|
|
9
|
+
const cols = stdout.columns;
|
|
10
|
+
if (typeof cols === "number" && Number.isFinite(cols) && cols >= 40) {
|
|
11
|
+
return cols;
|
|
12
|
+
}
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Outer box width for Claude-style full-bleed frames.
|
|
17
|
+
* Leaves 1 col margin so the right border doesn't wrap.
|
|
18
|
+
*/
|
|
19
|
+
export function frameWidth(fallback = 80) {
|
|
20
|
+
return Math.max(48, terminalColumns(fallback) - 1);
|
|
21
|
+
}
|
|
22
|
+
const ROUNDED = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
23
|
+
const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
24
|
+
function boxChars() {
|
|
25
|
+
return !ansi.enabled || process.env.TERM === "dumb" ? ASCII : ROUNDED;
|
|
26
|
+
}
|
|
27
|
+
function centerLabel(label, width, fill) {
|
|
28
|
+
const room = Math.max(0, width - visibleWidth(label));
|
|
29
|
+
const left = Math.floor(room / 2);
|
|
30
|
+
const right = room - left;
|
|
31
|
+
return `${fill.repeat(left)}${label}${fill.repeat(right)}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Frames `lines` in a box. By default sized to content; pass `width` /
|
|
35
|
+
* `minWidth` to stretch to the terminal like Claude Code.
|
|
36
|
+
*/
|
|
37
|
+
export function roundedBox(lines, opts = {}) {
|
|
38
|
+
const padding = Math.max(0, opts.padding ?? 1);
|
|
39
|
+
const chars = boxChars();
|
|
40
|
+
const title = opts.title ? ` ${opts.title} ` : "";
|
|
41
|
+
const contentInner = Math.max(0, ...lines.map(visibleWidth), visibleWidth(title)) + padding * 2;
|
|
42
|
+
let innerWidth = contentInner;
|
|
43
|
+
if (opts.width !== undefined) {
|
|
44
|
+
// outer = inner + 2 border cols
|
|
45
|
+
innerWidth = Math.max(contentInner, opts.width - 2);
|
|
46
|
+
}
|
|
47
|
+
else if (opts.minWidth !== undefined) {
|
|
48
|
+
innerWidth = Math.max(contentInner, opts.minWidth - 2);
|
|
49
|
+
}
|
|
50
|
+
const top = ansi.gray(title
|
|
51
|
+
? `${chars.tl}${centerLabel(title, innerWidth, chars.h)}${chars.tr}`
|
|
52
|
+
: `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`);
|
|
53
|
+
const bottom = ansi.gray(`${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`);
|
|
54
|
+
const body = lines.map((line) => {
|
|
55
|
+
const gap = Math.max(0, innerWidth - padding - visibleWidth(line));
|
|
56
|
+
return `${ansi.gray(chars.v)}${" ".repeat(padding)}${line}${" ".repeat(gap)}${ansi.gray(chars.v)}`;
|
|
57
|
+
});
|
|
58
|
+
return [top, ...body, bottom];
|
|
59
|
+
}
|
|
60
|
+
/** Pad/truncate a line to an exact visible width (ANSI-safe). */
|
|
61
|
+
export function padVisible(line, width) {
|
|
62
|
+
const w = visibleWidth(line);
|
|
63
|
+
if (w === width)
|
|
64
|
+
return line;
|
|
65
|
+
if (w < width)
|
|
66
|
+
return `${line}${" ".repeat(width - w)}`;
|
|
67
|
+
// Truncate while preserving ANSI codes: walk the string counting only
|
|
68
|
+
// visible characters, then slice the original at that position.
|
|
69
|
+
let visible = 0;
|
|
70
|
+
let i = 0;
|
|
71
|
+
const re = /\u001b\[[0-9;]*m/g;
|
|
72
|
+
while (visible < width && i < line.length) {
|
|
73
|
+
re.lastIndex = i;
|
|
74
|
+
const m = re.exec(line);
|
|
75
|
+
if (m && m.index === i) {
|
|
76
|
+
i = re.lastIndex;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const code = line.charCodeAt(i);
|
|
80
|
+
i += code >= 0xd800 && code <= 0xdbff ? 2 : 1;
|
|
81
|
+
visible++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return line.slice(0, i);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Join left/right columns into rows of equal left-column width.
|
|
88
|
+
* Extra rows on either side are blank-padded.
|
|
89
|
+
*/
|
|
90
|
+
export function sideBySide(left, right, leftWidth, gap = 2) {
|
|
91
|
+
const rows = Math.max(left.length, right.length);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (let i = 0; i < rows; i++) {
|
|
94
|
+
const L = padVisible(left[i] ?? "", leftWidth);
|
|
95
|
+
const R = right[i] ?? "";
|
|
96
|
+
out.push(`${L}${" ".repeat(gap)}${R}`);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Window (ms) for a second Ctrl+C to confirm shell exit. */
|
|
2
|
+
export declare const CTRL_C_CONFIRM_MS = 1500;
|
|
3
|
+
/**
|
|
4
|
+
* Tracks consecutive Ctrl+C presses. First press arms a confirm window;
|
|
5
|
+
* a second press within {@link CTRL_C_CONFIRM_MS} means exit.
|
|
6
|
+
*/
|
|
7
|
+
export declare class CtrlCExitGate {
|
|
8
|
+
private lastAt;
|
|
9
|
+
/** @returns true when the caller should exit the shell */
|
|
10
|
+
press(now?: number): boolean;
|
|
11
|
+
reset(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare const CTRL_C_EXIT_HINT = "Press Ctrl+C again to exit";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Window (ms) for a second Ctrl+C to confirm shell exit. */
|
|
2
|
+
export const CTRL_C_CONFIRM_MS = 1500;
|
|
3
|
+
/**
|
|
4
|
+
* Tracks consecutive Ctrl+C presses. First press arms a confirm window;
|
|
5
|
+
* a second press within {@link CTRL_C_CONFIRM_MS} means exit.
|
|
6
|
+
*/
|
|
7
|
+
export class CtrlCExitGate {
|
|
8
|
+
lastAt = 0;
|
|
9
|
+
/** @returns true when the caller should exit the shell */
|
|
10
|
+
press(now = Date.now()) {
|
|
11
|
+
if (this.lastAt > 0 && now - this.lastAt <= CTRL_C_CONFIRM_MS) {
|
|
12
|
+
this.lastAt = 0;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
this.lastAt = now;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
reset() {
|
|
19
|
+
this.lastAt = 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const CTRL_C_EXIT_HINT = "Press Ctrl+C again to exit";
|
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 empty, or Ctrl+C twice). */
|
|
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>;
|