@xynogen/pix-pretty 1.7.27 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.7.27",
3
+ "version": "1.8.1",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@xynogen/pix-data": "^0.4.0",
62
- "@xynogen/pix-runtime": "^0.3.0",
62
+ "@xynogen/pix-runtime": "^0.4.0",
63
63
  "chalk": "^4.1.2",
64
64
  "cli-highlight": "^2.1.11",
65
65
  "@ff-labs/fff-node": "^0.5.2",
package/src/confirm.ts CHANGED
@@ -5,8 +5,15 @@
5
5
  * as gate-overlay and pix-ask. Returns true on confirm, false on deny/timeout.
6
6
  */
7
7
 
8
- import { type SelectItem, SelectList } from "@earendil-works/pi-tui";
9
- import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
8
+ import { type KeybindingsManager, type SelectItem, SelectList } from "@earendil-works/pi-tui";
9
+ import {
10
+ frameModal,
11
+ MIN_PERMISSION_MODAL_HEIGHT,
12
+ ModalPager,
13
+ modalWidth,
14
+ selectListTheme,
15
+ terminalModalHeight,
16
+ } from "./modal-frame.js";
10
17
 
11
18
  // Minimal structural type for the `ctx.ui.custom` host call.
12
19
  interface CustomTheme {
@@ -25,12 +32,12 @@ interface CustomComponent {
25
32
  export interface ConfirmUI {
26
33
  custom<T>(
27
34
  cb: (
28
- tui: { requestRender(): void },
35
+ tui: { requestRender(): void; terminal?: { rows?: number } },
29
36
  theme: CustomTheme,
30
- kb: unknown,
37
+ kb: KeybindingsManager,
31
38
  done: (v: T) => void,
32
39
  ) => CustomComponent,
33
- opts?: { overlay?: boolean },
40
+ opts?: { overlay?: boolean; overlayOptions?: { maxHeight?: number | `${number}%` } },
34
41
  ): Promise<T | undefined>;
35
42
  }
36
43
 
@@ -64,9 +71,10 @@ export function confirmOverlay(ui: ConfirmUI, opts: ConfirmOptions): Promise<boo
64
71
  const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
65
72
 
66
73
  ui.custom<boolean>(
67
- (tui, theme, _kb, done) => {
74
+ (tui, theme, kb, done) => {
68
75
  let ticker: ReturnType<typeof setInterval> | undefined;
69
76
  let countdownLine: string | undefined;
77
+ const pager = new ModalPager();
70
78
 
71
79
  const choices: SelectItem[] = [
72
80
  {
@@ -112,43 +120,43 @@ export function confirmOverlay(ui: ConfirmUI, opts: ConfirmOptions): Promise<boo
112
120
  render: (w: number) => {
113
121
  const mw = modalWidth(w);
114
122
  const inner = mw - 4;
115
- const lines: string[] = [];
116
-
117
- // Title
118
- lines.push(theme.fg(accent, theme.bold(opts.title)));
119
-
120
- // Body
121
- for (const line of opts.body ?? []) {
122
- lines.push(theme.fg("text", line));
123
- }
124
-
125
- // Divider
126
- lines.push(theme.fg("dim", "─".repeat(inner)));
127
-
128
- // Countdown
129
- if (countdownLine !== undefined) lines.push(countdownLine);
130
-
131
- // Select list
132
- for (const l of selectList.render(inner)) lines.push(l);
133
-
134
- lines.push("");
135
- lines.push(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"));
136
-
137
- return frameLines({
123
+ const footer = [theme.fg("dim", "─".repeat(inner))];
124
+ if (countdownLine !== undefined) footer.push(countdownLine);
125
+ footer.push(...selectList.render(inner));
126
+ footer.push("");
127
+ footer.push(
128
+ theme.fg("dim", "↑↓ choose • ←→/PgUp/PgDn inspect • enter select • esc cancel"),
129
+ );
130
+
131
+ const result = frameModal({
138
132
  width: mw,
139
- lines,
133
+ maxHeight: terminalModalHeight(tui.terminal?.rows),
134
+ minHeight: MIN_PERMISSION_MODAL_HEIGHT,
135
+ header: [theme.fg(accent, theme.bold(opts.title))],
136
+ body: (opts.body ?? []).map((line) => theme.fg("text", line)),
137
+ footer,
138
+ bodyOffset: pager.bodyOffset,
140
139
  color: (s) => theme.fg(accent, s),
141
140
  bg: (s) => theme.bg("customMessageBg", s),
141
+ fg: (s) => theme.fg("text", s),
142
+ overflowLine: ({ page, totalPages }) =>
143
+ theme.fg("dim", `←→/PgUp/PgDn inspect • ${page}/${totalPages}`),
142
144
  });
145
+ pager.sync(result);
146
+ return result.lines;
143
147
  },
144
148
  invalidate: () => {},
145
149
  handleInput: (data: string) => {
150
+ if (pager.handleInput(data, kb, true)) {
151
+ tui.requestRender();
152
+ return;
153
+ }
146
154
  selectList.handleInput(data);
147
155
  tui.requestRender();
148
156
  },
149
157
  };
150
158
  },
151
- { overlay: true },
159
+ { overlay: true, overlayOptions: { maxHeight: "80%" } },
152
160
  ).then((result) => {
153
161
  if (timer !== undefined) clearTimeout(timer);
154
162
  resolve(result ?? false);
@@ -80,16 +80,25 @@ export interface DiffColors {
80
80
  bgGutterDel: string;
81
81
  }
82
82
 
83
+ // Faint row tints for changed lines. Kept dark enough to sit under syntax
84
+ // highlighting without washing it out, but visible enough that a scanned diff
85
+ // reads as add/remove bands rather than gutter chips alone.
86
+ const BG_ADD_FAINT = "\x1b[48;2;18;42;28m";
87
+ const BG_DEL_FAINT = "\x1b[48;2;48;22;26m";
88
+ /** Intra-line word-diff emphasis — one step brighter than the row tint. */
89
+ const BG_ADD_STRONG = "\x1b[48;2;28;72;44m";
90
+ const BG_DEL_STRONG = "\x1b[48;2;82;32;38m";
91
+
83
92
  export const DEFAULT_DIFF_COLORS: DiffColors = {
84
93
  fgAdd: FG_ADD,
85
94
  fgDel: FG_DEL,
86
95
  fgCtx: FG_DIM,
87
- bgAdd: BG_BASE,
88
- bgDel: BG_BASE,
89
- bgAddHighlight: BG_BASE,
90
- bgDelHighlight: BG_BASE,
91
- bgGutterAdd: BG_BASE,
92
- bgGutterDel: BG_BASE,
96
+ bgAdd: BG_ADD_FAINT,
97
+ bgDel: BG_DEL_FAINT,
98
+ bgAddHighlight: BG_ADD_STRONG,
99
+ bgDelHighlight: BG_DEL_STRONG,
100
+ bgGutterAdd: BG_ADD_FAINT,
101
+ bgGutterDel: BG_DEL_FAINT,
93
102
  };
94
103
 
95
104
  type DiffTheme = {
@@ -112,12 +121,12 @@ export function resolveDiffColors(theme?: DiffTheme): DiffColors {
112
121
  fgDel: themeFg(theme, "toolDiffRemoved", FG_DEL),
113
122
  fgCtx: themeFg(theme, "toolDiffContext", FG_DIM),
114
123
  theme: typeof theme.fg === "function" ? (theme as FgTheme) : undefined,
115
- bgAdd: BG_BASE,
116
- bgDel: BG_BASE,
117
- bgAddHighlight: BG_BASE,
118
- bgDelHighlight: BG_BASE,
119
- bgGutterAdd: BG_BASE,
120
- bgGutterDel: BG_BASE,
124
+ bgAdd: BG_ADD_FAINT,
125
+ bgDel: BG_DEL_FAINT,
126
+ bgAddHighlight: BG_ADD_STRONG,
127
+ bgDelHighlight: BG_DEL_STRONG,
128
+ bgGutterAdd: BG_ADD_FAINT,
129
+ bgGutterDel: BG_DEL_FAINT,
121
130
  };
122
131
  }
123
132
 
package/src/diff.test.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { parseDiff } from "./diff.js";
3
3
  import {
4
+ DEFAULT_DIFF_COLORS,
4
5
  diffThemeCacheKey,
5
6
  renderDiffSummary,
6
7
  renderUnified,
@@ -22,20 +23,34 @@ describe("theme-derived diff rendering", () => {
22
23
  },
23
24
  };
24
25
 
25
- it("uses semantic foregrounds without tint backgrounds", () => {
26
+ it("uses semantic foregrounds", () => {
26
27
  const colors = resolveDiffColors(theme);
27
28
  expect(colors.fgAdd).toBe("\x1b[38;2;120;210;150m");
28
29
  expect(colors.fgDel).toBe("\x1b[38;2;230;120;130m");
29
30
  expect(colors.fgCtx).toBe("\x1b[38;2;130;140;150m");
30
- for (const key of [
31
- "bgAdd",
32
- "bgDel",
33
- "bgAddHighlight",
34
- "bgDelHighlight",
35
- "bgGutterAdd",
36
- "bgGutterDel",
37
- ] as const) {
38
- expect(colors[key]).toBe("\x1b[49m");
31
+ });
32
+
33
+ // Regression: these six slots were all set to BG_BASE (\x1b[49m), which
34
+ // dropped the faint green/red row tint and left only the gutter chips
35
+ // colored. A diff must read as add/remove bands at a glance.
36
+ it("gives changed rows a faint tint background", () => {
37
+ for (const colors of [DEFAULT_DIFF_COLORS, resolveDiffColors(theme)]) {
38
+ for (const key of [
39
+ "bgAdd",
40
+ "bgDel",
41
+ "bgAddHighlight",
42
+ "bgDelHighlight",
43
+ "bgGutterAdd",
44
+ "bgGutterDel",
45
+ ] as const) {
46
+ expect(colors[key]).not.toBe("\x1b[49m");
47
+ expect(colors[key]).toMatch(/^\x1b\[48;2;\d+;\d+;\d+m$/);
48
+ }
49
+ // Word-diff emphasis must be distinguishable from the row tint.
50
+ expect(colors.bgAddHighlight).not.toBe(colors.bgAdd);
51
+ expect(colors.bgDelHighlight).not.toBe(colors.bgDel);
52
+ // Add and remove must never collide.
53
+ expect(colors.bgAdd).not.toBe(colors.bgDel);
39
54
  }
40
55
  });
41
56
 
@@ -53,7 +68,19 @@ describe("theme-derived diff rendering", () => {
53
68
  );
54
69
  });
55
70
 
56
- it("keeps the foreground-only diff style", async () => {
71
+ it("emits tint backgrounds on changed rows", async () => {
72
+ const rendered = await renderUnified(
73
+ parseDiff("const oldValue = 1;", "const newValue = 2;"),
74
+ "typescript",
75
+ 80,
76
+ resolveDiffColors({ ...theme, fg: (_key, text) => text }),
77
+ );
78
+ const { bgAdd, bgDel } = resolveDiffColors(theme);
79
+ expect(rendered).toContain(bgDel);
80
+ expect(rendered).toContain(bgAdd);
81
+ });
82
+
83
+ it("keeps gutter numbering and rule layout intact", async () => {
57
84
  const rendered = await renderUnified(
58
85
  parseDiff("const oldValue = 1;", "const newValue = 2;"),
59
86
  "typescript",
@@ -69,7 +96,6 @@ describe("theme-derived diff rendering", () => {
69
96
  expect(lines[3]).toMatch(/^─+$/);
70
97
  expect(rendered).toContain(theme.getFgAnsi("toolDiffRemoved"));
71
98
  expect(rendered).toContain(theme.getFgAnsi("toolDiffAdded"));
72
- expect(rendered).not.toMatch(/\x1b\[48(?:;[^m]*)?m/);
73
99
  });
74
100
  });
75
101
 
@@ -14,8 +14,16 @@
14
14
  * - Single source of truth for the overlay look across pix-gate and pix-sudo.
15
15
  */
16
16
 
17
- import { Input, type SelectItem, SelectList, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
- import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
17
+ import { Input, type SelectItem, SelectList } from "@earendil-works/pi-tui";
18
+ import {
19
+ frameModal,
20
+ MIN_PERMISSION_MODAL_HEIGHT,
21
+ type ModalPageKeybindings,
22
+ ModalPager,
23
+ modalWidth,
24
+ selectListTheme,
25
+ terminalModalHeight,
26
+ } from "./modal-frame.js";
19
27
 
20
28
  // ── Types ─────────────────────────────────────────────────────────────────────
21
29
 
@@ -83,6 +91,7 @@ interface OverlayTheme {
83
91
 
84
92
  interface OverlayTui {
85
93
  requestRender(): void;
94
+ terminal?: { rows?: number };
86
95
  }
87
96
 
88
97
  interface OverlayComponent {
@@ -111,6 +120,13 @@ const DEFAULT_CHOICES: OverlayChoice[] = [
111
120
  { value: "no", label: "Deny", description: "Block" },
112
121
  ];
113
122
 
123
+ function asPageKeybindings(value: unknown): ModalPageKeybindings | undefined {
124
+ if (!value || typeof value !== "object") return undefined;
125
+ return typeof (value as { matches?: unknown }).matches === "function"
126
+ ? (value as ModalPageKeybindings)
127
+ : undefined;
128
+ }
129
+
114
130
  // ── Masked input (● per char) ─────────────────────────────────────────────────
115
131
 
116
132
  class MaskedInput extends Input {
@@ -125,8 +141,14 @@ class MaskedInput extends Input {
125
141
 
126
142
  // ── Renderer ──────────────────────────────────────────────────────────────────
127
143
 
128
- /** Build body lines for the current stage, rendered into frameLines. */
129
- function buildLines(opts: {
144
+ interface OverlaySections {
145
+ header: string[];
146
+ body: string[];
147
+ footer: string[];
148
+ }
149
+
150
+ /** Partition inspectable content from pinned approval controls. */
151
+ function buildSections(opts: {
130
152
  theme: OverlayTheme;
131
153
  accent: string;
132
154
  config: OverlayConfig;
@@ -136,7 +158,7 @@ function buildLines(opts: {
136
158
  countdownLine: string | undefined;
137
159
  passwordStatus: string | undefined;
138
160
  width: number;
139
- }): string[] {
161
+ }): OverlaySections {
140
162
  const {
141
163
  theme,
142
164
  accent,
@@ -149,42 +171,31 @@ function buildLines(opts: {
149
171
  width,
150
172
  } = opts;
151
173
  const inner = width - 4; // CHROME = 2 border + 2 padding
152
- const lines: string[] = [];
153
-
154
- // Title wrap so a long reason/command isn't truncated by the frame.
155
- for (const t of wrapTextWithAnsi(config.title, inner)) {
156
- lines.push(theme.fg(accent, theme.bold(t)));
157
- }
158
-
159
- // Body — wrap each line so long commands wrap instead of getting cut off.
160
- for (const line of config.body ?? []) {
161
- const wrapped = line === "" ? [""] : wrapTextWithAnsi(line, inner);
162
- for (const w of wrapped) lines.push(theme.fg("text", w));
163
- }
164
-
165
- // Divider after title/body
166
- lines.push(theme.fg("dim", "─".repeat(inner)));
174
+ const header = [theme.fg(accent, theme.bold(config.title))];
175
+ const body = (config.body ?? []).map((line) => {
176
+ if (line.startsWith("Intent:")) return theme.fg("text", line.slice(7).trimStart());
177
+ if (line.startsWith("Command:")) return theme.fg("dim", line.slice(8).trimStart());
178
+ return theme.fg("text", line);
179
+ });
180
+ const footer = [theme.fg("dim", "─".repeat(inner))];
167
181
 
168
- // Countdown
169
- if (countdownLine !== undefined) lines.push(countdownLine);
182
+ if (countdownLine !== undefined) footer.push(countdownLine);
170
183
 
171
184
  // Select or password stage
172
185
  if (stage === "select") {
173
- const listLines = selectList.render(inner);
174
- for (const l of listLines) lines.push(l);
175
- lines.push("");
176
- lines.push(theme.fg("dim", "↑↓ navigate • enter select • esc deny"));
186
+ footer.push(...selectList.render(inner));
187
+ footer.push("");
188
+ footer.push(theme.fg("dim", "↑↓ choose • ←→/PgUp/PgDn inspect • enter select • esc deny"));
177
189
  } else {
178
190
  const label = config.mode === "sudo" ? (config.passwordLabel ?? "Sudo password:") : "Password:";
179
- lines.push(theme.fg("muted", label));
180
- if (passwordStatus) lines.push(theme.fg("error", passwordStatus));
181
- const inputLines = maskedInput.render(inner);
182
- for (const l of inputLines) lines.push(l);
183
- lines.push("");
184
- lines.push(theme.fg("dim", "enter confirm • esc cancel"));
191
+ footer.push(theme.fg("muted", label));
192
+ if (passwordStatus) footer.push(theme.fg("error", passwordStatus));
193
+ footer.push(...maskedInput.render(inner));
194
+ footer.push("");
195
+ footer.push(theme.fg("dim", "←→/PgUp/PgDn inspect • enter confirm • esc cancel"));
185
196
  }
186
197
 
187
- return lines;
198
+ return { header, body, footer };
188
199
  }
189
200
 
190
201
  // ── Main ──────────────────────────────────────────────────────────────────────
@@ -225,13 +236,15 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
225
236
 
226
237
  return new Promise((resolve) => {
227
238
  ui.custom<OverlayResult>(
228
- (tui, theme, _kb, done) => {
239
+ (tui, theme, kb, done) => {
240
+ const pageKeybindings = asPageKeybindings(kb);
229
241
  type Stage = "select" | "password";
230
242
  let stage: Stage = "select";
231
243
  let countdownLine: string | undefined;
232
244
  let passwordStatus: string | undefined;
233
245
  let passwordAttempts = 0;
234
246
  let validatingPassword = false;
247
+ const pager = new ModalPager();
235
248
 
236
249
  // Dead-man's-switch timer: counts down only while untouched. The
237
250
  // first keypress cancels it (user is present → let them decide). If
@@ -267,14 +280,21 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
267
280
 
268
281
  // Arm the dead-man's switch (only when a timeout was requested).
269
282
  if (timeoutMs > 0) {
270
- countdownLine = theme.fg("dim", `auto-deny in ${remaining}s`);
283
+ const urgencyColor = (s: number): string =>
284
+ s <= 5 ? "error" : s <= 15 ? "warning" : "dim";
285
+ const countdownText = (s: number): string => {
286
+ const color = urgencyColor(s);
287
+ const text = `auto-deny in ${s}s`;
288
+ return s <= 5 ? theme.bold(theme.fg(color, text)) : theme.fg(color, text);
289
+ };
290
+ countdownLine = countdownText(remaining);
271
291
  timer = setInterval(() => {
272
292
  remaining -= 1;
273
293
  if (remaining <= 0) {
274
294
  finish({ action: "timeout" });
275
295
  return;
276
296
  }
277
- countdownLine = theme.fg("dim", `auto-deny in ${remaining}s`);
297
+ countdownLine = countdownText(remaining);
278
298
  tui.requestRender();
279
299
  }, 1000);
280
300
  }
@@ -327,7 +347,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
327
347
  return {
328
348
  render: (w) => {
329
349
  const mw = modalWidth(w);
330
- const lines = buildLines({
350
+ const sections = buildSections({
331
351
  theme,
332
352
  accent,
333
353
  config,
@@ -338,17 +358,29 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
338
358
  passwordStatus,
339
359
  width: mw,
340
360
  });
341
- return frameLines({
361
+ const result = frameModal({
342
362
  width: mw,
343
- lines,
363
+ maxHeight: terminalModalHeight(tui.terminal?.rows),
364
+ minHeight: MIN_PERMISSION_MODAL_HEIGHT,
365
+ ...sections,
366
+ bodyOffset: pager.bodyOffset,
344
367
  color: (s) => theme.fg(accent, s),
345
368
  bg: (s) => theme.bg("customMessageBg", s),
369
+ fg: (s) => theme.fg("text", s),
370
+ overflowLine: ({ page, totalPages }) =>
371
+ theme.fg("dim", `←→/PgUp/PgDn inspect • ${page}/${totalPages}`),
346
372
  });
373
+ pager.sync(result);
374
+ return result.lines;
347
375
  },
348
376
  invalidate: () => {},
349
377
  handleInput: (data) => {
350
378
  cancelTimer(); // user is present — stop the auto-deny countdown
351
379
  if (validatingPassword) return;
380
+ if (pager.handleInput(data, pageKeybindings, true)) {
381
+ tui.requestRender();
382
+ return;
383
+ }
352
384
  if (stage === "select") selectList.handleInput(data);
353
385
  else maskedInput.handleInput(data);
354
386
  tui.requestRender();