mergestorm 0.3.24 → 0.3.26

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.
@@ -0,0 +1,43 @@
1
+ import type { StackDto } from "./stack-dto.js";
2
+ export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
3
+ export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
4
+ export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
5
+ export type StackWatchNextArgs = {
6
+ stack_id: string;
7
+ enrolled_head_sha?: string | null;
8
+ after_finished_at?: string | null;
9
+ bounce_id?: string | null;
10
+ timeout_s: 45;
11
+ };
12
+ export type StackWatchNext = {
13
+ tool: "stack_wait";
14
+ args: StackWatchNextArgs;
15
+ command: string;
16
+ };
17
+ export type StackWatchObligation = {
18
+ done: boolean;
19
+ until: "landed";
20
+ reason: StackWatchReason;
21
+ next: StackWatchNext | null;
22
+ message: string;
23
+ };
24
+ export type StackWatchObligationInput = {
25
+ stackId: string;
26
+ terminal: StackWatchDoneReason | null;
27
+ cursor?: {
28
+ enrolledHeadSha?: string | null;
29
+ afterFinishedAt?: string | null;
30
+ bounceId?: string | null;
31
+ } | null;
32
+ status?: string;
33
+ attention?: {
34
+ prNumber: number;
35
+ blocker: string;
36
+ } | null;
37
+ retryAfterSeconds?: number;
38
+ unread?: boolean;
39
+ freshCursor?: boolean;
40
+ };
41
+ export declare function stackTerminalReason(stack: Pick<StackDto, "archivedAt" | "layers" | "unit"> | null | undefined): StackWatchDoneReason | null;
42
+ export declare function stackWatchNextArgs(stackId: string, cursor: StackWatchObligationInput["cursor"]): StackWatchNextArgs;
43
+ export declare function stackWatchObligation(input: StackWatchObligationInput): StackWatchObligation;
@@ -0,0 +1,79 @@
1
+ export const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed. Your task is not done. Call stack_wait again with this cursor.";
2
+ const DONE_MESSAGES = {
3
+ landed: "This stack is landed. The watch is done; stop calling stack_wait for it.",
4
+ closed: "Every PR in this stack is merged or closed and nothing is left to land, but not every PR landed. The watch is done; tell the human which PRs were closed without landing.",
5
+ archived: "This stack is archived: the human took it back. The watch is done; stop calling stack_wait for it and do not push to its PRs.",
6
+ not_found: "No stack with this id exists for this account. Mergestorm deletes a stack once none of its layers is open, so a stack you were watching has most likely landed; a mistyped id or another account's stack reads the same. The watch is done; confirm each PR is merged (gh pr view <n> --json state) before reporting it landed.",
7
+ };
8
+ export function stackTerminalReason(stack) {
9
+ if (!stack)
10
+ return "not_found";
11
+ if (stack.archivedAt)
12
+ return "archived";
13
+ const layers = stack.layers;
14
+ if (layers.some((layer) => layer.state !== "merged" && layer.state !== "closed"))
15
+ return null;
16
+ const unit = stack.unit;
17
+ if (layers.length === 0 && !unit)
18
+ return null;
19
+ const landPr = unit?.landPr;
20
+ if (landPr && landPr.prNumber > 0 && landPr.state !== "merged" && landPr.state !== "closed")
21
+ return null;
22
+ if (unit && unit.state !== "landed" && unit.state !== "abandoned")
23
+ return null;
24
+ const everyLayerMerged = layers.every((layer) => layer.state === "merged");
25
+ return everyLayerMerged && (!unit || unit.state === "landed") ? "landed" : "closed";
26
+ }
27
+ export function stackWatchNextArgs(stackId, cursor) {
28
+ return {
29
+ stack_id: stackId,
30
+ ...(cursor && cursor.enrolledHeadSha !== undefined ? { enrolled_head_sha: cursor.enrolledHeadSha } : {}),
31
+ ...(cursor && cursor.afterFinishedAt !== undefined ? { after_finished_at: cursor.afterFinishedAt } : {}),
32
+ ...(cursor && cursor.bounceId !== undefined ? { bounce_id: cursor.bounceId } : {}),
33
+ timeout_s: 45,
34
+ };
35
+ }
36
+ export function stackWatchObligation(input) {
37
+ if (input.terminal) {
38
+ return {
39
+ done: true,
40
+ until: "landed",
41
+ reason: input.terminal,
42
+ next: null,
43
+ message: DONE_MESSAGES[input.terminal],
44
+ };
45
+ }
46
+ const args = stackWatchNextArgs(input.stackId, input.cursor);
47
+ const reason = input.status === "rate_limited"
48
+ ? "rate_limited"
49
+ : input.status === "failed"
50
+ ? "failed"
51
+ : input.attention ? "attention" : input.unread ? "unread" : "open";
52
+ const parts = [
53
+ STACK_WATCH_NOT_DONE_SENTENCE,
54
+ `Watch stack ${input.stackId} as a whole until it lands or the human takes it back. A clean push, a submit, or one merged layer does not finish it: merging a lower layer can break a layer above it.`,
55
+ ];
56
+ if (reason === "rate_limited") {
57
+ parts.push(`Wait ${input.retryAfterSeconds !== undefined ? `${input.retryAfterSeconds}s` : "retry_after_seconds"} first, then call stack_wait with the same cursor.`);
58
+ }
59
+ else if (reason === "failed") {
60
+ parts.push(`This read failed, which does not end the task. Call stack_wait once more with the same cursor; if that fails too, tell the human that stack ${input.stackId} is not landed and is no longer being watched.`);
61
+ }
62
+ else if (reason === "attention" && input.attention) {
63
+ parts.push(`First fix #${input.attention.prNumber} (${input.attention.blocker}); repair, when present, names the fix. Calling stack_wait again before fixing it returns the same attention. After your push, call stack_wait with this cursor and enrolled_head_sha set to the SHA you pushed.`);
64
+ }
65
+ else if (reason === "unread") {
66
+ parts.push("This tool did not read the stack's state; stack_wait will.");
67
+ }
68
+ parts.push(`Next call: stack_wait ${JSON.stringify(args)}.`);
69
+ if (input.freshCursor) {
70
+ parts.push("If you already hold a cursor from an earlier stack_wait on this stack, keep passing that cursor instead.");
71
+ }
72
+ return {
73
+ done: false,
74
+ until: "landed",
75
+ reason,
76
+ next: { tool: "stack_wait", args, command: `mg stack wait ${input.stackId} --json` },
77
+ message: parts.join(" "),
78
+ };
79
+ }
@@ -1,8 +1,10 @@
1
- import { type StackBlocker } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
2
+ import { type StackWatchObligation } from "./stack-watch-obligation.js";
2
3
  import { apiFetch } from "./api.js";
