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/dist/shell.js CHANGED
@@ -1,7 +1,7 @@
1
- import { createInterface } from "node:readline/promises";
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
- const COMMANDS = [
15
- "help",
16
- "login",
17
- "logout",
18
- "review",
19
- "status",
20
- "credits",
21
- "usage",
22
- "jobs",
23
- "whoami",
24
- "thread",
25
- "clear",
26
- "exit",
27
- "quit",
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
- login [--key] Sign in via browser (or paste a key)
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
- function parseLine(line) {
52
- const trimmed = line.trim();
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 rl = createInterface({
65
- input,
66
- output,
67
- completer,
68
- historySize: 200,
69
- terminal: Boolean(output.isTTY),
70
- });
61
+ const history = [];
71
62
  let busyAbort = null;
72
- let sigintHint = false;
73
- let sigintDuringPrompt = false;
63
+ const idleCtrlC = new CtrlCExitGate();
64
+ let exiting = false;
74
65
  const onSigint = () => {
75
- sigintDuringPrompt = true;
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
- if (!sigintHint) {
81
- output.write(`\n${ansi.dim("(type exit or press ctrl+d to quit)")}\n`);
82
- sigintHint = true;
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 rl.question(shellPrompt(loggedIn));
90
+ line = await askLine({
91
+ prompt: shellPrompt(loggedIn),
92
+ history,
93
+ commands: COMMANDS,
94
+ });
97
95
  }
98
- catch {
99
- if (sigintDuringPrompt) {
100
- sigintDuringPrompt = false;
101
- continue;
96
+ catch (err) {
97
+ if (err instanceof PromptClosedError) {
98
+ console.log("");
99
+ break;
102
100
  }
103
- // Ctrl+D / closed
104
- console.log("");
105
- break;
101
+ throw err;
106
102
  }
107
- sigintHint = false;
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("\u001b[2J\u001b[H");
117
+ output.write("");
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, rl);
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}" — type help`));
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
- const enabled = process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0"
3
- ? true
4
- : Boolean(stdout.isTTY) &&
5
- !process.env.NO_COLOR &&
6
- process.env.TERM !== "dumb";
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 (!enabled)
10
+ if (!computeEnabled())
9
11
  return text;
10
- return `\u001b[${code}m${text}\u001b[0m`;
12
+ return `[${code}m${text}`;
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
- boldGreen: (t) => (enabled ? `\u001b[1;32m${t}\u001b[0m` : t),
23
+ gray: (t) => wrap("90", t),
24
+ brightGreen: (t) => wrap("92", t),
25
+ invert: (t) => wrap("7", t),
26
+ boldGreen: (t) => (computeEnabled() ? `${t}` : t),
20
27
  };
@@ -1,3 +1,4 @@
1
+ /** Used by the non-interactive `usage()` path in cli.ts — the boxed banner is shell-only. */
1
2
  export declare function printWordmark(): void;
2
3
  export declare function printBannerHeader(): Promise<void>;
3
4
  export declare function shellPrompt(loggedIn: boolean): string;
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
- function formatStatusLine(me, key) {
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 (` ${ansi.dim("○")} not logged in — type ${ansi.green("login")} to sign in via browser,\n` +
19
- ` or ${ansi.green("login --key")} to paste an API key`);
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 ` ${ansi.green("●")} logged in (${ansi.bold(display)})`;
45
+ return [`${ansi.green("●")} logged in (${ansi.bold(display)})`];
24
46
  }
25
47
  const plan = me.plan_label_key ?? me.plan_key;
26
- const left = me.usage.standard.remaining;
27
- const limit = me.usage.standard.limit;
28
- return (` ${ansi.green("●")} ${ansi.bold(display)} · ${plan} · ` +
29
- `standard credits: ${left} of ${limit} left`);
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
- console.log(formatStatusLine(me, key));
40
- console.log("");
41
- console.log(ansi.dim(" type `help` for commands · `review` to review main...HEAD"));
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
- const name = loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
46
- return `${name}> `;
116
+ return loggedIn ? ansi.boldGreen("mergestorm") : ansi.dim("mergestorm");
47
117
  }
@@ -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";
@@ -0,0 +1,5 @@
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 declare const TORNADO_LOGO: string[];
@@ -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>;