@xynogen/pix-pretty 1.11.2 → 1.13.0

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.11.2",
3
+ "version": "1.13.0",
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",
@@ -65,6 +65,8 @@ const CATALOG = {
65
65
  // part of this set.
66
66
  "status.ok": { nerd: "\u2713", unicode: `\u2713${VS}`, ascii: "ok" },
67
67
  "status.error": { nerd: "\u2717", unicode: `\u2717${VS}`, ascii: "x" },
68
+ // `⚠` is East-Asian wide (2 cells); consumers that place it in an aligned
69
+ // marker column must normalize width via `padIcon` (pix-pretty/utils).
68
70
  "status.warn": { nerd: "\u26A0", unicode: `\u26A0${VS}`, ascii: "!" },
69
71
  "status.pending": { nerd: "\u25CB", unicode: `\u25CB${VS}`, ascii: "o" },
70
72
  "status.running": { nerd: "\u25D0", unicode: `\u25D0${VS}`, ascii: "*" },
@@ -17,6 +17,10 @@ import {
17
17
  wrapTextWithAnsi,
18
18
  } from "@earendil-works/pi-tui";
19
19
 
20
+ export { truncateToWidth, visibleWidth, wrapTextWithAnsi };
21
+
22
+ // ponytail: re-export ANSI-safe wrapping via pix-pretty so consumers dedupe local wrapText (pix-mcp)
23
+
20
24
  // ── Constants ─────────────────────────────────────────────────────────────────
21
25
 
22
26
  const MIN_WIDTH = 40;
package/src/utils.test.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  formatCollapsedToolRow,
7
7
  formatJson,
8
8
  hideCollapsedToolCall,
9
+ padIcon,
9
10
  pluralize,
10
11
  renderCollapsedToolRow,
11
12
  renderDimPreview,
@@ -108,11 +109,25 @@ describe("collapsed tool rows", () => {
108
109
  const rowTheme = { fg: (_key: string, text: string) => text, bold: (text: string) => text };
109
110
 
110
111
  it("renders a consistent status, tool, target, and metadata row", () => {
112
+ // The status marker is width-normalized to 2 cells (padIcon) so wide glyphs
113
+ // align with narrow ones; a 1-cell `✓` therefore carries one pad space.
111
114
  expect(formatCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines")).toBe(
112
- "✓ read src/a.ts · 12 lines",
115
+ "✓ read src/a.ts · 12 lines",
113
116
  );
114
117
  const rendered = plain(renderCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines"));
115
- expect(rendered).toStartWith("✓ read src/a.ts · 12 lines");
118
+ expect(rendered).toStartWith("✓ read src/a.ts · 12 lines");
119
+ });
120
+
121
+ it("padIcon normalizes markers to a fixed cell width (per pi-tui visibleWidth)", () => {
122
+ // pi-tui's width table drives the actual TUI column math, so padIcon trusts
123
+ // it: `✓`/`✗`/`⚠` measure 1 cell and gain a pad space; `⚡` measures 2 and
124
+ // is left as-is. All markers then occupy the same 2-cell column.
125
+ expect(padIcon("✓")).toBe("✓ ");
126
+ expect(padIcon("✗")).toBe("✗ ");
127
+ expect(padIcon("⚠")).toBe("⚠ ");
128
+ expect(padIcon("⚡")).toBe("⚡"); // already 2 cells — unchanged
129
+ expect(padIcon("x", 4)).toBe("x "); // explicit width
130
+ expect(padIcon("⚡", 1)).toBe("⚡"); // never truncated below its own width
116
131
  });
117
132
 
118
133
  it("hides only collapsed, non-expanded call rows", () => {
package/src/utils.ts CHANGED
@@ -125,6 +125,37 @@ export function formatJson(value: unknown, options: FormatJsonOptions = {}): str
125
125
 
126
126
  export type CollapsedToolStatus = "success" | "error" | "warning";
127
127
 
128
+ /**
129
+ * Status glyphs for collapsed tool rows. `⚠` (warning) is East-Asian *wide*
130
+ * (2 cells) while `✓`/`✗` are 1 cell — render them through `padIcon` so every
131
+ * marker occupies the same fixed column and rows stay vertically aligned.
132
+ * (`⚡` is reserved for strength / model-score badges, not warnings.)
133
+ */
134
+ export const COLLAPSED_TOOL_GLYPH: Record<CollapsedToolStatus, string> = {
135
+ success: "✓",
136
+ warning: "⚠",
137
+ error: "✗",
138
+ };
139
+
140
+ /**
141
+ * Normalize a marker glyph to a fixed display width so mixed 1-cell and 2-cell
142
+ * (East-Asian wide / emoji) icons align in a column and the following text
143
+ * always starts at the same offset. Measures actual terminal cells via pi-tui's
144
+ * `visibleWidth` (ANSI-aware), then right-pads with spaces. A glyph already
145
+ * at/over `width` is returned unchanged (never truncated — clipping a marker is
146
+ * worse than a 1-cell overflow).
147
+ *
148
+ * The default width (2) makes every marker occupy exactly two cells, so with a
149
+ * single separator space the following text always starts at the same column:
150
+ *
151
+ * `${padIcon("✓")} bash` // "✓ bash" (1 cell + 1 pad + separator)
152
+ * `${padIcon("⚠")} bash` // "⚠ bash" (2 cells + separator — same column)
153
+ */
154
+ export function padIcon(glyph: string, width = 2): string {
155
+ const pad = width - visibleWidth(glyph);
156
+ return pad > 0 ? glyph + " ".repeat(pad) : glyph;
157
+ }
158
+
128
159
  type CollapsedToolTheme = {
129
160
  fg: (
130
161
  key: "success" | "error" | "warning" | "toolTitle" | "muted" | "dim",
@@ -141,7 +172,7 @@ export function formatCollapsedToolRow(
141
172
  meta = "",
142
173
  status: CollapsedToolStatus = "success",
143
174
  ): string {
144
- const icon = status === "success" ? "✓" : status === "warning" ? "⚡" : "✗";
175
+ const icon = padIcon(COLLAPSED_TOOL_GLYPH[status]);
145
176
  const parts = [
146
177
  `${theme.fg(status, icon)} ${theme.fg("toolTitle", theme.bold(tool))}`,
147
178
  target ? theme.fg("muted", target) : "",
@@ -3,6 +3,7 @@ import {
3
3
  describeActivity,
4
4
  fmtTokenCount,
5
5
  formatContext,
6
+ formatDuration,
6
7
  formatMs,
7
8
  formatSpeed,
8
9
  formatTokens,
@@ -37,6 +38,18 @@ describe("widget formatters", () => {
37
38
  expect(formatMs(2_100)).toBe("2.1s");
38
39
  });
39
40
 
41
+ test("formatDuration keeps 3 presentations via style param", () => {
42
+ expect(formatDuration(420, "bash")).toBe("420ms");
43
+ expect(formatDuration(2_450, "bash")).toBe("2.5s");
44
+ expect(formatDuration(12_400, "bash")).toBe("12s");
45
+ expect(formatDuration(450, "btw")).toBe("450ms");
46
+ expect(formatDuration(2_100, "btw")).toBe("2.1s");
47
+ expect(formatDuration(12_400, "btw")).toBe("12s");
48
+ expect(formatDuration(65_000, "btw")).toBe("1m 5s");
49
+ expect(formatDuration(2_100)).toBe("2.1s");
50
+ expect(formatDuration(2_100, "ms")).toBe(formatMs(2_100));
51
+ });
52
+
40
53
  test("formatSpeed returns empty when there is no work", () => {
41
54
  expect(formatSpeed(0, 1_000)).toBe("");
42
55
  expect(formatSpeed(100, 0)).toBe("");
@@ -109,6 +109,23 @@ export function formatMs(ms: number): string {
109
109
  return `${(ms / 1000).toFixed(1)}s`;
110
110
  }
111
111
 
112
+ /** ponytail: unified duration - keep 3 presentations via style param. */
113
+ export type DurationStyle = "bash" | "btw" | "ms"; // bash=ms/<10s 1dp/>10s int; btw=ms/s/m+s; ms=always s 1dp
114
+ export function formatDuration(ms: number, style: DurationStyle = "ms"): string {
115
+ const n = Math.max(0, Math.floor(ms));
116
+ if (style === "bash") {
117
+ if (n < 1_000) return `${n}ms`;
118
+ if (n < 10_000) return `${(n / 1_000).toFixed(1)}s`;
119
+ return `${Math.round(n / 1_000)}s`;
120
+ }
121
+ if (style === "btw") {
122
+ if (n < 1_000) return `${n}ms`;
123
+ if (n < 60_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`;
124
+ return `${Math.floor(n / 60_000)}m ${Math.round((n % 60_000) / 1_000)}s`;
125
+ }
126
+ return formatMs(n);
127
+ }
128
+
112
129
  /**
113
130
  * Output tokens per second over a duration. "" when either input is
114
131
  * non-positive (no work / zero elapsed) so callers can skip the segment.