@xynogen/pix-pretty 1.7.4 → 1.7.6

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.4",
3
+ "version": "1.7.6",
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",
@@ -117,6 +117,23 @@ describe("showOverlay — confirm mode", () => {
117
117
  expect(joined).toContain("MY TITLE");
118
118
  expect(joined).toContain("body-line-x");
119
119
  });
120
+
121
+ test("wraps a long body command instead of truncating it", async () => {
122
+ // A command far wider than any modal width — must survive in full, wrapped.
123
+ const longCmd = `echo ${"pix-gate-installed-or-linked ".repeat(8)}done`;
124
+ let captured: string[] = [];
125
+ await showOverlay(
126
+ makeUI((comp) => {
127
+ captured = comp.render(80);
128
+ comp.handleInput(ENTER);
129
+ }),
130
+ { mode: "confirm", title: "T", body: [longCmd], timeoutMs: 0 },
131
+ );
132
+ // Every whitespace-delimited token of the command appears somewhere in the
133
+ // frame — nothing was dropped by truncation.
134
+ const joined = captured.join("\n");
135
+ for (const tok of longCmd.split(" ")) expect(joined).toContain(tok);
136
+ });
120
137
  });
121
138
 
122
139
  describe("showOverlay — sudo mode", () => {
@@ -161,3 +178,56 @@ describe("showOverlay — sudo mode", () => {
161
178
  expect(joined).toContain("●");
162
179
  });
163
180
  });
181
+
182
+ // ── Auto-deny timer (dead-man's switch) ───────────────────────────────────────
183
+ //
184
+ // Timer-aware mock: unlike makeUI, this keeps the promise pending and resolves
185
+ // only when `done` fires — so a real setInterval expiry can drive the result.
186
+ // `onReady` gets the live component to optionally feed input before expiry.
187
+ function makeTimerUI(onReady?: (comp: Wired) => void): OverlayUI {
188
+ return {
189
+ custom: <T>(
190
+ cb: (
191
+ tui: { requestRender(): void },
192
+ th: typeof theme,
193
+ kb: unknown,
194
+ done: (v: T) => void,
195
+ ) => Wired,
196
+ ): Promise<T | undefined> =>
197
+ new Promise((resolve) => {
198
+ const comp = cb({ requestRender: () => {} }, theme, undefined, (v) =>
199
+ resolve(v),
200
+ );
201
+ comp.render(80);
202
+ onReady?.(comp);
203
+ }),
204
+ };
205
+ }
206
+
207
+ describe("showOverlay — auto-deny timer", () => {
208
+ test("expires to timeout when left untouched", async () => {
209
+ const result = await showOverlay(makeTimerUI(), {
210
+ mode: "confirm",
211
+ title: "T",
212
+ timeoutMs: 1000, // ceil → 1s, fires on first tick
213
+ });
214
+ expect(result.action).toBe("timeout");
215
+ });
216
+
217
+ test("first keypress cancels the timer (no auto-deny)", async () => {
218
+ let live: Wired | undefined;
219
+ const pending = showOverlay(
220
+ makeTimerUI((comp) => {
221
+ live = comp;
222
+ comp.handleInput(DOWN); // any key — cancels the dead-man's switch
223
+ }),
224
+ { mode: "confirm", title: "T", timeoutMs: 1000 },
225
+ );
226
+ // Wait well past the 1s window. A live timer would have resolved "timeout";
227
+ // since the keypress cancelled it, the promise is still pending here.
228
+ await new Promise((r) => setTimeout(r, 1300));
229
+ live?.handleInput(ENTER); // now deny explicitly
230
+ const result = await pending;
231
+ expect(result.action).toBe("denied");
232
+ });
233
+ });
@@ -14,12 +14,17 @@
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 } from "@earendil-works/pi-tui";
17
+ import {
18
+ Input,
19
+ type SelectItem,
20
+ SelectList,
21
+ wrapTextWithAnsi,
22
+ } from "@earendil-works/pi-tui";
18
23
  import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
19
24
 
20
25
  // ── Types ─────────────────────────────────────────────────────────────────────
21
26
 
