@xynogen/pix-pretty 1.3.1 → 1.3.3

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.3.1",
3
+ "version": "1.3.3",
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",
package/src/fff.ts CHANGED
@@ -123,7 +123,6 @@ export class CursorStore {
123
123
 
124
124
  /**
125
125
  * Convert FFF GrepResult items to ripgrep-style "file:line:content" text.
126
- * This ensures pi-pretty's renderGrepResults works unchanged.
127
126
  */
128
127
  export function fffFormatGrepText(items: GrepMatch[], limit: number): string {
129
128
  const capped = items.slice(0, limit);
package/src/renderers.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { basename, dirname } from "node:path";
2
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
2
 
4
3
  import {
@@ -15,7 +14,7 @@ import { MAX_PREVIEW_LINES } from "./config.js";
15
14
  import { hlBlock } from "./highlight.js";
16
15
  import { dirIcon, fileIcon } from "./icons.js";
17
16
  import { lang } from "./lang.js";
18
- import { lnum, normalizeLineEndings, rule, termW } from "./utils.js";
17
+ import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
19
18
 
20
19
  /** Render syntax-highlighted file content with line numbers. */
21
20
  export async function renderFileContent(
@@ -51,7 +50,7 @@ export async function renderFileContent(
51
50
  out.push(rule(tw));
52
51
  if (total > maxLines) {
53
52
  out.push(
54
- `${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`,
53
+ `${FG_DIM} … ${pluralize(total - maxLines, "more line")} (${total} total)${RST}`,
55
54
  );
56
55
  }
57
56
  return out.join("\n");
@@ -77,7 +76,7 @@ export function renderBashOutput(
77
76
 
78
77
  let body = show.join("\n");
79
78
  if (remaining > 0) {
80
- body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
79
+ body += `\n${FG_DIM} … ${pluralize(remaining, "more line")}${RST}`;
81
80
  }
82
81
 
83
82
  return { summary: codeStr, body };
@@ -110,110 +109,13 @@ export function renderTree(text: string, _basePath: string): string {
110
109
 
111
110
  if (total > MAX_PREVIEW_LINES) {
112
111
  out.push(
113
- `${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`,
112
+ `${FG_RULE}└── ${RST}${FG_DIM}… ${pluralize(total - MAX_PREVIEW_LINES, "more entry", "more entries")}${RST}`,
114
113
  );
115
114
  }
116
115
 
117
116
  return out.join("\n");
118
117
  }
119
118
 
120
- /** Render find results grouped by directory with icons. */
121
- export function renderFindResults(text: string): string {
122
- const lines = text.trim().split("\n").filter(Boolean);
123
- if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
124
-
125
- // Group by directory
126
- const groups = new Map<string, string[]>();
127
- for (const line of lines) {
128
- const trimmed = line.trim();
129
- const dir = dirname(trimmed) || ".";
130
- const file = basename(trimmed);
131
- if (!groups.has(dir)) groups.set(dir, []);
132
- const bucket = groups.get(dir);
133
- if (bucket) bucket.push(file);
134
- }
135
-
136
- const out: string[] = [];
137
- let count = 0;
138
-
139
- for (const [dir, files] of groups) {
140
- if (count > 0) out.push(""); // blank line between groups
141
- out.push(`${dirIcon()}${FG_BLUE}${BOLD}${dir}/${RST}`);
142
- for (let i = 0; i < files.length; i++) {
143
- if (count >= MAX_PREVIEW_LINES) {
144
- out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
145
- return out.join("\n");
146
- }
147
- const isLast = i === files.length - 1;
148
- const prefix = isLast ? "└── " : "├── ";
149
- const icon = fileIcon(files[i]);
150
- out.push(` ${FG_RULE}${prefix}${RST}${icon}${files[i]}`);
151
- count++;
152
- }
153
- }
154
-
155
- return out.join("\n");
156
- }
157
-
158
- /** Render grep results with highlighted matches and line numbers. */
159
- export async function renderGrepResults(
160
- text: string,
161
- pattern: string,
162
- ): Promise<string> {
163
- const lines = normalizeLineEndings(text).split("\n");
164
- if (!lines.length || (lines.length === 1 && !lines[0].trim()))
165
- return `${FG_DIM}(no matches)${RST}`;
166
-
167
- const out: string[] = [];
168
- let currentFile = "";
169
- let count = 0;
170
-
171
- // Try to build a regex for highlighting
172
- let re: RegExp | null = null;
173
- try {
174
- re = new RegExp(`(${pattern})`, "gi");
175
- } catch {
176
- // invalid regex — skip highlighting
177
- }
178
-
179
- for (const line of lines) {
180
- if (count >= MAX_PREVIEW_LINES) {
181
- out.push(`${FG_DIM} … more matches${RST}`);
182
- break;
183
- }
184
-
185
- // ripgrep-style: "file:line:content" or "file-line-content" or just "file"
186
- const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
187
- if (fileMatch) {
188
- const [, file, lineNo, content] = fileMatch;
189
- if (file !== currentFile) {
190
- if (currentFile) out.push(""); // blank line between files
191
- const icon = fileIcon(file);
192
- out.push(`${icon}${FG_BLUE}${BOLD}${file}${RST}`);
193
- currentFile = file;
194
- }
195
-
196
- const nw = Math.max(3, lineNo.length);
197
- let display = content;
198
- if (re) {
199
- display = content.replace(re, `${RST}${FG_YELLOW}${BOLD}$1${RST}`);
200
- }
201
- out.push(
202
- ` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`,
203
- );
204
- count++;
205
- } else if (line.trim() === "--") {
206
- // ripgrep separator
207
- out.push(` ${FG_DIM} ···${RST}`);
208
- } else if (line.trim()) {
209
- out.push(line);
210
- count++;
211
- }
212
- }
213
-
214
- return out.join("\n");
215
- }
216
-
217
119
  // ---------------------------------------------------------------------------
218
120
  // FFF integration (optional) — Fast File Finder with frecency & SIMD search
219
121
  //
package/src/tools/find.ts CHANGED
@@ -4,8 +4,6 @@ import type {
4
4
  ToolRenderResultOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
 
7
- import { FG_DIM, RST } from "../ansi.js";
8
- import { renderFindResults } from "../renderers.js";
9
7
  import type {
10
8
  FindParams,
11
9
  FindResultDetails,
@@ -19,8 +17,8 @@ import {
19
17
  appendNotices,
20
18
  fillToolBackground,
21
19
  getTextContent,
22
- isTextContent,
23
20
  makeTextResult,
21
+ renderDimPreview,
24
22
  renderToolError,
25
23
  setResultDetails,
26
24
  } from "../utils.js";
@@ -137,22 +135,8 @@ export function registerFindTool(
137
135
  return text;
138
136
  }
139
137
 
140
- const d = result.details;
141
- if (d?._type === "findResult" && d.text) {
142
- const rendered = renderFindResults(d.text);
143
- const info = `${FG_DIM}${d.matchCount} files${RST}`;
144
- text.setText(fillToolBackground(` ${info}\n${rendered}`));
145
- return text;
146
- }
147
-
148
- const fallback = result.content?.[0];
149
- const fallbackText =
150
- fallback && isTextContent(fallback) ? fallback.text : "found";
151
- text.setText(
152
- fillToolBackground(
153
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
154
- ),
155
- );
138
+ const output = getTextContent(result) || "found";
139
+ text.setText(renderDimPreview(output, theme));
156
140
  return text;
157
141
  },
158
142
  });
package/src/tools/grep.ts CHANGED
@@ -4,12 +4,9 @@ import type {
4
4
  ToolRenderResultOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
 
7
- import { FG_DIM, RST } from "../ansi.js";
8
7
  import { fffFormatGrepText } from "../fff.js";
9
- import { renderGrepResults } from "../renderers.js";
10
8
  import type {
11
9
  GrepParams,
12
- GrepRenderState,
13
10
  GrepResultDetails,
14
11
  PiPrettyApi,
15
12
  RenderContextLike,
@@ -25,9 +22,10 @@ import {
25
22
  isTextContent,
26
23
  makeTextResult,
27
24
  normalizeLineEndings,
25
+ pluralize,
26
+ renderDimPreview,
28
27
  renderToolError,
29
28
  setResultDetails,
30
- termW,
31
29
  } from "../utils.js";
32
30
  import type { ToolContext } from "./context.js";
33
31
 
@@ -155,7 +153,7 @@ export function registerGrepTool(
155
153
  result: ToolResultLike<GrepResultDetails>,
156
154
  _opt: ToolRenderResultOptions,
157
155
  theme: ThemeLike,
158
- renderCtx: RenderContextLike<GrepRenderState>,
156
+ renderCtx: RenderContextLike,
159
157
  ) {
160
158
  const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
161
159
 
@@ -165,37 +163,15 @@ export function registerGrepTool(
165
163
  }
166
164
 
167
165
  const d = result.details;
168
- if (d?._type === "grepResult" && d.text) {
169
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
170
- if (renderCtx.state._gk !== key) {
171
- renderCtx.state._gk = key;
172
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
173
- renderCtx.state._gt = fillToolBackground(` ${info}`);
174
-
175
- renderGrepResults(d.text, d.pattern)
176
- .then((rendered: string) => {
177
- if (renderCtx.state._gk !== key) return;
178
- renderCtx.state._gt = fillToolBackground(
179
- ` ${info}\n${rendered}`,
180
- );
181
- renderCtx.invalidate();
182
- })
183
- .catch(() => {});
184
- }
185
- text.setText(
186
- renderCtx.state._gt ??
187
- fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`),
188
- );
189
- return text;
190
- }
191
-
192
- const fallback = result.content?.[0];
193
- const fallbackText =
194
- fallback && isTextContent(fallback) ? fallback.text : "searched";
166
+ const output = getTextContent(result) || "searched";
195
167
  text.setText(
196
- fillToolBackground(
197
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
198
- ),
168
+ renderDimPreview(output, theme, {
169
+ header:
170
+ d?._type === "grepResult"
171
+ ? pluralize(d.matchCount, "match", "matches")
172
+ : undefined,
173
+ highlight: d?._type === "grepResult" ? d.pattern : undefined,
174
+ }),
199
175
  );
200
176
  return text;
201
177
  },
package/src/tools/ls.ts CHANGED
@@ -3,7 +3,6 @@ import type {
3
3
  ExtensionContext,
4
4
  LsToolInput,
5
5
  } from "@earendil-works/pi-coding-agent";
6
-
7
6
  import { FG_DIM, RST } from "../ansi.js";
8
7
  import { renderTree } from "../renderers.js";
9
8
  import type {
@@ -17,7 +16,6 @@ import type {
17
16
  import {
18
17
  fillToolBackground,
19
18
  getTextContent,
20
- isTextContent,
21
19
  renderToolError,
22
20
  setResultDetails,
23
21
  } from "../utils.js";
@@ -98,13 +96,9 @@ export function registerLsTool(
98
96
  return text;
99
97
  }
100
98
 
101
- const fallback = result.content?.[0];
102
- const fallbackText =
103
- fallback && isTextContent(fallback) ? fallback.text : "listed";
99
+ const output = getTextContent(result) || "listed";
104
100
  text.setText(
105
- fillToolBackground(
106
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
107
- ),
101
+ fillToolBackground(` ${theme.fg("dim", output.slice(0, 120))}`),
108
102
  );
109
103
  return text;
110
104
  },
@@ -4,13 +4,10 @@ import type {
4
4
  ToolRenderResultOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
 
7
- import { FG_DIM, RST } from "../ansi.js";
8
7
  import { fffFormatGrepText } from "../fff.js";
9
- import { renderGrepResults } from "../renderers.js";
10
8
  import type {
11
9
  GrepResultDetails,
12
10
  MultiGrepParams,
13
- MultiGrepRenderState,
14
11
  PiPrettyApi,
15
12
  RenderContextLike,
16
13
  ThemeLike,
@@ -21,14 +18,16 @@ import {
21
18
  appendNotices,
22
19
  buildLiteralAlternationPattern,
23
20
  countRipgrepMatches,
21
+ fillToolBackground,
24
22
  getConstraintBackedPath,
25
23
  getErrorMessage,
26
24
  getTextContent,
27
- isTextContent,
28
25
  makeTextResult,
29
26
  normalizeLineEndings,
27
+ pluralize,
28
+ renderDimPreview,
29
+ renderToolError,
30
30
  shouldIgnoreCaseForPatterns,
31
- termW,
32
31
  trimToUndefined,
33
32
  } from "../utils.js";
34
33
  import type { ToolContext } from "./context.js";
@@ -278,7 +277,7 @@ export function registerMultiGrepTool(
278
277
  theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
279
278
  content += path;
280
279
  if (constraints) content += theme.fg("muted", ` (${constraints})`);
281
- text.setText(content);
280
+ text.setText(fillToolBackground(content));
282
281
  return text;
283
282
  },
284
283
 
@@ -286,43 +285,26 @@ export function registerMultiGrepTool(
286
285
  result: ToolResultLike<GrepResultDetails | { error?: string }>,
287
286
  _opt: ToolRenderResultOptions,
288
287
  theme: ThemeLike,
289
- renderCtx: RenderContextLike<MultiGrepRenderState>,
288
+ renderCtx: RenderContextLike,
290
289
  ) {
291
290
  const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
292
291
 
293
292
  if (renderCtx.isError) {
294
- text.setText(
295
- `\n${theme.fg("error", getTextContent(result) || "Error")}`,
296
- );
293
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
297
294
  return text;
298
295
  }
299
296
 
300
297
  const d = result.details;
301
- if (d && "_type" in d && d._type === "grepResult" && d.text) {
302
- const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
303
- if (renderCtx.state._mgk !== key) {
304
- renderCtx.state._mgk = key;
305
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
306
- renderCtx.state._mgt = ` ${info}`;
307
-
308
- renderGrepResults(d.text, d.pattern)
309
- .then((rendered: string) => {
310
- if (renderCtx.state._mgk !== key) return;
311
- renderCtx.state._mgt = ` ${info}\n${rendered}`;
312
- renderCtx.invalidate();
313
- })
314
- .catch(() => {});
315
- }
316
- text.setText(
317
- renderCtx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`,
318
- );
319
- return text;
320
- }
321
-
322
- const fallback = result.content?.[0];
323
- const fallbackText =
324
- fallback && isTextContent(fallback) ? fallback.text : "searched";
325
- text.setText(` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`);
298
+ const isGrep = d && "_type" in d && d._type === "grepResult";
299
+ const output = getTextContent(result) || "searched";
300
+ text.setText(
301
+ renderDimPreview(output, theme, {
302
+ header: isGrep
303
+ ? pluralize(d.matchCount, "match", "matches")
304
+ : undefined,
305
+ highlight: isGrep ? d.pattern : undefined,
306
+ }),
307
+ );
326
308
  return text;
327
309
  },
328
310
  });
package/src/types.ts CHANGED
@@ -211,8 +211,6 @@ export type MultiGrepParams = {
211
211
  limit?: number;
212
212
  };
213
213
 
214
- export type GrepRenderState = { _gk?: string; _gt?: string };
215
-
216
214
  export type EditRenderState = {
217
215
  _pk?: string;
218
216
  _pt?: string;
@@ -229,8 +227,6 @@ export type WriteRenderState = {
229
227
  _nft?: string;
230
228
  };
231
229
 
232
- export type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
233
-
234
230
  export type FindResultDetails = {
235
231
  _type: "findResult";
236
232
  text: string;
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { MAX_PREVIEW_LINES } from "./config.js";
4
+ import type { FgTheme } from "./types.js";
5
+ import { pluralize, renderDimPreview } from "./utils.js";
6
+
7
+ // Strip ANSI escapes so assertions test content, not color codes.
8
+ const ANSI = /\x1b\[[0-9;]*m/g;
9
+ function plain(text: string): string {
10
+ return text.replace(ANSI, "");
11
+ }
12
+
13
+ // Minimal theme: fg() passes text through untouched.
14
+ const theme: FgTheme = { fg: (_key, text) => text };
15
+
16
+ describe("pluralize", () => {
17
+ it("uses singular for count of 1", () => {
18
+ expect(pluralize(1, "match", "matches")).toBe("1 match");
19
+ });
20
+
21
+ it("uses plural for count != 1", () => {
22
+ expect(pluralize(0, "match", "matches")).toBe("0 matches");
23
+ expect(pluralize(2, "match", "matches")).toBe("2 matches");
24
+ });
25
+
26
+ it("defaults plural to noun + s", () => {
27
+ expect(pluralize(1, "line")).toBe("1 line");
28
+ expect(pluralize(3, "line")).toBe("3 lines");
29
+ });
30
+ });
31
+
32
+ describe("renderDimPreview", () => {
33
+ it("renders 'done' for empty input", () => {
34
+ expect(plain(renderDimPreview("", theme))).toContain("done");
35
+ });
36
+
37
+ it("shows every line when under the cap", () => {
38
+ const out = plain(renderDimPreview("a\nb\nc", theme));
39
+ expect(out).toContain("a");
40
+ expect(out).toContain("b");
41
+ expect(out).toContain("c");
42
+ expect(out).not.toContain("more line");
43
+ });
44
+
45
+ it("does not add overflow marker at exactly the cap", () => {
46
+ const body = Array.from({ length: MAX_PREVIEW_LINES }, (_, i) => `L${i}`);
47
+ const out = plain(renderDimPreview(body.join("\n"), theme));
48
+ expect(out).not.toContain("more line");
49
+ });
50
+
51
+ it("adds singular overflow marker for 1 extra line", () => {
52
+ const body = Array.from(
53
+ { length: MAX_PREVIEW_LINES + 1 },
54
+ (_, i) => `L${i}`,
55
+ );
56
+ const out = plain(renderDimPreview(body.join("\n"), theme));
57
+ expect(out).toContain("… 1 more line");
58
+ expect(out).not.toContain("more lines");
59
+ });
60
+
61
+ it("adds plural overflow marker for many extra lines", () => {
62
+ const body = Array.from(
63
+ { length: MAX_PREVIEW_LINES + 3 },
64
+ (_, i) => `L${i}`,
65
+ );
66
+ const out = plain(renderDimPreview(body.join("\n"), theme));
67
+ expect(out).toContain("… 3 more lines");
68
+ });
69
+
70
+ it("respects a custom maxLines", () => {
71
+ const out = plain(renderDimPreview("a\nb\nc\nd", theme, { maxLines: 2 }));
72
+ expect(out).toContain("… 2 more lines");
73
+ });
74
+
75
+ it("prepends a header line when given", () => {
76
+ const out = plain(renderDimPreview("body", theme, { header: "5 matches" }));
77
+ expect(out).toContain("5 matches");
78
+ expect(out).toContain("body");
79
+ });
80
+
81
+ it("highlights matched keyword with non-dim styling", () => {
82
+ const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
83
+ // matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
84
+ expect(raw).toContain("\x1b[");
85
+ expect(plain(raw)).toContain("foo bar foo");
86
+ });
87
+
88
+ it("does not throw on an invalid highlight regex", () => {
89
+ expect(() =>
90
+ renderDimPreview("text", theme, { highlight: "(" }),
91
+ ).not.toThrow();
92
+ });
93
+ });
package/src/utils.ts CHANGED
@@ -5,10 +5,13 @@ import {
5
5
  ANSI_CAPTURE_RE,
6
6
  BG_BASE,
7
7
  BG_ERROR,
8
+ BOLD,
9
+ FG_GREEN,
8
10
  FG_LNUM,
9
11
  FG_RULE,
10
12
  RST,
11
13
  } from "./ansi.js";
14
+ import { MAX_PREVIEW_LINES } from "./config.js";
12
15
  import type {
13
16
  FgTheme,
14
17
  ToolContent,
@@ -48,6 +51,64 @@ export function fillToolBackground(text: string, bg = BG_BASE): string {
48
51
  .join("\n");
49
52
  }
50
53
 
54
+ export function pluralize(
55
+ count: number,
56
+ noun: string,
57
+ plural?: string,
58
+ ): string {
59
+ return `${count} ${count === 1 ? noun : (plural ?? `${noun}s`)}`;
60
+ }
61
+
62
+ export type DimPreviewOptions = {
63
+ maxLines?: number;
64
+ header?: string;
65
+ /** Pattern whose matches are highlighted (green bold) inside dim lines. */
66
+ highlight?: string;
67
+ };
68
+
69
+ function safeHighlightRegex(pattern: string): RegExp | null {
70
+ try {
71
+ return new RegExp(`(${pattern})`, "gi");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function dimLineWithHighlight(
78
+ line: string,
79
+ theme: FgTheme,
80
+ re: RegExp | null,
81
+ ): string {
82
+ if (!re) return theme.fg("dim", line);
83
+ // split with capture group: odd indexes are matches
84
+ return line
85
+ .split(re)
86
+ .map((part, i) =>
87
+ i % 2 ? `${FG_GREEN}${BOLD}${part}${RST}` : theme.fg("dim", part),
88
+ )
89
+ .join("");
90
+ }
91
+
92
+ export function renderDimPreview(
93
+ text: string,
94
+ theme: FgTheme,
95
+ opts: DimPreviewOptions = {},
96
+ ): string {
97
+ const maxLines = opts.maxLines ?? MAX_PREVIEW_LINES;
98
+ const re = opts.highlight ? safeHighlightRegex(opts.highlight) : null;
99
+ const output = normalizeLineEndings(text).trim() || "done";
100
+ const lines = output.split("\n");
101
+ const preview = lines
102
+ .slice(0, maxLines)
103
+ .map((line) => ` ${dimLineWithHighlight(line, theme, re)}`);
104
+ if (opts.header) preview.unshift(` ${theme.fg("dim", opts.header)}`);
105
+ if (lines.length > maxLines) {
106
+ const more = pluralize(lines.length - maxLines, "more line");
107
+ preview.push(` ${theme.fg("dim", `… ${more}`)}`);
108
+ }
109
+ return fillToolBackground(preview.join("\n"));
110
+ }
111
+
51
112
  let _cachedTermW: number | undefined;
52
113
  let _termWResizeBound = false;
53
114