@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 +1 -1
- package/src/gate-overlay.test.ts +38 -0
- package/src/gate-overlay.ts +54 -3
- package/src/utils.test.ts +26 -3
- package/src/utils.ts +24 -17
package/package.json
CHANGED
package/src/gate-overlay.test.ts
CHANGED
|
@@ -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(
|
package/src/gate-overlay.ts
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
275
|
-
|
|
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("
|
|
83
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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>(
|