3
4
  import type { Config } from "./config.js";
4
5
  export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
5
6
  export declare const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45000;
7
+ export declare const STACK_WATCH_BUSY_RECHECK_S = 15;
6
8
  /** Resume selectors are fixed for the entire wait, including transient retries. */
7
9
  export type StackWatchCursor = {
8
10
  readonly stackId: string;
@@ -25,6 +27,11 @@ export type StackWatchEnvelope = {
25
27
  headSha: string | null;
26
28
  } | null;
27
29
  assessment: "available" | "unavailable";
30
+ busy: StackAgentBusy[];
31
+ agents: StackLayerAgents | null;
32
+ repair: StackRepairHint | null;
33
+ landGatePending: StackLandGatePending | null;
34
+ watch: StackWatchObligation;
28
35
  };
29
36
  export type PollStackWatchOptions = {
30
37
  timeoutMs?: number;
@@ -1,9 +1,11 @@
1
- import { currentLayer, stackBlockers } from "./stack-blockers.js";
1
+ import { currentLayer, layerAgents, stackBlockers, } from "./stack-blockers.js";
2
+ import { stackTerminalReason, stackWatchObligation, } from "./stack-watch-obligation.js";
2
3
  import { setTimeout as sleep } from "node:timers/promises";
3
4
  import { apiFetch } from "./api.js";
4
5
  import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
5
6
  export const STACK_WATCH_SCHEMA = "mergestorm.stack_watch/v1";
6
7
  export const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45_000;
8
+ export const STACK_WATCH_BUSY_RECHECK_S = 15;
7
9
  export class StackWatchTimeoutError extends Error {
8
10
  lastEnvelope;
9
11
  constructor(lastEnvelope) {
@@ -46,10 +48,20 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
46
48
  let lastEnvelope = {
47
49
  schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
48
50
  bounceKind: null, prNumber: null, headSha: null, cursor,
49
- issues: [], currentCandidate: null, assessment: "unavailable",
51
+ issues: [], currentCandidate: null, assessment: "unavailable", busy: [], agents: null, repair: null,
52
+ landGatePending: null,
50
53
  };
54
+ let terminal = null;
55
+ const seal = (snapshot, retryAfterSeconds) => ({
56
+ ...snapshot,
57
+ watch: stackWatchObligation({
58
+ stackId: id, terminal, cursor: snapshot.cursor, status: snapshot.status, retryAfterSeconds,
59
+ attention: snapshot.status === "attention" && snapshot.blocker && snapshot.prNumber != null
60
+ ? { prNumber: snapshot.prNumber, blocker: snapshot.blocker } : null,
61
+ }),
62
+ });
51
63
  let transientFailures = 0;
52
- const timeout = () => new StackWatchTimeoutError(lastEnvelope);
64
+ const timeout = () => new StackWatchTimeoutError(seal(lastEnvelope));
53
65
  const pause = async (ms) => {
54
66
  const remaining = Math.min(ms, Math.max(0, deadline - now()));
55
67
  if (remaining > 0)
@@ -75,11 +87,11 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
75
87
  throw timeout();
76
88
  if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
77
89
  transientFailures += 1;
78
- opts.onTick?.(lastEnvelope);
90
+ opts.onTick?.(seal(lastEnvelope));
79
91
  await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
80
92
  continue;
81
93
  }
82
- throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed", assessment: "unavailable" }, { cause: err });
94
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }), { cause: err });
83
95
  }
