@xynogen/pix-pretty 1.7.17 → 1.7.19

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.17",
3
+ "version": "1.7.19",
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",
@@ -160,6 +160,44 @@ describe("showOverlay — sudo mode", () => {
160
160
  expect(result.password).toBeUndefined();
161
161
  });
162
162
 
163
+ test("wrong password retries inside the same overlay", async () => {
164
+ let component: Wired | undefined;
165
+ let overlayCount = 0;
166
+ const attempts: string[] = [];
167
+ const ui: OverlayUI = {
168
+ custom: <T>(cb: Parameters<OverlayUI["custom"]>[0]): Promise<T | undefined> => {
169
+ overlayCount += 1;
170
+ return new Promise((resolve) => {
171
+ component = cb({ requestRender: () => {} }, theme, undefined, (value) =>
172
+ resolve(value as T),
173
+ );
174
+ });
175
+ },
176
+ };
177
+
178
+ const pending = showOverlay(ui, {
179
+ mode: "sudo",
180
+ title: "ROOT",
181
+ timeoutMs: 0,
182
+ maxPasswordAttempts: 3,
183
+ validatePassword: async (password) => {
184
+ attempts.push(password);
185
+ return password === "correct";
186
+ },
187
+ });
188
+ component?.handleInput(ENTER);
189
+ component?.handleInput("wrong");
190
+ component?.handleInput(ENTER);
191
+ await new Promise((resolve) => setTimeout(resolve, 0));
192
+ expect(component?.render(80).join("\n")).toContain("Incorrect password — attempt 1 of 3");
193
+ component?.handleInput("correct");
194
+ component?.handleInput(ENTER);
195
+
196
+ expect(await pending).toEqual({ action: "approved", password: "correct" });
197
+ expect(attempts).toEqual(["wrong", "correct"]);
198
+ expect(overlayCount).toBe(1);
199
+ });
200
+
163
201
  test("password is masked in render (● not plaintext)", async () => {
164
202
  let pwFrame: string[] = [];
165
203
  await showOverlay(
@@ -25,6 +25,8 @@ export interface OverlayResult {
25
25
  action: OverlayAction;
26
26
  /** Only present when action === "approved" and mode === "sudo". */
27
27
  password?: string;
28
+ /** True when password validation exhausted every allowed attempt. */
29
+ passwordAttemptsExhausted?: boolean;
28
30
  }
29
31
 
30
32
  export interface OverlayChoice {
@@ -64,6 +66,10 @@ export interface SudoConfig extends BaseConfig {
64
66
  mode: "sudo";
65
67
  /** Label for the password input hint. Default "Sudo password:" */
66
68
  passwordLabel?: string;
69
+ /** Validate an entered password without closing the overlay. */
70
+ validatePassword?: (password: string) => Promise<boolean>;
71
+ /** Number of validation attempts before closing as exhausted. Default 3. */
72
+ maxPasswordAttempts?: number;
67
73
  }
68
74
 
69
75
  export type OverlayConfig = ConfirmConfig | SudoConfig;
@@ -128,9 +134,20 @@ function buildLines(opts: {
128
134
  selectList: SelectList;
129
135
  maskedInput: MaskedInput;
130
136
  countdownLine: string | undefined;
137
+ passwordStatus: string | undefined;
131
138
  width: number;
132
139
  }): string[] {
133
- const { theme, accent, config, stage, selectList, maskedInput, countdownLine, width } = opts;
140
+ const {
141
+ theme,
142
+ accent,
143
+ config,
144
+ stage,
145
+ selectList,
146
+ maskedInput,
147
+ countdownLine,
148
+ passwordStatus,
149
+ width,
150
+ } = opts;
134
151
  const inner = width - 4; // CHROME = 2 border + 2 padding
135
152
  const lines: string[] = [];
136
153
 
@@ -160,6 +177,7 @@ function buildLines(opts: {
160
177
  } else {
161
178
  const label = config.mode === "sudo" ? (config.passwordLabel ?? "Sudo password:") : "Password:";
162
179
  lines.push(theme.fg("muted", label));
180
+ if (passwordStatus) lines.push(theme.fg("error", passwordStatus));
163
181
  const inputLines = maskedInput.render(inner);
164
182
  for (const l of inputLines) lines.push(l);
165
183
  lines.push("");
@@ -211,6 +229,9 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
211
229
  type Stage = "select" | "password";
212
230
  let stage: Stage = "select";
213
231
  let countdownLine: string | undefined;
232
+ let passwordStatus: string | undefined;
233
+ let passwordAttempts = 0;
234
+ let validatingPassword = false;
214
235
 
215
236
  // Dead-man's-switch timer: counts down only while untouched. The
216
237
  // first keypress cancels it (user is present → let them decide). If
@@ -271,8 +292,36 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
271
292
  };
272
293
  selectList.onCancel = () => finish({ action: "denied" });
273
294
 
274
- maskedInput.onSubmit = (pw) => finish({ action: "approved", password: pw });
275
- maskedInput.onEscape = () => finish({ action: "denied" });
295
+ maskedInput.onSubmit = async (pw) => {
296
+ if (config.mode !== "sudo" || !config.validatePassword) {
297
+ finish({ action: "approved", password: pw });
298
+ return;
299
+ }
300
+ if (!pw.trim() || validatingPassword) return;
301
+
302
+ validatingPassword = true;
303
+ passwordStatus = "Checking password…";
304
+ tui.requestRender();
305
+ const valid = await config.validatePassword(pw);
306
+ validatingPassword = false;
307
+ if (valid) {
308
+ finish({ action: "approved", password: pw });
309
+ return;
310
+ }
311
+
312
+ passwordAttempts += 1;
313
+ const maxAttempts = config.maxPasswordAttempts ?? 3;
314
+ if (passwordAttempts >= maxAttempts) {
315
+ finish({ action: "approved", password: pw, passwordAttemptsExhausted: true });
316
+ return;
317
+ }
318
+ maskedInput.setValue("");
319
+ passwordStatus = `Incorrect password — attempt ${passwordAttempts} of ${maxAttempts}`;
320
+ tui.requestRender();
321
+ };
322
+ maskedInput.onEscape = () => {
323
+ if (!validatingPassword) finish({ action: "denied" });
324
+ };
276
325
 
277
326
  // ── component interface ──────────────────────────────────────────
278
327
  return {
@@ -286,6 +335,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
286
335
  selectList,
287
336
  maskedInput,
288
337
  countdownLine,
338
+ passwordStatus,
289
339
  width: mw,
290
340
  });
291
341
  return frameLines({
@@ -298,6 +348,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
298
348
  invalidate: () => {},
299
349
  handleInput: (data) => {
300
350
  cancelTimer(); // user is present — stop the auto-deny countdown
351
+ if (validatingPassword) return;
301
352
  if (stage === "select") selectList.handleInput(data);
302
353
  else maskedInput.handleInput(data);
303
354
  tui.requestRender();
package/src/utils.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
 
3
3
  import { MAX_PREVIEW_LINES } from "./config.js";
4
4
  import type { FgTheme } from "./types.js";
5
- import { pluralize, renderDimPreview } from "./utils.js";
5
+ import { pluralize, renderDimPreview, setResultDetails } from "./utils.js";
6
6
 
7
7
  // Strip ANSI escapes so assertions test content, not color codes.
8
8
  const ANSI = /\x1b\[[0-9;]*m/g;
@@ -29,6 +29,27 @@ describe("pluralize", () => {
29
29
  });
30
30
  });
31
31
 
32
+ describe("setResultDetails", () => {
33
+ it("preserves upstream metadata while adding renderer details", () => {
34
+ const result = {
35
+ content: [{ type: "text" as const, text: "output" }],
36
+ details: {
37
+ truncation: { truncated: true, totalLines: 500 },
38
+ fullOutputPath: "/tmp/full.log",
39
+ },
40
+ };
41
+
42
+ setResultDetails(result, { _type: "bashResult", exitCode: 0 });
43
+
44
+ expect(result.details as Record<string, unknown>).toEqual({
45
+ truncation: { truncated: true, totalLines: 500 },
46
+ fullOutputPath: "/tmp/full.log",
47
+ _type: "bashResult",
48
+ exitCode: 0,
49
+ });
50
+ });
51
+ });
52
+
32
53
  describe("renderDimPreview", () => {
33
54
  it("renders 'done' for empty input", () => {
34
55
  expect(plain(renderDimPreview("", theme))).toContain("done");
@@ -79,7 +100,9 @@ describe("renderDimPreview", () => {
79
100
  expect(plain(raw)).toContain("foo bar foo");
80
101
  });
81
102
 
82
- it("does not throw on an invalid highlight regex", () => {
83
- expect(() => renderDimPreview("text", theme, { highlight: "(" })).not.toThrow();
103
+ it("treats regex metacharacters as literal highlight text", () => {
104
+ const raw = renderDimPreview("call(foo)", theme, { highlight: "(" });
105
+ expect(plain(raw)).toContain("call(foo)");
106
+ expect(raw).toContain("\x1b[");
84
107
  });
85
108
  });
package/src/utils.ts CHANGED
@@ -59,21 +59,23 @@ export type DimPreviewOptions = {
59
59
  highlight?: string;
60
60
  };
61
61
 
62
- function safeHighlightRegex(pattern: string): RegExp | null {
63
- try {
64
- return new RegExp(`(${pattern})`, "gi");
65
- } catch {
66
- return null;
62
+ function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
63
+ if (!pattern) return theme.fg("dim", line);
64
+ const foldedLine = line.toLocaleLowerCase();
65
+ const foldedPattern = pattern.toLocaleLowerCase();
66
+ if (!foldedPattern) return theme.fg("dim", line);
67
+
68
+ const parts: string[] = [];
69
+ let start = 0;
70
+ for (;;) {
71
+ const match = foldedLine.indexOf(foldedPattern, start);
72
+ if (match < 0) break;
73
+ if (match > start) parts.push(theme.fg("dim", line.slice(start, match)));
74
+ parts.push(`${FG_GREEN}${BOLD}${line.slice(match, match + pattern.length)}${RST}`);
75
+ start = match + pattern.length;
67
76
  }
68
- }
69
-
70
- function dimLineWithHighlight(line: string, theme: FgTheme, re: RegExp | null): string {
71
- if (!re) return theme.fg("dim", line);
72
- // split with capture group: odd indexes are matches
73
- return line
74
- .split(re)
75
- .map((part, i) => (i % 2 ? `${FG_GREEN}${BOLD}${part}${RST}` : theme.fg("dim", part)))
76
- .join("");
77
+ if (start < line.length) parts.push(theme.fg("dim", line.slice(start)));
78
+ return parts.length > 0 ? parts.join("") : theme.fg("dim", line);
77
79
  }
78
80
 
79
81
  export function renderDimPreview(
@@ -82,12 +84,12 @@ export function renderDimPreview(
82
84
  opts: DimPreviewOptions = {},
83
85
  ): string {
84
86
  const maxLines = opts.maxLines ?? MAX_PREVIEW_LINES;
85
- const re = opts.highlight ? safeHighlightRegex(opts.highlight) : null;
87
+ const highlight = opts.highlight;
86
88
  const output = normalizeLineEndings(text).trim() || "done";
87
89
  const lines = output.split("\n");
88
90
  const preview = lines
89
91
  .slice(0, maxLines)
90
- .map((line) => ` ${dimLineWithHighlight(line, theme, re)}`);
92
+ .map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
91
93
  if (opts.header) preview.unshift(` ${theme.fg("dim", opts.header)}`);
92
94
  if (lines.length > maxLines) {
93
95
  const more = pluralize(lines.length - maxLines, "more line");
@@ -208,8 +210,13 @@ export function getTextContent(result: ToolResultLike): string {
208
210
  );
209
211
  }
210
212
 
213
+ /** Add renderer metadata without discarding execution metadata from the upstream tool. */
211
214
  export function setResultDetails<T>(result: ToolResultLike, details: T): void {
212
- result.details = details;
215
+ const upstream =
216
+ result.details && typeof result.details === "object"
217
+ ? (result.details as Record<string, unknown>)
218
+ : undefined;
219
+ result.details = upstream ? { ...upstream, ...details } : details;
213
220
  }
214
221
 
215
222
  export function makeTextResult<TDetails>(