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 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
- node packages/mergestorm-cli/dist/cli.js <command>
28
- # or skip the build during dev (runs src/cli.ts via tsx):
29
- npm run dev --workspace=mergestorm -- <command>
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 boxed welcome panel (logo, auth/credits status, tips, what's new) and a bordered input box with a live `/` slash-command dropdown. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
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
- ╭──────────────────── mergestorm v0.3.0 ────────────────────╮
38
- │ ▟██████████▛ │
39
- │ ▜████████▛ │
40
- │ ▝▜████▛▘ │
41
- │ ▜██▛ │
42
- │ ██ │
43
- │ ▝▘ │
44
- │ │
45
- │ ● msk_live_a1b2… · maelstrom │
46
- │ [████████████████████░░░░░░░░░░░░] 63% used │
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
- ⁄review Review git diff (default main...HEAD)
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, and Ctrl+C-to-clear all work as in a regular 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).
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 Claude-style 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>`).
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
- let sigintHint = false;
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
- if (!sigintHint) {
69
- output.write(`\n${ansi.dim("(type exit or press ctrl+d to quit)")}\n`);
70
- sigintHint = true;
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
- sigintHint = false;
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}" — type help`));
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
- "Claude-style usage meter (N% used) + credit reset time",
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 via browser,`,
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
- ` ${usageBar(s.used, s.limit, { width: 32 })}`,
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 the diff for main...HEAD`,
54
- ` ${ansi.green("branches")} pick a recently reviewed branch`,
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 textLines = [
81
- ...formatStatusLines(me, key),
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
- 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], {
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 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.
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 = /\[[0-9;]*m/g;
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 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.
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 innerWidth = Math.max(0, ...lines.map(visibleWidth), visibleWidth(title)) + padding * 2;
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";
@@ -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 on an empty line). */
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
- /** Thrown when the user asks to close the prompt (Ctrl+D on an empty line). */
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
- rows.push(...roundedBox([`${ansi.green("›")} ${buffer}`], { padding: 1 }));
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 = ` ⁄${c.name.padEnd(12)}${c.summary}`;
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
  }
@@ -1,11 +1,11 @@
1
1
  export type UsageBarOpts = {
2
- /** Bar width in cells (default 56 — Claude-length). */
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
- * Claude-style usage meter — long thin bar + percent only:
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
- * Claude-style reset footer: `Resets Aug 1, 12:00am (UTC)`.
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
- * Claude-style usage meter — long thin bar + percent only:
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 — matches Claude's quiet meter.
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
- * Claude-style reset footer: `Resets Aug 1, 12:00am (UTC)`.
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()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "MergeStorm CLI — interactive shell and one-shot review commands",
5
5
  "type": "module",
6
6
  "bin": {