84
96
  if (response.status === 200)
85
97
  return response.body;
@@ -89,14 +101,14 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
89
101
  if (timeoutMs !== 0 && isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES &&
90
102
  (response.status !== 429 || retryWaitMs < deadline - now())) {
91
103
  transientFailures += 1;
92
- opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
104
+ opts.onTick?.(seal({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status }, response.status === 429 ? response.retryAfterSeconds : undefined));
93
105
  await pause(retryWaitMs);
94
106
  continue;
95
107
  }
96
- throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
108
+ throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, seal({
97
109
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
98
110
  assessment: "unavailable",
99
- }, undefined, response.retryAfterSeconds);
111
+ }, response.retryAfterSeconds), undefined, response.retryAfterSeconds);
100
112
  }
101
113
  throw timeout();
102
114
  };
@@ -105,16 +117,22 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
105
117
  const stack = snapshot && Array.isArray(snapshot.stacks)
106
118
  ? snapshot.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
107
119
  : undefined;
120
+ if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
121
+ terminal = "not_found";
122
+ throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [],
123
+ prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
124
+ }
108
125
  if (!stack || !Array.isArray(stack.layers)) {
109
- throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
126
+ throw new StackWatchError("Stack snapshot missing or invalid", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
110
127
  }
128
+ terminal = stackTerminalReason(stack);
111
129
  return stack;
112
130
  };
