pi-bro 0.24.0 → 0.25.0

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/src/modal-kit.ts CHANGED
@@ -71,6 +71,9 @@ export type FrameParts = {
71
71
  notice?: string;
72
72
  /** Omit when the body prints its own hints (config's SettingsList). */
73
73
  hints?: string;
74
+ /** Lines rendered under the hints, e.g. a focusable action row. Wrapped by the caller; a line
75
+ * wider than the frame is truncated, so keep a single label narrower than the modal. */
76
+ footerAfterHints?: string;
74
77
  /** false: size to the content instead of padding the body to the full height. */
75
78
  fill?: boolean;
76
79
  /** Body rows the notice must leave free; it shrinks to one line before taking them. */
@@ -94,10 +97,12 @@ export function renderFrame(theme: Theme, width: number, height: number, parts:
94
97
  const footer = parts.footer ?? [];
95
98
  // Wrapped, not truncated: Esc comes last in every hint line and must not be cut off.
96
99
  const hints = parts.hints ? wrapTextWithAnsi(theme.fg("dim", parts.hints), inner) : [];
100
+ // The action row is already wrapped by the caller, so keep its own line breaks.
101
+ const belowHints = parts.footerAfterHints ? parts.footerAfterHints.split("\n").map((line) => truncateToWidth(line, inner, "")) : [];
97
102
  const notice = parts.notice ? wrapTextWithAnsi(parts.notice, inner).slice(0, 3) : [];
98
- while (notice.length > 1 && height - 5 - footer.length - hints.length - notice.length < (parts.minBody ?? 0)) notice.pop();
103
+ while (notice.length > 1 && height - 5 - footer.length - hints.length - belowHints.length - notice.length < (parts.minBody ?? 0)) notice.pop();
99
104
  // The notice sits above the footer so it is dropped before a composer when space runs out.
100
- const below = [...notice, ...footer, ...hints];
105
+ const below = [...notice, ...footer, ...hints, ...belowHints];
101
106
  const bodyRows = Math.max(0, height - 5 - below.length);
102
107
  const body = bodyRows ? parts.body(bodyRows, inner).slice(0, bodyRows) : [];
103
108
  if (parts.fill !== false) while (body.length < bodyRows) body.push("");
@@ -174,6 +179,34 @@ export class Scroller {
174
179
  }
175
180
  }
176
181
 
182
+ /**
183
+ * Render a focusable action row: the focused button carries a leading marker and accent colour, so a
184
+ * keypress always has exactly one visible target. Callers own focus, keys and activation; this only
185
+ * draws, which is why Guided Review can keep its own navigation and layout.
186
+ */
187
+ export function buttonLabels(theme: Theme, labels: readonly string[], focused?: number): string[] {
188
+ return labels.map((label, index) => {
189
+ const text = `${index === focused ? "›" : ""}[${label}]`;
190
+ return index === focused ? theme.bold(theme.fg("accent", text)) : text;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Pack whole buttons onto lines with `visibleWidth`, never splitting one: a label broken mid-word
196
+ * makes the focused target ambiguous, which is the misclick this row exists to prevent.
197
+ */
198
+ export function wrapButtons(buttons: readonly string[], width: number): string[] {
199
+ const lines: string[] = [];
200
+ let line = "";
201
+ for (const button of buttons) {
202
+ if (!line) { line = button; continue; }
203
+ if (visibleWidth(`${line} ${button}`) <= width) line += ` ${button}`;
204
+ else { lines.push(line); line = button; }
205
+ }
206
+ if (line) lines.push(line);
207
+ return lines;
208
+ }
209
+
177
210
  /**
178
211
  * Forward a pointer event to a pi-tui child drawn at `area` inside the overlay, in the child's own
179
212
  * coordinates (without `area`, the event already is). Pi 0.84 components have no handleMouse, hence