@xynogen/pix-pretty 1.7.0 → 1.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,7 +21,8 @@
21
21
  "./utils": "./src/utils.ts",
22
22
  "./resize": "./src/resize.ts",
23
23
  "./context": "./src/tools/context.ts",
24
- "./gate-overlay": "./src/gate-overlay.ts"
24
+ "./gate-overlay": "./src/gate-overlay.ts",
25
+ "./modal-frame": "./src/modal-frame.ts"
25
26
  },
26
27
  "scripts": {
27
28
  "test": "bun test"
package/src/confirm.ts CHANGED
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * pix-pretty/confirm — reusable Yes/No confirmation overlay.
3
3
  *
4
- * A single coloured SelectList overlay (Yes / No) with optional title, body
5
- * lines and an auto-cancel countdown. Mirrors the confirm stage of pix-sudo
6
- * so any extension can get the same look without re-implementing it.
7
- *
8
- * Returns true on confirm, false on deny / cancel / timeout.
4
+ * Rounded modal frame (╭─╮╰─╯), solid bg, accent border same visual style
5
+ * as gate-overlay and pix-ask. Returns true on confirm, false on deny/timeout.
9
6
  */
10
7
 
11
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
12
- import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
8
+ import { type SelectItem, SelectList } from "@earendil-works/pi-tui";
9
+ import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
13
10
 
14
- // Minimal structural type for the `ctx.ui.custom` host call. Kept local so the
15
- // component has no hard dependency on a specific Pi context shape.
11
+ // Minimal structural type for the `ctx.ui.custom` host call.
16
12
  interface CustomTheme {
17
13
  fg(color: string, text: string): string;
18
14
  bg(color: string, text: string): string;
@@ -76,6 +72,7 @@ export function confirmOverlay(
76
72
  ui.custom<boolean>(
77
73
  (tui, theme, _kb, done) => {
78
74
  let ticker: ReturnType<typeof setInterval> | undefined;
75
+ let countdownLine: string | undefined;
79
76
 
80
77
  const choices: SelectItem[] = [
81
78
  {
@@ -90,62 +87,33 @@ export function confirmOverlay(
90
87
  },
91
88
  ];
92
89
 
93
- const selectList = new SelectList(choices, choices.length, {
94
- selectedPrefix: (t) => theme.fg(accent, t),
95
- selectedText: (t) => theme.fg(accent, t),
96
- description: (t) => theme.fg("muted", t),
97
- scrollInfo: (t) => theme.fg("dim", t),
98
- noMatch: (t) => theme.fg("warning", t),
99
- });
100
-
101
- const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
102
- container.addChild(
103
- new DynamicBorder((s: string) => theme.fg(accent, s)),
104
- );
105
- container.addChild(
106
- new Text(theme.fg(accent, theme.bold(opts.title)), 1, 0),
90
+ const selectList = new SelectList(
91
+ choices,
92
+ choices.length,
93
+ selectListTheme(theme, accent),
107
94
  );
108
95
 
109
- for (const line of opts.body ?? []) {
110
- container.addChild(new Text(theme.fg("text", line), 1, 0));
111
- }
112
-
113
96
  if (timeoutMs > 0) {
114
97
  const deadlineMs = Date.now() + timeoutMs;
115
- const countdownText = new Text("", 1, 0);
116
98
  const updateCountdown = () => {
117
99
  const remaining = Math.max(
118
100
  0,
119
101
  Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
120
102
  );
121
- countdownText.setText(
103
+ countdownLine =
122
104
  theme.fg("dim", "Auto-cancel in ") +
123
- theme.fg(
124
- remaining <= COUNTDOWN_WARN_S ? accent : "muted",
125
- `${remaining}s`,
126
- ),
127
- );
105
+ theme.fg(
106
+ remaining <= COUNTDOWN_WARN_S ? accent : "muted",
107
+ `${remaining}s`,
108
+ );
128
109
  };
129
110
  updateCountdown();
130
111
  ticker = setInterval(() => {
131
112
  updateCountdown();
132
113
  tui.requestRender();
133
114
  }, SECOND_MS);
134
- container.addChild(countdownText);
135
115
  }
136
116
 
137
- container.addChild(selectList);
138
- container.addChild(
139
- new Text(
140
- theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
141
- 1,
142
- 0,
143
- ),
144
- );
145
- container.addChild(
146
- new DynamicBorder((s: string) => theme.fg(accent, s)),
147
- );
148
-
149
117
  const finish = (value: boolean) => {
150
118
  if (timer !== undefined) clearTimeout(timer);
151
119
  if (ticker !== undefined) clearInterval(ticker);
@@ -157,8 +125,41 @@ export function confirmOverlay(
157
125
  controller.signal.addEventListener("abort", () => finish(false));
158
126
 
159
127
  return {
160
- render: (w: number) => container.render(w),
161
- invalidate: () => container.invalidate(),
128
+ render: (w: number) => {
129
+ const mw = modalWidth(w);
130
+ const inner = mw - 4;
131
+ const lines: string[] = [];
132
+
133
+ // Title
134
+ lines.push(theme.fg(accent, theme.bold(opts.title)));
135
+
136
+ // Body
137
+ for (const line of opts.body ?? []) {
138
+ lines.push(theme.fg("text", line));
139
+ }
140
+
141
+ // Divider
142
+ lines.push(theme.fg("dim", "─".repeat(inner)));
143
+
144
+ // Countdown
145
+ if (countdownLine !== undefined) lines.push(countdownLine);
146
+
147
+ // Select list
148
+ for (const l of selectList.render(inner)) lines.push(l);
149
+
150
+ lines.push("");
151
+ lines.push(
152
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
153
+ );
154
+
155
+ return frameLines({
156
+ width: mw,
157
+ lines,
158
+ color: (s) => theme.fg(accent, s),
159
+ bg: (s) => theme.bg("customMessageBg", s),
160
+ });
161
+ },
162
+ invalidate: () => {},
162
163
  handleInput: (data: string) => {
163
164
  selectList.handleInput(data);
164
165
  tui.requestRender();
@@ -56,7 +56,6 @@ const BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); // word emphasi
56
56
  const BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m");
57
57
  const BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m");
58
58
  const BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m");
59
- const BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows when one side is shorter
60
59
 
61
60
  const FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); // desaturated green
62
61
  const FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); // desaturated red
@@ -912,21 +911,13 @@ export async function renderSplit(
912
911
  }
913
912
 
914
913
  const maxRowsN = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
915
- const leftIsEmpty = !r.left;
916
- const rightIsEmpty = !r.right;
917
914
  for (let row = 0; row < maxRowsN; row++) {
918
915
  const lg = row === 0 ? lResult.gutter : lResult.contGutter;
919
916
  const rg = row === 0 ? rResult.gutter : rResult.contGutter;
920
- const lb =
921
- lResult.bodyRows[row] ??
922
- (leftIsEmpty
923
- ? stripes(cw, stripeRow)
924
- : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
925
- const rb =
926
- rResult.bodyRows[row] ??
927
- (rightIsEmpty
928
- ? stripes(cw, stripeRow)
929
- : `${BG_EMPTY}${" ".repeat(cw)}${RST}`);
917
+ // A missing body row means this side has no content at this visual row
918
+ // (other side wrapped longer, or the side is empty) — always hatch it.
919
+ const lb = lResult.bodyRows[row] ?? stripes(cw, stripeRow);
920
+ const rb = rResult.bodyRows[row] ?? stripes(cw, stripeRow);
930
921
  out.push(`${lg}${lb}${DIVIDER}${rg}${rb}${RST}`);
931
922
  stripeRow++;
932
923
  }
@@ -5,7 +5,8 @@
5
5
  * "confirm" — SelectList only. Used by pix-gate for command gating.
6
6
  * "sudo" — SelectList → masked password input. Used by pix-sudo.
7
7
  *
8
- * Both modes share: colored border, title, body lines, countdown.
8
+ * Both modes share: rounded modal frame (╭─╮╰─╯), solid bg, accent border,
9
+ * title, body lines, optional countdown. Same visual style as pix-ask.
9
10
  *
10
11
  * Design goals:
11
12
  * - Pure function — no side effects, no global state.
@@ -13,14 +14,8 @@
13
14
  * - Single source of truth for the overlay look across pix-gate and pix-sudo.
14
15
  */
15
16
 
16
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
17
- import {
18
- Box,
19
- Input,
20
- type SelectItem,
21
- SelectList,
22
- Text,
23
- } from "@earendil-works/pi-tui";
17
+ import { Input, type SelectItem, SelectList } from "@earendil-works/pi-tui";
18
+ import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
24
19
 
25
20
  // ── Types ─────────────────────────────────────────────────────────────────────
26
21
 
@@ -122,6 +117,67 @@ class MaskedInput extends Input {
122
117
  }
123
118
  }
124
119
 
120
+ // ── Renderer ──────────────────────────────────────────────────────────────────
121
+
122
+ /** Build body lines for the current stage, rendered into frameLines. */
123
+ function buildLines(opts: {
124
+ theme: OverlayTheme;
125
+ accent: string;
126
+ config: OverlayConfig;
127
+ stage: "select" | "password";
128
+ selectList: SelectList;
129
+ maskedInput: MaskedInput;
130
+ countdownLine: string | undefined;
131
+ width: number;
132
+ }): string[] {
133
+ const {
134
+ theme,
135
+ accent,
136
+ config,
137
+ stage,
138
+ selectList,
139
+ maskedInput,
140
+ countdownLine,
141
+ width,
142
+ } = opts;
143
+ const inner = width - 4; // CHROME = 2 border + 2 padding
144
+ const lines: string[] = [];
145
+
146
+ // Title
147
+ lines.push(theme.fg(accent, theme.bold(config.title)));
148
+
149
+ // Body
150
+ for (const line of config.body ?? []) {
151
+ lines.push(theme.fg("text", line));
152
+ }
153
+
154
+ // Divider after title/body
155
+ lines.push(theme.fg("dim", "─".repeat(inner)));
156
+
157
+ // Countdown
158
+ if (countdownLine !== undefined) lines.push(countdownLine);
159
+
160
+ // Select or password stage
161
+ if (stage === "select") {
162
+ const listLines = selectList.render(inner);
163
+ for (const l of listLines) lines.push(l);
164
+ lines.push("");
165
+ lines.push(theme.fg("dim", "↑↓ navigate • enter select • esc deny"));
166
+ } else {
167
+ const label =
168
+ config.mode === "sudo"
169
+ ? (config.passwordLabel ?? "Sudo password:")
170
+ : "Password:";
171
+ lines.push(theme.fg("muted", label));
172
+ const inputLines = maskedInput.render(inner);
173
+ for (const l of inputLines) lines.push(l);
174
+ lines.push("");
175
+ lines.push(theme.fg("dim", "enter confirm • esc cancel"));
176
+ }
177
+
178
+ return lines;
179
+ }
180
+
125
181
  // ── Main ──────────────────────────────────────────────────────────────────────
126
182
 
127
183
  /**
@@ -171,9 +227,9 @@ export function showOverlay(
171
227
 
172
228
  ui.custom<OverlayResult>(
173
229
  (tui, theme, _kb, done) => {
174
- // ── state ───────────────────────────────────────────────────────
175
230
  type Stage = "select" | "password";
176
231
  let stage: Stage = "select";
232
+ let countdownLine: string | undefined;
177
233
 
178
234
  // ── components ──────────────────────────────────────────────────
179
235
  const selectItems: SelectItem[] = choices.map((c) => ({
@@ -182,32 +238,12 @@ export function showOverlay(
182
238
  description: c.description,
183
239
  }));
184
240
 
185
- const selectList = new SelectList(selectItems, selectItems.length, {
186
- selectedPrefix: (t) => theme.fg(accent, t),
187
- selectedText: (t) => theme.fg(accent, t),
188
- description: (t) => theme.fg("muted", t),
189
- scrollInfo: (t) => theme.fg("dim", t),
190
- noMatch: (t) => theme.fg("warning", t),
191
- });
192
-
193
- const maskedInput = new MaskedInput();
194
- const passwordHint = new Text("", 1, 0);
195
- const helpText = new Text("", 1, 0);
196
- const countdownText = new Text("", 1, 0);
197
-
198
- // ── container ───────────────────────────────────────────────────
199
- // padding: 2 cols horizontal, 1 row vertical around all dialog content
200
- const container = new Box(2, 1, (s) => theme.bg("customMessageBg", s));
201
- container.addChild(
202
- new DynamicBorder((s: string) => theme.fg(accent, s)),
241
+ const selectList = new SelectList(
242
+ selectItems,
243
+ selectItems.length,
244
+ selectListTheme(theme, accent),
203
245
  );
204
- container.addChild(
205
- new Text(theme.fg(accent, theme.bold(config.title)), 1, 0),
206
- );
207
-
208
- for (const line of config.body ?? []) {
209
- container.addChild(new Text(theme.fg("text", line), 1, 0));
210
- }
246
+ const maskedInput = new MaskedInput();
211
247
 
212
248
  // ── countdown ───────────────────────────────────────────────────
213
249
  let ticker: ReturnType<typeof setInterval> | undefined;
@@ -218,64 +254,34 @@ export function showOverlay(
218
254
  0,
219
255
  Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
220
256
  );
221
- countdownText.setText(
257
+ countdownLine =
222
258
  theme.fg("dim", "Auto-deny in ") +
223
- theme.fg(
224
- remaining <= COUNTDOWN_WARN_S ? accent : "muted",
225
- `${remaining}s`,
226
- ),
227
- );
259
+ theme.fg(
260
+ remaining <= COUNTDOWN_WARN_S ? accent : "muted",
261
+ `${remaining}s`,
262
+ );
228
263
  };
229
264
  updateCountdown();
230
265
  ticker = setInterval(() => {
231
266
  updateCountdown();
232
267
  tui.requestRender();
233
268
  }, SECOND_MS);
234
- container.addChild(countdownText);
235
269
  }
236
270
 
237
- // ── select stage (always starts here) ───────────────────────────
238
- helpText.setText(
239
- theme.fg("dim", "↑↓ navigate • enter select • esc deny"),
240
- );
241
- container.addChild(selectList);
242
- container.addChild(helpText);
243
- container.addChild(
244
- new DynamicBorder((s: string) => theme.fg(accent, s)),
245
- );
246
-
247
- // ── finish helper ────────────────────────────────────────────────
271
+ // ── finish ───────────────────────────────────────────────────────
248
272
  const finish = (result: OverlayResult) => {
249
273
  if (timer !== undefined) clearTimeout(timer);
250
274
  if (ticker !== undefined) clearInterval(ticker);
251
275
  done(result);
252
276
  };
253
277
 
254
- // ── stage: password (sudo mode only) ────────────────────────────
255
- const switchToPassword = () => {
256
- stage = "password";
257
- container.removeChild(selectList);
258
- container.removeChild(helpText);
259
-
260
- const label =
261
- config.mode === "sudo"
262
- ? (config.passwordLabel ?? "Sudo password:")
263
- : "Password:";
264
- passwordHint.setText(theme.fg("muted", label));
265
- container.addChild(passwordHint);
266
- container.addChild(maskedInput);
267
- container.addChild(
268
- new Text(theme.fg("dim", "enter confirm • esc cancel"), 1, 0),
269
- );
270
- tui.requestRender();
271
- };
272
-
273
278
  // ── event wiring ─────────────────────────────────────────────────
274
279
  selectList.onSelect = (item) => {
275
280
  if (item.value !== approveVal) {
276
281
  finish({ action: "denied" });
277
282
  } else if (config.mode === "sudo") {
278
- switchToPassword();
283
+ stage = "password";
284
+ tui.requestRender();
279
285
  } else {
280
286
  finish({ action: "approved" });
281
287
  }
@@ -292,8 +298,26 @@ export function showOverlay(
292
298
 
293
299
  // ── component interface ──────────────────────────────────────────
294
300
  return {
295
- render: (w) => container.render(w),
296
- invalidate: () => container.invalidate(),
301
+ render: (w) => {
302
+ const mw = modalWidth(w);
303
+ const lines = buildLines({
304
+ theme,
305
+ accent,
306
+ config,
307
+ stage,
308
+ selectList,
309
+ maskedInput,
310
+ countdownLine,
311
+ width: mw,
312
+ });
313
+ return frameLines({
314
+ width: mw,
315
+ lines,
316
+ color: (s) => theme.fg(accent, s),
317
+ bg: (s) => theme.bg("customMessageBg", s),
318
+ });
319
+ },
320
+ invalidate: () => {},
297
321
  handleInput: (data) => {
298
322
  if (stage === "select") selectList.handleInput(data);
299
323
  else maskedInput.handleInput(data);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * pix-pretty/modal-frame — shared primitives for interactive overlay UIs.
3
+ *
4
+ * Provides:
5
+ * frameLines() — render a rounded bordered modal box (╭─╮╰─╯)
6
+ * modalWidth() — clamp terminal width to a sane modal width
7
+ * selectListTheme() — canonical SelectList theme config (accent + muted + dim)
8
+ *
9
+ * Used by: gate-overlay, confirm, and (via re-export) pix-ask.
10
+ */
11
+
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ // ── Constants ─────────────────────────────────────────────────────────────────
15
+
16
+ const MIN_WIDTH = 40;
17
+ const MAX_WIDTH = 96;
18
+ const MARGIN = 4;
19
+ /** 2 border cols + 2 padding spaces */
20
+ const CHROME = 4;
21
+
22
+ // ── Width ─────────────────────────────────────────────────────────────────────
23
+
24
+ /** Clamp terminal width to a sane modal width (40–96 cols). */
25
+ export function modalWidth(termWidth: number): number {
26
+ return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, termWidth - MARGIN));
27
+ }
28
+
29
+ // ── Frame ─────────────────────────────────────────────────────────────────────
30
+
31
+ export interface FrameOptions {
32
+ width: number;
33
+ lines: string[];
34
+ /** Color function for border glyphs — e.g. `(s) => theme.fg("accent", s)` */
35
+ color: (s: string) => string;
36
+ /** Background fill function — e.g. `(s) => theme.bg("customMessageBg", s)` */
37
+ bg?: (s: string) => string;
38
+ /** Optional pre-styled string rendered as the first content row (tab bar etc.) */
39
+ top?: string;
40
+ }
41
+
42
+ /**
43
+ * Render a rounded modal box.
44
+ *
45
+ * Returns an array of full-width ANSI strings:
46
+ * ╭──────────────────╮
47
+ * │ [top row] │ ← only if top is set
48
+ * │ content line 1 │
49
+ * │ content line 2 │
50
+ * ╰──────────────────╯
51
+ *
52
+ * Solid background fill — theme fg/bold spans that emit \x1b[0m are patched
53
+ * so the background colour is re-asserted, preventing transparent holes.
54
+ */
55
+ export function frameLines(opts: FrameOptions): string[] {
56
+ const { width, lines, color, top } = opts;
57
+ const bg = opts.bg ?? ((s: string) => s);
58
+ const inner = Math.max(1, width - CHROME);
59
+ const dashes = "─".repeat(width - 2);
60
+
61
+ // Derive the bg OPEN sequence so we can re-assert it after any full reset
62
+ // (\x1b[0m) or bg reset (\x1b[49m) embedded in content.
63
+ const SENTINEL = "\x00";
64
+ const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
65
+ const reassert = (s: string): string =>
66
+ bgOpen
67
+ ? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
68
+ p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
69
+ )
70
+ : s;
71
+
72
+ const row = (content: string): string => {
73
+ const pad = inner - visibleWidth(content);
74
+ const padded =
75
+ pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
76
+ return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
77
+ };
78
+
79
+ const out: string[] = [bg(color(`╭${dashes}╮`))];
80
+ if (top !== undefined) out.push(row(top));
81
+ for (const line of lines) out.push(row(line));
82
+ out.push(bg(color(`╰${dashes}╯`)));
83
+ return out;
84
+ }
85
+
86
+ // ── SelectList theme ──────────────────────────────────────────────────────────
87
+
88
+ export interface SelectListThemeConfig {
89
+ selectedPrefix: (t: string) => string;
90
+ selectedText: (t: string) => string;
91
+ description: (t: string) => string;
92
+ scrollInfo: (t: string) => string;
93
+ noMatch: (t: string) => string;
94
+ }
95
+
96
+ interface FgTheme {
97
+ fg(color: string, text: string): string;
98
+ }
99
+
100
+ /**
101
+ * Canonical SelectList theme for interactive overlays.
102
+ * accent = active/selected, muted = descriptions, dim = scroll/hints, warning = no-match.
103
+ */
104
+ export function selectListTheme(
105
+ theme: FgTheme,
106
+ accent = "accent",
107
+ ): SelectListThemeConfig {
108
+ return {
109
+ selectedPrefix: (t) => theme.fg(accent, t),
110
+ selectedText: (t) => theme.fg(accent, t),
111
+ description: (t) => theme.fg("muted", t),
112
+ scrollInfo: (t) => theme.fg("dim", t),
113
+ noMatch: (t) => theme.fg("warning", t),
114
+ };
115
+ }