22
- export type OverlayAction = "approved" | "denied" | "timeout"; // ponytail: timeout kept for back-compat; never emitted now
27
+ export type OverlayAction = "approved" | "denied" | "timeout";
23
28
 
24
29
  export interface OverlayResult {
25
30
  action: OverlayAction;
@@ -40,7 +45,11 @@ interface BaseConfig {
40
45
  title: string;
41
46
  /** Optional body lines under the title. */
42
47
  body?: string[];
43
- /** @deprecated No-op — timeout removed. Dialog waits indefinitely for user input. */
48
+ /**
49
+ * Auto-deny after this many ms of NO user input (dead-man's switch). The
50
+ * first keypress cancels the timer and the dialog then waits indefinitely.
51
+ * 0 or omitted = no timer (wait forever). Resolves with action "timeout".
52
+ */
44
53
  timeoutMs?: number;
45
54
  /**
46
55
  * Choices shown in the SelectList.
@@ -139,12 +148,15 @@ function buildLines(opts: {
139
148
  const inner = width - 4; // CHROME = 2 border + 2 padding
140
149
  const lines: string[] = [];
141
150
 
142
- // Title
143
- lines.push(theme.fg(accent, theme.bold(config.title)));
151
+ // Title — wrap so a long reason/command isn't truncated by the frame.
152
+ for (const t of wrapTextWithAnsi(config.title, inner)) {
153
+ lines.push(theme.fg(accent, theme.bold(t)));
154
+ }
144
155
 
145
- // Body
156
+ // Body — wrap each line so long commands wrap instead of getting cut off.
146
157
  for (const line of config.body ?? []) {
147
- lines.push(theme.fg("text", line));
158
+ const wrapped = line === "" ? [""] : wrapTextWithAnsi(line, inner);
159
+ for (const w of wrapped) lines.push(theme.fg("text", w));
148
160
  }
149
161
 
150
162
  // Divider after title/body
@@ -220,6 +232,18 @@ export function showOverlay(
220
232
  let stage: Stage = "select";
221
233
  let countdownLine: string | undefined;
222
234
 
235
+ // Dead-man's-switch timer: counts down only while untouched. The
236
+ // first keypress cancels it (user is present → let them decide). If
237
+ // it expires with no input, auto-deny so the agent isn't stuck.
238
+ const timeoutMs = config.timeoutMs ?? 0;
239
+ let remaining = Math.ceil(timeoutMs / 1000);
240
+ let timer: ReturnType<typeof setInterval> | undefined;
241
+ const cancelTimer = () => {
242
+ if (timer) clearInterval(timer);
243
+ timer = undefined;
244
+ countdownLine = undefined;
245
+ };
246
+
223
247
  // ── components ──────────────────────────────────────────────────
224
248
  const selectItems: SelectItem[] = choices.map((c) => ({
225
249
  value: c.value,
@@ -235,7 +259,24 @@ export function showOverlay(
235
259
  const maskedInput = new MaskedInput();
236
260
 
237
261
  // ── finish ───────────────────────────────────────────────────────
238
- const finish = (result: OverlayResult) => done(result);
262
+ const finish = (result: OverlayResult) => {
263
+ cancelTimer();
264
+ done(result);
265
+ };
266
+
267
+ // Arm the dead-man's switch (only when a timeout was requested).
268
+ if (timeoutMs > 0) {
269
+ countdownLine = theme.fg("dim", `auto-deny in ${remaining}s`);
270
+ timer = setInterval(() => {
271
+ remaining -= 1;
272
+ if (remaining <= 0) {
273
+ finish({ action: "timeout" });
274
+ return;
275
+ }
276
+ countdownLine = theme.fg("dim", `auto-deny in ${remaining}s`);
277
+ tui.requestRender();
278
+ }, 1000);
279
+ }
239
280
 
240
281
  // ── event wiring ─────────────────────────────────────────────────
241
282
  selectList.onSelect = (item) => {
@@ -277,6 +318,7 @@ export function showOverlay(
277
318
  },
278
319
  invalidate: () => {},
279
320
  handleInput: (data) => {
321
+ cancelTimer(); // user is present — stop the auto-deny countdown
280
322
  if (stage === "select") selectList.handleInput(data);
281
323
  else maskedInput.handleInput(data);
282
324
  tui.requestRender();