113
131
  const queueRoute = `/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`;
114
132
  const readQueue = async (route, finalSnapshot = false) => {
115
133
  const queue = await request(route, finalSnapshot);
116
134
  if (!queue || !Array.isArray(queue.entries)) {
117
- throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
135
+ throw new StackWatchError("Invalid merge queue response", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
118
136
  }
119
137
  return {
120
138
  entries: queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id),
@@ -122,15 +140,16 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
122
140
  };
123
141
  };
124
142
  const evaluate = (stack, entries) => {
125
- const { attention, issues, currentCandidate, bounce } = stackBlockers(stack, entries, cursor);
143
+ const { attention, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
126
144
  if (bounce)
127
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
128
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
129
- lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available",
147
+ const held = busy[0];
148
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
130
149
  blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
131
- prNumber: attention?.prNumber ?? currentCandidate?.prNumber ?? null,
132
- headSha: attention ? attention.headSha : currentCandidate?.headSha ?? null,
133
- status: attention ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
150
+ prNumber: attention?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
151
+ headSha: attention ? attention.headSha : held ? held.headSha : currentCandidate?.headSha ?? null,
152
+ status: attention ? "attention" : busy.length || landGatePending || entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
134
153
  ? "in_progress" : "waiting" };
135
154
  };
136
155
  let stack = await enrich(timeoutMs === 0);
@@ -139,14 +158,15 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
139
158
  cursor = Object.freeze({ stackId: id, enrolledHeadSha: layer?.headSha ?? null });
140
159
  enrolled = true;
141
160
  }
142
- lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
161
+ lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null,
162
+ agents: layer ? layerAgents(layer) : null };
143
163
  let queue = await readQueue(queueRoute, timeoutMs === 0);
144
164
  evaluate(stack, queue.entries);
145
- if (lastEnvelope.status === "attention" || timeoutMs === 0)
146
- return lastEnvelope;
165
+ if (lastEnvelope.status === "attention" || timeoutMs === 0 || terminal)
166
+ return seal(lastEnvelope);
147
167
  while (now() < deadline) {
148
168
  const heldAt = now();
149
- const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
169
+ const seconds = Math.min(lastEnvelope.busy.length ? STACK_WATCH_BUSY_RECHECK_S : 45, Math.max(1, Math.ceil((deadline - heldAt) / 1000)));
150
170
  const seen = queue.fingerprint ? `&seen=${encodeURIComponent(queue.fingerprint)}` : "";
151
171
  const held = await readQueue(`${queueRoute}&wait=${seconds}${seen}`);
152
172
  if (JSON.stringify(held.entries) === JSON.stringify(queue.entries)) {
@@ -155,12 +175,13 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
155
175
  queue = held;
156
176
  stack = await enrich(true);
157
177
  const refreshed = currentLayer(stack);
158
- lastEnvelope = { ...lastEnvelope, prNumber: refreshed?.prNumber ?? null, headSha: refreshed?.headSha ?? null };
178
+ lastEnvelope = { ...lastEnvelope, prNumber: refreshed?.prNumber ?? null, headSha: refreshed?.headSha ?? null,
179
+ agents: refreshed ? layerAgents(refreshed) : null };
159
180
  transientFailures = 0;
160
181
  evaluate(stack, queue.entries);
161
- if (lastEnvelope.status === "attention")
162
- return lastEnvelope;
163
- opts.onTick?.(lastEnvelope);
182
+ if (lastEnvelope.status === "attention" || terminal)
183
+ return seal(lastEnvelope);
184
+ opts.onTick?.(seal(lastEnvelope));
164
185
  }
165
186
  throw timeout();
166
187
  }
