mergestorm 0.3.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 +31 -27
- package/dist/shell.js +15 -8
- package/dist/ui/banner.js +34 -15
- package/dist/ui/box.d.ts +23 -3
- package/dist/ui/box.js +68 -5
- package/dist/ui/ctrl-c-exit.d.ts +13 -0
- package/dist/ui/ctrl-c-exit.js +22 -0
- package/dist/ui/prompt.d.ts +1 -1
- package/dist/ui/prompt.js +21 -4
- package/dist/ui/usage.d.ts +3 -3
- package/dist/ui/usage.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,41 +20,45 @@ npm install -g mergestorm
|
|
|
20
20
|
mergestorm login
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
### From this monorepo (development)
|
|
23
|
+
### From this monorepo (development / local UI testing)
|
|
24
|
+
|
|
25
|
+
You do **not** need to bump/publish npm to try shell layout changes. From the repo root:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
28
|
+
# Fastest — runs TypeScript via tsx (no build, no version bump):
|
|
29
|
+
npm run dev --workspace=mergestorm
|
|
30
|
+
|
|
31
|
+
# Or build once, then run the compiled binary:
|
|
32
|
+
npm run build --workspace=mergestorm
|
|
33
|
+
node packages/mergestorm-cli/dist/cli.js
|
|
34
|
+
|
|
35
|
+
# Optional: expose a local binary as `mergestorm` without publishing
|
|
26
36
|
npm run build --workspace=mergestorm
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
npm
|
|
37
|
+
npm link --workspace=mergestorm
|
|
38
|
+
mergestorm # uses your local build
|
|
39
|
+
# later: npm unlink -g mergestorm && npm install -g mergestorm
|
|
30
40
|
```
|
|
31
41
|
|
|
32
42
|
## Interactive shell
|
|
33
43
|
|
|
34
|
-
On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a
|
|
44
|
+
On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a **full-width** welcome panel (logo + status on the left, tips / what's new on the right) and a **full-width** bordered input box with a live `/` slash-command dropdown. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
|
|
35
45
|
|
|
36
46
|
```
|
|
37
|
-
|
|
38
|
-
│
|
|
39
|
-
│
|
|
40
|
-
│
|
|
41
|
-
│
|
|
42
|
-
│
|
|
43
|
-
│
|
|
44
|
-
│
|
|
45
|
-
│
|
|
46
|
-
|
|
47
|
-
│ │
|
|
48
|
-
│ Tips for getting started: │
|
|
49
|
-
│ review review the diff for main...HEAD │
|
|
50
|
-
│ branches pick a recently reviewed branch │
|
|
51
|
-
│ /help list all commands │
|
|
52
|
-
╰─────────────────────────────────────────────────────────────╯
|
|
47
|
+
╭─ mergestorm v0.3.1 ──────────────────────────────────────────────────────────╮
|
|
48
|
+
│ ▟██████████▛ Tips for getting started │
|
|
49
|
+
│ ▜████████▛ review review main...HEAD │
|
|
50
|
+
│ ▝▜████▛▘ branches pick a reviewed branch │
|
|
51
|
+
│ ▜██▛ /help list all commands │
|
|
52
|
+
│ ██ │
|
|
53
|
+
│ ▝▘ What's new in v0.3.1 │
|
|
54
|
+
│ ● msk… · maelstrom • Full-width welcome + input │
|
|
55
|
+
│ [████░░░░░░░░░░] 12% used │
|
|
56
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
53
57
|
mergestorm
|
|
54
|
-
|
|
55
|
-
│ › /re
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
59
|
+
│ › /re │
|
|
60
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
61
|
+
/review Review git diff (default main...HEAD)
|
|
58
62
|
|
|
59
63
|
mergestorm> credits
|
|
60
64
|
mergestorm> branches
|
|
@@ -62,9 +66,9 @@ mergestorm> chain
|
|
|
62
66
|
mergestorm> exit
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
Bare command names still work exactly as before (`review`, `help`, `exit`, …) — typing `/` at the start of a line just opens the live autocomplete dropdown (filtered as you type, navigate with Up/Down, accept with Tab/Enter/Right). Arrow-key history, Ctrl+A/E, Ctrl+U/K,
|
|
69
|
+
Bare command names still work exactly as before (`review`, `help`, `exit`, …) — typing `/` at the start of a line just opens the live autocomplete dropdown (filtered as you type, navigate with Up/Down, accept with Tab/Enter/Right). Arrow-key history, Ctrl+A/E, Ctrl+U/K work as in a regular shell. At the idle prompt, **Ctrl+C twice** (within ~1.5s) exits the shell; a single Ctrl+C clears the line and shows a confirm hint. While `review` / `status` is waiting, Ctrl+C detaches or aborts that wait (job may keep running) — it does not exit the shell. Ctrl+D on an empty line, or `exit` / `quit`, also leave the shell. On a dumb terminal, without color, or when piped, the box falls back to plain ASCII borders (or no shell at all for non-TTY input).
|
|
66
70
|
|
|
67
|
-
`credits` / `usage` print a
|
|
71
|
+
`credits` / `usage` print a usage meter — long bar + `N% used`, then `Resets … (UTC)` — for standard and premium. `branches` (alias `chains`) lists recently reviewed branches; on a TTY you arrow-select one to open its review-chain timeline. `chain [slug]` shows that timeline directly (defaults to `local/<current-git-branch>`).
|
|
68
72
|
|
|
69
73
|
## Commands
|
|
70
74
|
|
package/dist/shell.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
|
+
import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ui/ctrl-c-exit.js";
|
|
14
15
|
import { askLine, PromptClosedError } from "./ui/prompt.js";
|
|
15
16
|
export const COMMANDS = [
|
|
16
17
|
{ name: "help", summary: "Show available commands" },
|
|
@@ -59,25 +60,31 @@ export async function runShell() {
|
|
|
59
60
|
catch { /* banner is best-effort */ }
|
|
60
61
|
const history = [];
|
|
61
62
|
let busyAbort = null;
|
|
62
|
-
|
|
63
|
+
const idleCtrlC = new CtrlCExitGate();
|
|
64
|
+
let exiting = false;
|
|
63
65
|
const onSigint = () => {
|
|
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.
|
|
64
68
|
if (busyAbort) {
|
|
65
69
|
busyAbort.abort();
|
|
66
70
|
return;
|
|
67
71
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
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()) {
|
|
73
75
|
output.write("\n");
|
|
76
|
+
exiting = true;
|
|
77
|
+
return;
|
|
74
78
|
}
|
|
79
|
+
output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
|
|
75
80
|
};
|
|
76
81
|
process.on("SIGINT", onSigint);
|
|
77
82
|
try {
|
|
78
83
|
while (true) {
|
|
79
84
|
const cfg = await loadConfig();
|
|
80
85
|
const loggedIn = Boolean(resolveApiKey(cfg));
|
|
86
|
+
if (exiting)
|
|
87
|
+
break;
|
|
81
88
|
let line;
|
|
82
89
|
try {
|
|
83
90
|
line = await askLine({
|
|
@@ -93,7 +100,7 @@ export async function runShell() {
|
|
|
93
100
|
}
|
|
94
101
|
throw err;
|
|
95
102
|
}
|
|
96
|
-
|
|
103
|
+
idleCtrlC.reset();
|
|
97
104
|
const parsed = parseLine(line);
|
|
98
105
|
if (!parsed)
|
|
99
106
|
continue;
|
|
@@ -155,7 +162,7 @@ export async function runShell() {
|
|
|
155
162
|
await cmdThread(args[0], args.slice(1));
|
|
156
163
|
}
|
|
157
164
|
else {
|
|
158
|
-
console.error(ansi.dim(`unknown command "${cmd}"
|
|
165
|
+
console.error(ansi.dim(`unknown command "${cmd}" -- type help`));
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
168
|
catch (err) {
|
package/dist/ui/banner.js
CHANGED
|
@@ -2,7 +2,7 @@ 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";
|
|
5
|
+
import { frameWidth, padVisible, roundedBox, sideBySide, visibleWidth, } from "./box.js";
|
|
6
6
|
import { TORNADO_LOGO } from "./logo.js";
|
|
7
7
|
import { usageBar } from "./usage.js";
|
|
8
8
|
const WORDMARK = `
|
|
@@ -24,14 +24,19 @@ const WHATS_NEW = {
|
|
|
24
24
|
"Live `/` slash-command autocomplete in the shell",
|
|
25
25
|
],
|
|
26
26
|
"0.3.0": [
|
|
27
|
-
"
|
|
27
|
+
"Usage meter (N% used) + credit reset time",
|
|
28
28
|
"`branches` / `chain` — explore branch review timelines",
|
|
29
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
|
+
],
|
|
30
35
|
};
|
|
31
|
-
function formatStatusLines(me, key) {
|
|
36
|
+
function formatStatusLines(me, key, barWidth) {
|
|
32
37
|
if (!key) {
|
|
33
38
|
return [
|
|
34
|
-
`${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in
|
|
39
|
+
`${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in`,
|
|
35
40
|
` or ${ansi.green("login --key")} to paste an API key`,
|
|
36
41
|
];
|
|
37
42
|
}
|
|
@@ -41,17 +46,16 @@ function formatStatusLines(me, key) {
|
|
|
41
46
|
}
|
|
42
47
|
const plan = me.plan_label_key ?? me.plan_key;
|
|
43
48
|
const s = me.usage.standard;
|
|
44
|
-
// Compact Claude meter in the welcome panel (shorter than the credits view).
|
|
45
49
|
return [
|
|
46
50
|
`${ansi.green("●")} ${ansi.bold(display)} · ${plan}`,
|
|
47
|
-
|
|
51
|
+
usageBar(s.used, s.limit, { width: barWidth }),
|
|
48
52
|
];
|
|
49
53
|
}
|
|
50
54
|
function tipsLines() {
|
|
51
55
|
return [
|
|
52
|
-
ansi.dim("Tips for getting started
|
|
53
|
-
` ${ansi.green("review")} review
|
|
54
|
-
` ${ansi.green("branches")} pick a
|
|
56
|
+
ansi.dim("Tips for getting started"),
|
|
57
|
+
` ${ansi.green("review")} review main...HEAD`,
|
|
58
|
+
` ${ansi.green("branches")} pick a reviewed branch`,
|
|
55
59
|
` ${ansi.green("/help")} list all commands`,
|
|
56
60
|
];
|
|
57
61
|
}
|
|
@@ -60,7 +64,7 @@ function whatsNewLines(version) {
|
|
|
60
64
|
if (!entries || !entries.length)
|
|
61
65
|
return [];
|
|
62
66
|
return [
|
|
63
|
-
ansi.dim(`What's new in v${version}
|
|
67
|
+
ansi.dim(`What's new in v${version}`),
|
|
64
68
|
...entries.map((entry) => ` ${ansi.green("•")} ${entry}`),
|
|
65
69
|
];
|
|
66
70
|
}
|
|
@@ -77,16 +81,31 @@ export async function printBannerHeader() {
|
|
|
77
81
|
me = await getMe(cfg);
|
|
78
82
|
}
|
|
79
83
|
const version = cliVersion();
|
|
80
|
-
const
|
|
81
|
-
|
|
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,
|
|
82
94
|
"",
|
|
95
|
+
...formatStatusLines(me, key, barWidth),
|
|
96
|
+
];
|
|
97
|
+
const rightCol = [
|
|
83
98
|
...tipsLines(),
|
|
99
|
+
"",
|
|
84
100
|
...whatsNewLines(version),
|
|
85
101
|
];
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
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, {
|
|
89
106
|
title: `mergestorm v${version}`,
|
|
107
|
+
width: outer,
|
|
108
|
+
padding: 1,
|
|
90
109
|
});
|
|
91
110
|
for (const line of panel) {
|
|
92
111
|
console.log(line);
|
package/dist/ui/box.d.ts
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
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;
|
|
2
9
|
export interface RoundedBoxOptions {
|
|
3
10
|
title?: string;
|
|
4
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;
|
|
5
19
|
}
|
|
6
20
|
/**
|
|
7
|
-
* Frames `lines` in a box sized to
|
|
8
|
-
*
|
|
9
|
-
* box-drawing glyphs can render incorrectly on such terminals.
|
|
21
|
+
* Frames `lines` in a box. By default sized to content; pass `width` /
|
|
22
|
+
* `minWidth` to stretch to the terminal like Claude Code.
|
|
10
23
|
*/
|
|
11
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
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import { stdout } from "node:process";
|
|
1
2
|
import { ansi } from "./ansi.js";
|
|
2
|
-
const ANSI_PATTERN =
|
|
3
|
+
const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
3
4
|
export function visibleWidth(str) {
|
|
4
5
|
return str.replace(ANSI_PATTERN, "").length;
|
|
5
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
|
+
}
|
|
6
22
|
const ROUNDED = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };
|
|
7
23
|
const ASCII = { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" };
|
|
8
24
|
function boxChars() {
|
|
@@ -15,15 +31,22 @@ function centerLabel(label, width, fill) {
|
|
|
15
31
|
return `${fill.repeat(left)}${label}${fill.repeat(right)}`;
|
|
16
32
|
}
|
|
17
33
|
/**
|
|
18
|
-
* Frames `lines` in a box sized to
|
|
19
|
-
*
|
|
20
|
-
* box-drawing glyphs can render incorrectly on such terminals.
|
|
34
|
+
* Frames `lines` in a box. By default sized to content; pass `width` /
|
|
35
|
+
* `minWidth` to stretch to the terminal like Claude Code.
|
|
21
36
|
*/
|
|
22
37
|
export function roundedBox(lines, opts = {}) {
|
|
23
38
|
const padding = Math.max(0, opts.padding ?? 1);
|
|
24
39
|
const chars = boxChars();
|
|
25
40
|
const title = opts.title ? ` ${opts.title} ` : "";
|
|
26
|
-
const
|
|
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
|
+
}
|
|
27
50
|
const top = ansi.gray(title
|
|
28
51
|
? `${chars.tl}${centerLabel(title, innerWidth, chars.h)}${chars.tr}`
|
|
29
52
|
: `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`);
|
|
@@ -34,3 +57,43 @@ export function roundedBox(lines, opts = {}) {
|
|
|
34
57
|
});
|
|
35
58
|
return [top, ...body, bottom];
|
|
36
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/prompt.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface AskLineOptions {
|
|
|
7
7
|
history?: string[];
|
|
8
8
|
commands?: CommandSpec[];
|
|
9
9
|
}
|
|
10
|
-
/** Thrown when the user asks to close the prompt (Ctrl+D
|
|
10
|
+
/** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
|
|
11
11
|
export declare class PromptClosedError extends Error {
|
|
12
12
|
constructor();
|
|
13
13
|
}
|
package/dist/ui/prompt.js
CHANGED
|
@@ -2,8 +2,9 @@ import * as readline from "node:readline";
|
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
4
|
import { ansi } from "./ansi.js";
|
|
5
|
-
import { roundedBox, visibleWidth } from "./box.js";
|
|
6
|
-
|
|
5
|
+
import { frameWidth, roundedBox, visibleWidth } from "./box.js";
|
|
6
|
+
import { CTRL_C_EXIT_HINT, CtrlCExitGate } from "./ctrl-c-exit.js";
|
|
7
|
+
/** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
|
|
7
8
|
export class PromptClosedError extends Error {
|
|
8
9
|
constructor() {
|
|
9
10
|
super("prompt closed");
|
|
@@ -40,6 +41,7 @@ export async function askLine(opts) {
|
|
|
40
41
|
let dropdownIndex = 0;
|
|
41
42
|
let linesDrawn = 0;
|
|
42
43
|
let finished = false;
|
|
44
|
+
const ctrlCExit = new CtrlCExitGate();
|
|
43
45
|
const wasRaw = Boolean(input.isRaw);
|
|
44
46
|
readline.emitKeypressEvents(input);
|
|
45
47
|
input.setRawMode(true);
|
|
@@ -59,13 +61,17 @@ export async function askLine(opts) {
|
|
|
59
61
|
const rows = [];
|
|
60
62
|
if (opts.prompt)
|
|
61
63
|
rows.push(ansi.dim(opts.prompt));
|
|
62
|
-
|
|
64
|
+
// Full terminal width input frame, not content-sized.
|
|
65
|
+
rows.push(...roundedBox([`${ansi.green("›")} ${buffer}`], {
|
|
66
|
+
padding: 1,
|
|
67
|
+
width: frameWidth(),
|
|
68
|
+
}));
|
|
63
69
|
const dropdown = currentDropdown();
|
|
64
70
|
if (dropdown.length) {
|
|
65
71
|
if (dropdownIndex >= dropdown.length)
|
|
66
72
|
dropdownIndex = dropdown.length - 1;
|
|
67
73
|
dropdown.forEach((c, i) => {
|
|
68
|
-
const label = `
|
|
74
|
+
const label = ` /${c.name.padEnd(12)}${c.summary}`;
|
|
69
75
|
rows.push(i === dropdownIndex ? ansi.invert(label) : ansi.gray(label));
|
|
70
76
|
});
|
|
71
77
|
}
|
|
@@ -108,11 +114,22 @@ export async function askLine(opts) {
|
|
|
108
114
|
try {
|
|
109
115
|
if (finished || !key)
|
|
110
116
|
return;
|
|
117
|
+
if (!(key.ctrl && key.name === "c"))
|
|
118
|
+
ctrlCExit.reset();
|
|
111
119
|
const dropdown = currentDropdown();
|
|
112
120
|
if (key.ctrl && key.name === "c") {
|
|
121
|
+
if (ctrlCExit.press()) {
|
|
122
|
+
finish(null, new PromptClosedError());
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
113
125
|
buffer = "";
|
|
114
126
|
cursor = 0;
|
|
115
127
|
historyIndex = history.length;
|
|
128
|
+
const rowsBelow = Math.max(0, linesDrawn - inputRow - 1);
|
|
129
|
+
if (rowsBelow > 0)
|
|
130
|
+
output.write(down(rowsBelow));
|
|
131
|
+
output.write(`\n${ansi.dim(CTRL_C_EXIT_HINT)}\n`);
|
|
132
|
+
linesDrawn = 0;
|
|
116
133
|
render();
|
|
117
134
|
return;
|
|
118
135
|
}
|
package/dist/ui/usage.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export type UsageBarOpts = {
|
|
2
|
-
/** Bar width in cells (default 56
|
|
2
|
+
/** Bar width in cells (default 56). */
|
|
3
3
|
width?: number;
|
|
4
4
|
/** When false, return plain text with no ANSI (default: respect ansi.enabled). */
|
|
5
5
|
color?: boolean;
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Usage meter — long thin bar + percent only:
|
|
9
9
|
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
10
10
|
*
|
|
11
11
|
* No used/limit clutter. Edge cases:
|
|
@@ -16,7 +16,7 @@ export declare function usageBar(used: number, limit: number | null | undefined,
|
|
|
16
16
|
/** Start of the next UTC calendar month (matches API `resets_at`). */
|
|
17
17
|
export declare function nextUtcMonthStart(now?: Date): string;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Reset footer: `Resets Aug 1, 12:00am (UTC)`.
|
|
20
20
|
* Falls back to next UTC month start when `iso` is missing/invalid.
|
|
21
21
|
*/
|
|
22
22
|
export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
|
package/dist/ui/usage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ansi } from "./ansi.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Usage meter — long thin bar + percent only:
|
|
4
4
|
* `[████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 7% used`
|
|
5
5
|
*
|
|
6
6
|
* No used/limit clutter. Edge cases:
|
|
@@ -21,7 +21,7 @@ export function usageBar(used, limit, opts = {}) {
|
|
|
21
21
|
const filled = Math.round(ratio * width);
|
|
22
22
|
const empty = Math.max(0, width - filled);
|
|
23
23
|
const pct = Math.min(100, Math.round((safeUsed / limit) * 100));
|
|
24
|
-
// Filled segment colored; empty + label stay dim
|
|
24
|
+
// Filled segment colored; empty + label stay dim.
|
|
25
25
|
const filledSeg = "█".repeat(filled);
|
|
26
26
|
const emptySeg = "░".repeat(empty);
|
|
27
27
|
const suffix = ` ${pct}% used`;
|
|
@@ -40,7 +40,7 @@ export function nextUtcMonthStart(now = new Date()) {
|
|
|
40
40
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString();
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Reset footer: `Resets Aug 1, 12:00am (UTC)`.
|
|
44
44
|
* Falls back to next UTC month start when `iso` is missing/invalid.
|
|
45
45
|
*/
|
|
46
46
|
export function formatResetLabel(iso, now = new Date()) {
|