package/dist/ui/ansi.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
3
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
4
+ */
5
+ export declare const BRAND_GREEN: {
6
+ readonly hex: "#5FAF5F";
7
+ readonly index: 77;
8
+ };
9
+ export declare const BRAND_GREEN_SGR: string;
10
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
11
+ export declare const BRAND_FILL_SGR: string;
1
12
  export declare const ansi: {
2
13
  readonly enabled: boolean;
3
14
  green: (t: string) => string;
@@ -7,8 +18,10 @@ export declare const ansi: {
7
18
  yellow: (t: string) => string;
8
19
  gray: (t: string) => string;
9
20
  brightGreen: (t: string) => string;
21
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
22
+ brandFill: (t: string) => string;
10
23
  invert: (t: string) => string;
11
24
  boldGreen: (t: string) => string;
12
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
25
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
13
26
  boldBrightGreen: (t: string) => string;
14
27
  };
package/dist/ui/ansi.js CHANGED
@@ -9,7 +9,25 @@ function computeEnabled() {
9
9
  function wrap(code, text) {
10
10
  if (!computeEnabled())
11
11
  return text;
12
- return `[${code}m${text}`;
12
+ return `\x1b[${code}m${text}\x1b[0m`;
13
+ }
14
+ /**
15
+ * Brand ink. xterm-256 77 (#5FAF5F) � muted green that Terminal.app
16
+ * actually paints. 24-bit `38;2;61;�` was read as palette 61 (purple).
17
+ */
18
+ export const BRAND_GREEN = { hex: "#5FAF5F", index: 77 };
19
+ export const BRAND_GREEN_SGR = `38;5;${BRAND_GREEN.index}`;
20
+ /** Full-cell fill. Glyphs like ? leave a hairline; a space with this bg does not. */
21
+ export const BRAND_FILL_SGR = `${BRAND_GREEN_SGR};48;5;${BRAND_GREEN.index}`;
22
+ function brandGreen(text, bold = false) {
23
+ if (!computeEnabled())
24
+ return text;
25
+ return `\x1b[${bold ? "1;" : ""}${BRAND_GREEN_SGR}m${text}\x1b[0m`;
26
+ }
27
+ function brandFill(text) {
28
+ if (!computeEnabled())
29
+ return text;
30
+ return `\x1b[${BRAND_FILL_SGR}m${text}\x1b[0m`;
13
31
  }
14
32
  export const ansi = {
15
33
  get enabled() {
@@ -21,9 +39,11 @@ export const ansi = {
21
39
  red: (t) => wrap("31", t),
22
40
  yellow: (t) => wrap("33", t),
23
41
  gray: (t) => wrap("90", t),
24
- brightGreen: (t) => wrap("92", t),
42
+ brightGreen: (t) => brandGreen(t),
43
+ /** Solid cell (space + fg/bg 77). Stacks flush � no ? hairline. */
44
+ brandFill: (t) => brandFill(t),
25
45
  invert: (t) => wrap("7", t),
26
- boldGreen: (t) => (computeEnabled() ? `${t}` : t),
27
- /** Prompt / mark ink. Never 32 — that reads as sick dark green. */
28
- boldBrightGreen: (t) => (computeEnabled() ? `${t}` : t),
46
+ boldGreen: (t) => (computeEnabled() ? `\x1b[1;32m${t}\x1b[0m` : t),
47
+ /** Prompt / mark ink. Never ANSI 32, 92, or 24-bit. */
48
+ boldBrightGreen: (t) => brandGreen(t, true),
29
49
  };
@@ -14,7 +14,7 @@ export type BannerDensity = "full" | "compact" | "mini" | "nano";
14
14
  export type BannerVariant = boolean | "mini" | "nano";
15
15
  /** Handle returned by {@link printBannerHeader}; `rows` reflows for a resize. */
16
16
  export type BannerHandle = {
17
- rows(columns?: number, variant?: BannerVariant): string[];
17
+ rows(columns?: number, variant?: BannerVariant, frame?: number): string[];
18
18
  };
19
19
  /**
20
20
  * The whole welcome panel as rows, reflowed to `columns`. Every row fits in
@@ -24,6 +24,7 @@ export type BannerHandle = {
24
24
  export declare function buildBannerRows(state: BannerState, columns?: number, opts?: {
25
25
  compact?: boolean;
26
26
  density?: BannerDensity;
27
+ frame?: number;
27
28
  }): string[];
28
29
  /**
29
30
  * Print the welcome panel and return a handle whose `rows()` reflows the
package/dist/ui/banner.js CHANGED
@@ -3,8 +3,8 @@ import { CommandError } from "../errors.js";
3
3
  import { keyDisplay, loadConfig, resolveApiKey } from "../config.js";
4
4
  import { cliVersion } from "../version.js";
5
5
  import { ansi } from "./ansi.js";
6
- import { frameWidth, padVisible, preferBoxedUi, roundedBox, terminalColumns, visibleWidth, } from "./box.js";
7
- import { STORM_MARK, STORM_MARK_WIDTH } from "./logo.js";
6
+ import { frameWidth, padVisible, preferBoxedUi, roundedBox, sideBySide, terminalColumns, visibleWidth, } from "./box.js";
7
+ import { paintSpriteRow, TORNADO_LOGO, TORNADO_LOGO_WIDTH } from "./logo.js";
8
8
  import { formatResetLabel, usageBar } from "./usage.js";
9
9
  const WORDMARK = `
10
10
  __ __ ___ ___ ___ ___ ___ _____ ___ ___ __ __
@@ -105,6 +105,12 @@ const WHATS_NEW = {
105
105
  "0.3.24": [
106
106
  "mg settings --auto-land-settle (15-300 seconds)",
107
107
  ],
108
+ "0.3.25": [
109
+ "stack wait holds a PR while Cyclone or Vortex works",
110
+ ],
111
+ "0.3.26": [
112
+ "stack results say the watch ends only at land",
113
+ ],
108
114
  };
109
115
  function densityFromVariant(variant) {
110
116
  if (variant === "mini" || variant === "nano")
@@ -196,57 +202,30 @@ export function buildBannerRows(state, columns, opts) {
196
202
  const contentCells = boxed
197
203
  ? Math.max(20, outer - 4)
198
204
  : Math.max(20, terminalColumns(80, columns) - 1);
199
- const indent = STORM_MARK_WIDTH + 2;
200
- const pad = " ".repeat(indent);
205
+ const markLines = TORNADO_LOGO;
206
+ const markWidth = TORNADO_LOGO_WIDTH;
207
+ const gap = 2;
208
+ const rightCells = Math.max(8, contentCells - markWidth - gap);
201
209
  const clip = (line) => visibleWidth(line) > contentCells ? padVisible(line, contentCells) : line;
202
- const mark = (row) => padVisible(ansi.brightGreen(STORM_MARK[row] ?? ""), indent);
210
+ const clipRight = (line) => visibleWidth(line) > rightCells ? padVisible(line, rightCells) : line;
203
211
  const title = `${ansi.boldBrightGreen("mergestorm")} ${ansi.dim(`v${state.version}`)}`;
204
- if (density === "nano") {
205
- const one = `${mark(0)}${title} ${statusLine(state)}`;
206
- return [clip(one)];
207
- }
208
- if (density === "mini") {
209
- // Short pane: mark + who you are, then the same cues as the full home.
210
- const line0 = clip(`${mark(0)}${title} ${statusLine(state)}`);
211
- const line1 = state.me && !state.keyInvalid
212
- ? clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`)
213
- : clip(mark(1));
214
- const room = contentCells - indent;
215
- const blurb = tagline(room);
216
- const tips = tipsLine(room);
217
- const fresh = whatsNewLine(state.version, room);
218
- return [
219
- line0,
220
- line1,
221
- ...(blurb ? [clip(`${pad}${blurb}`)] : []),
222
- ...(tips ? [clip(`${pad}${tips}`)] : []),
223
- ...(fresh ? [clip(`${pad}${fresh}`)] : []),
224
- ];
225
- }
226
- const content = [
227
- clip(`${pad}${title}`),
228
- clip(`${mark(0)}${statusLine(state)}`),
229
- ];
212
+ const right = [clipRight(title), clipRight(statusLine(state))];
230
213
  if (state.me && !state.keyInvalid) {
231
- content.push(clip(`${mark(1)}${usageLine(state.me, contentCells, indent)}`));
232
- }
233
- else {
234
- content.push(clip(mark(1)));
214
+ right.push(clipRight(usageLine(state.me, rightCells, 0)));
235
215
  }
236
- const room = contentCells - indent;
237
- const blurb = tagline(room);
238
- const tips = tipsLine(room);
239
- const fresh = whatsNewLine(state.version, room);
216
+ const blurb = tagline(rightCells);
217
+ const tips = tipsLine(rightCells);
218
+ const fresh = whatsNewLine(state.version, rightCells);
240
219
  if (blurb)
241
- content.push(clip(`${pad}${blurb}`));
220
+ right.push(clipRight(blurb));
242
221
  if (tips)
243
- content.push(clip(`${pad}${tips}`));
222
+ right.push(clipRight(tips));
244
223
  if (fresh)
245
- content.push(clip(`${pad}${fresh}`));
246
- if (boxed && density === "full") {
247
- content.splice(3, 0, "");
248
- return [...roundedBox(content, { width: outer, padding: 1 })];
249
- }
224
+ right.push(clipRight(fresh));
225
+ const markCol = markLines.map((line) => padVisible(paintSpriteRow(line), markWidth));
226
+ const content = sideBySide(markCol, density === "nano" ? right.slice(0, 3) : right, markWidth, gap).map(clip);
227
+ if (density === "mini" || density === "nano")
228
+ return content;
250
229
  if (boxed) {
251
230
  return [...roundedBox(content, { width: outer, padding: 1 })];
252
231
  }
@@ -282,7 +261,7 @@ export async function printBannerHeader(opts) {
282
261
  }
283
262
  }
284
263
  return {
285
- rows: (columns, variant) => buildBannerRows(state, columns, { density: densityFromVariant(variant) }),
264
+ rows: (columns, variant, frame) => buildBannerRows(state, columns, { density: densityFromVariant(variant), frame }),
286
265
  };
287
266
  }
288
267
  export function shellPrompt(_loggedIn) {
package/dist/ui/logo.d.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
2
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
3
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
4
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
5
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
6
  */
5
- export declare const STORM_MARK: string[];
6
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
7
- export declare const STORM_MARK_WIDTH: number;
8
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
9
7
  export declare const TORNADO_LOGO: string[];
10
- /** @deprecated Alias. */
8
+ /** Single idle frame. Kept for callers that index frames. */
9
+ export declare const TORNADO_FRAMES: string[][];
10
+ /** Every sprite glyph is one column. */
11
11
  export declare const TORNADO_LOGO_WIDTH: number;
12
- /** @deprecated Compact home is the full two-row mark. */
13
12
  export declare const TORNADO_LOGO_COMPACT: string[];
13
+ /** @deprecated Use {@link TORNADO_LOGO}. */
14
+ export declare const STORM_MARK: string[];
15
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
16
+ export declare const STORM_MARK_WIDTH: number;
17
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
18
+ export declare function paintSpriteRow(row: string): string;
package/dist/ui/logo.js CHANGED
@@ -1,16 +1,28 @@
1
+ import { ansi } from "./ansi.js";
1
2
  /**
2
- * Compact two-row storm mark (Fable / 0.3.14 home). Colored by the caller.
3
- * Block elements (U+2580..259F) are one cell each; no emoji, no gradients.
3
+ * The home mark: a tornado drawn as five horizontal bars and one square tail,
4
+ * each an upper-half band (▀). One column is one bar-height wide; half-column
5
+ * ends use the upper quadrants (▝ ▘) so every edge lands where the source art
6
+ * puts it. Same X/width as the SVG, shifted up half a cell. Painted fg 77.
4
7
  */
5
- export const STORM_MARK = [
6
- "▟██▛",
7
- " ▜▙",
8
+ export const TORNADO_LOGO = [
9
+ " ▀▀▀▀▀▀▀▀▀▀▘",
10
+ "▀▀▀▀▀▀▀▀▀ ",
11
+ " ▀▀▀▀▀▀ ",
12
+ " ▝▀▀▀▀▀ ",
13
+ " ▝▀▀▘ ",
14
+ " ▀ ",
8
15
  ];
9
- /** Widest row of the mark, in cells (every mark glyph is single-cell). */
10
- export const STORM_MARK_WIDTH = Math.max(...STORM_MARK.map((line) => line.length));
11
- /** @deprecated Alias — the home panel uses {@link STORM_MARK}. */
12
- export const TORNADO_LOGO = STORM_MARK;
13
- /** @deprecated Alias. */
14
- export const TORNADO_LOGO_WIDTH = STORM_MARK_WIDTH;
15
- /** @deprecated Compact home is the full two-row mark. */
16
- export const TORNADO_LOGO_COMPACT = STORM_MARK;
16
+ /** Single idle frame. Kept for callers that index frames. */
17
+ export const TORNADO_FRAMES = [TORNADO_LOGO];
18
+ /** Every sprite glyph is one column. */
19
+ export const TORNADO_LOGO_WIDTH = Math.max(...TORNADO_LOGO.map((line) => [...line].length));
20
+ export const TORNADO_LOGO_COMPACT = TORNADO_LOGO;
21
+ /** @deprecated Use {@link TORNADO_LOGO}. */
22
+ export const STORM_MARK = TORNADO_LOGO;
23
+ /** @deprecated Use {@link TORNADO_LOGO_WIDTH}. */
24
+ export const STORM_MARK_WIDTH = TORNADO_LOGO_WIDTH;
25
+ /** Paint one sprite row: each run of glyphs in brand green 77, spaces left bare. */
26
+ export function paintSpriteRow(row) {
27
+ return row.replace(/[^ ]+/g, (run) => ansi.brightGreen(run));
28
+ }
@@ -23,7 +23,7 @@ export interface AskLineOptions {
23
23
  * resize cannot desync CUU counts. Pass `true` for compact, `"mini"` /
24
24
  * `"nano"` for a short pane that still shows the mark.
25
25
  */
26
- header?: (columns?: number, variant?: boolean | "mini" | "nano") => string[];
26
+ header?: (columns?: number, variant?: boolean | "mini" | "nano", frame?: number) => string[];
27
27
  }
28
28
  /** Thrown when the user asks to close the prompt (Ctrl+D empty, or Ctrl+C twice). */
29
29
  export declare class PromptClosedError extends Error {
@@ -47,6 +47,16 @@ export declare function promptClearOverlay(boxed: boolean): string;
47
47
  * frame's row count is unknowable after the terminal rewraps.
48
48
  */
49
49
  export declare const HEADER_REDRAW_PREFIX = "\u001B[H\u001B[0J";
50
+ export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
51
+ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
52
+ export type SpriteRect = {
53
+ col: number;
54
+ row: number;
55
+ width: number;
56
+ height: number;
57
+ };
58
+ /** SGR coordinates and the rectangle are both 1-based. */
59
+ export declare function hitTestSprite(col: number, row: number, rect: SpriteRect): boolean;
50
60
  export declare const DROPDOWN_MAX_COMMAND_ROWS = 8;
51
61
  export declare function formatSlashLabel(spec: CommandSpec): string;
52
62
  export declare function specMatchesTerm(spec: CommandSpec, term: string): boolean;