@xynogen/pix-pretty 1.3.0 → 1.3.2
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/diff-render.ts +5 -31
- package/src/fff.ts +0 -1
- package/src/renderers.ts +4 -102
- package/src/tools/bash.test.ts +86 -0
- package/src/tools/bash.ts +28 -9
- package/src/tools/find.ts +4 -19
- package/src/tools/grep.ts +12 -35
- package/src/tools/ls.ts +5 -9
- package/src/tools/multi-grep.ts +18 -35
- package/src/tools/read.ts +3 -0
- package/src/tools/write.ts +1 -0
- package/src/types.ts +0 -4
- package/src/utils.test.ts +93 -0
- package/src/utils.ts +76 -8
package/package.json
CHANGED
package/src/diff-render.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { MAX_HL_CHARS, MAX_RENDER_LINES, WORD_DIFF_MIN_SIM } from "./config.js";
|
|
|
14
14
|
import type { DiffLine, ParsedDiff } from "./diff.js";
|
|
15
15
|
import { hlBlock } from "./highlight.js";
|
|
16
16
|
import type { BundledLanguage } from "./types.js";
|
|
17
|
+
import { termW as utilsTermW } from "./utils.js";
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Env-overridable color/threshold helpers (mirror pi-diff)
|
|
@@ -73,7 +74,6 @@ const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
|
|
|
73
74
|
// ---------------------------------------------------------------------------
|
|
74
75
|
|
|
75
76
|
const MAX_TERM_WIDTH = 210;
|
|
76
|
-
const DEFAULT_TERM_WIDTH = 200;
|
|
77
77
|
|
|
78
78
|
const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
|
|
79
79
|
|
|
@@ -201,36 +201,10 @@ function tabs(s: string): string {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
function termW(): number {
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
stderrCols ||
|
|
209
|
-
Number.parseInt(process.env.COLUMNS ?? "", 10) ||
|
|
210
|
-
_readTtyColsDR() ||
|
|
211
|
-
DEFAULT_TERM_WIDTH;
|
|
212
|
-
return Math.max(80, Math.min(raw, MAX_TERM_WIDTH));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function _readTtyColsDR(): number | undefined {
|
|
216
|
-
try {
|
|
217
|
-
const { getWindowSize } = require("node:tty") as {
|
|
218
|
-
getWindowSize?: (fd: number) => [number, number];
|
|
219
|
-
};
|
|
220
|
-
if (getWindowSize) {
|
|
221
|
-
for (const fd of [1, 2, 0]) {
|
|
222
|
-
try {
|
|
223
|
-
const [cols] = getWindowSize(fd);
|
|
224
|
-
if (cols && cols > 0) return cols;
|
|
225
|
-
} catch {
|
|
226
|
-
/* not a tty */
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
/* tty unavailable */
|
|
232
|
-
}
|
|
233
|
-
return undefined;
|
|
204
|
+
// Single source of truth: utils.termW caches, falls back to tty ioctl, and
|
|
205
|
+
// invalidates on resize. Diff layout needs a hard floor of 80 cols for the
|
|
206
|
+
// split-view column math, so clamp the shared value here.
|
|
207
|
+
return Math.max(80, Math.min(utilsTermW(), MAX_TERM_WIDTH));
|
|
234
208
|
}
|
|
235
209
|
|
|
236
210
|
/** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
|
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
|
|
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
|
|
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
|
|
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
|
//
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { registerBashTool } from "./bash";
|
|
4
|
+
|
|
5
|
+
class MockTextComponent {
|
|
6
|
+
private text: string;
|
|
7
|
+
|
|
8
|
+
constructor(text = "") {
|
|
9
|
+
this.text = text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setText(value: string): void {
|
|
13
|
+
this.text = value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getText(): string {
|
|
17
|
+
return this.text;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("registerBashTool", () => {
|
|
22
|
+
it("clamps renderCall to small terminal widths", () => {
|
|
23
|
+
const registered: { renderCall?: (...args: any[]) => MockTextComponent } =
|
|
24
|
+
{};
|
|
25
|
+
const origColumns = process.env.COLUMNS;
|
|
26
|
+
process.env.COLUMNS = "24";
|
|
27
|
+
process.stdout.emit("resize");
|
|
28
|
+
process.stdin.emit("resize");
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
registerBashTool(
|
|
32
|
+
{
|
|
33
|
+
registerTool(tool: unknown) {
|
|
34
|
+
Object.assign(registered, tool);
|
|
35
|
+
},
|
|
36
|
+
} as any,
|
|
37
|
+
() => ({
|
|
38
|
+
execute: async () => ({
|
|
39
|
+
content: [{ type: "text", text: "ok" }],
|
|
40
|
+
details: undefined,
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
{
|
|
44
|
+
cwd: process.cwd(),
|
|
45
|
+
sp: (p: string) => p,
|
|
46
|
+
TextComponent: MockTextComponent as any,
|
|
47
|
+
fffState: {} as any,
|
|
48
|
+
cursorStore: {} as any,
|
|
49
|
+
multiGrepRipgrepFallback: async () => ({
|
|
50
|
+
text: "",
|
|
51
|
+
matchCount: 0,
|
|
52
|
+
limitReached: false,
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const text = registered.renderCall?.(
|
|
58
|
+
{
|
|
59
|
+
command: 'printf "very very very long line"\necho second\necho third',
|
|
60
|
+
timeout: 30,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
fg: (_key: string, value: string) => value,
|
|
64
|
+
bold: (value: string) => value,
|
|
65
|
+
} as any,
|
|
66
|
+
{
|
|
67
|
+
expanded: false,
|
|
68
|
+
isError: false,
|
|
69
|
+
invalidate: () => {},
|
|
70
|
+
state: {},
|
|
71
|
+
} as any,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(text).toBeDefined();
|
|
75
|
+
const rendered = text?.getText() ?? "";
|
|
76
|
+
for (const line of rendered.split("\n")) {
|
|
77
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(24);
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
if (origColumns === undefined) delete process.env.COLUMNS;
|
|
81
|
+
else process.env.COLUMNS = origColumns;
|
|
82
|
+
process.stdout.emit("resize");
|
|
83
|
+
process.stdin.emit("resize");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/tools/bash.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
8
|
import { FG_DIM, RST } from "../ansi.js";
|
|
8
9
|
import { MAX_PREVIEW_LINES } from "../config.js";
|
|
9
10
|
import { renderBashOutput } from "../renderers.js";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
fillToolBackground,
|
|
20
21
|
getTextContent,
|
|
21
22
|
isTextContent,
|
|
23
|
+
normalizeLineEndings,
|
|
22
24
|
renderToolError,
|
|
23
25
|
rule,
|
|
24
26
|
setResultDetails,
|
|
@@ -37,6 +39,9 @@ export function registerBashTool(
|
|
|
37
39
|
pi.registerTool({
|
|
38
40
|
...origBash,
|
|
39
41
|
name: "bash",
|
|
42
|
+
// Full-width framing (rules + bg fill) baked at termW(); the default
|
|
43
|
+
// Box shell pads x by 1 and re-wraps at width-2, splitting every line.
|
|
44
|
+
renderShell: "self",
|
|
40
45
|
|
|
41
46
|
async execute(
|
|
42
47
|
tid: string,
|
|
@@ -84,17 +89,28 @@ export function registerBashTool(
|
|
|
84
89
|
renderCtx: RenderContextLike,
|
|
85
90
|
) {
|
|
86
91
|
const cmd = args.command ?? "";
|
|
92
|
+
const displayCmdRaw = cmd.trim();
|
|
87
93
|
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
94
|
+
const label = theme.fg("toolTitle", theme.bold("bash"));
|
|
88
95
|
const timeout = args.timeout
|
|
89
96
|
? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}`
|
|
90
97
|
: "";
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
`${
|
|
96
|
-
|
|
98
|
+
const cmdLines = displayCmdRaw.split("\n");
|
|
99
|
+
const firstLine = cmdLines[0] ?? "";
|
|
100
|
+
const compactCmd =
|
|
101
|
+
cmdLines.length > 1
|
|
102
|
+
? `${firstLine} ${theme.fg("muted", `… (+${cmdLines.length - 1} lines)`)}`
|
|
103
|
+
: firstLine;
|
|
104
|
+
const baseCmd = renderCtx.expanded ? displayCmdRaw : compactCmd;
|
|
105
|
+
const availableWidth = Math.max(1, termW() - 1);
|
|
106
|
+
const prefix = `${label} `;
|
|
107
|
+
const reserve = Math.max(0, availableWidth - timeout.length);
|
|
108
|
+
const displayCmd = truncateToWidth(
|
|
109
|
+
theme.fg("accent", baseCmd),
|
|
110
|
+
Math.max(1, reserve - prefix.length),
|
|
111
|
+
"…",
|
|
97
112
|
);
|
|
113
|
+
text.setText(fillToolBackground(`${prefix}${displayCmd}${timeout}`));
|
|
98
114
|
return text;
|
|
99
115
|
},
|
|
100
116
|
|
|
@@ -113,17 +129,20 @@ export function registerBashTool(
|
|
|
113
129
|
|
|
114
130
|
const d = result.details as Record<string, unknown> | undefined;
|
|
115
131
|
if (d?._type === "bashResult") {
|
|
132
|
+
const normalizedText = normalizeLineEndings(d.text as string)
|
|
133
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
134
|
+
.replace(/^\n+|\n+$/g, "");
|
|
116
135
|
const { summary } = renderBashOutput(
|
|
117
|
-
|
|
136
|
+
normalizedText,
|
|
118
137
|
d.exitCode as number | null,
|
|
119
138
|
);
|
|
120
|
-
const lines =
|
|
139
|
+
const lines = normalizedText ? normalizedText.split("\n") : [];
|
|
121
140
|
const lineCount = lines.length;
|
|
122
141
|
const lineInfo =
|
|
123
142
|
lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
|
|
124
143
|
const header = ` ${summary}${lineInfo}`;
|
|
125
144
|
|
|
126
|
-
if (
|
|
145
|
+
if (normalizedText) {
|
|
127
146
|
const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
128
147
|
const show = lines.slice(0, maxShow);
|
|
129
148
|
const tw = termW();
|
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";
|
|
@@ -37,6 +35,7 @@ export function registerFindTool(
|
|
|
37
35
|
pi.registerTool({
|
|
38
36
|
...origFind,
|
|
39
37
|
name: "find",
|
|
38
|
+
renderShell: "self",
|
|
40
39
|
|
|
41
40
|
async execute(
|
|
42
41
|
tid: string,
|
|
@@ -136,22 +135,8 @@ export function registerFindTool(
|
|
|
136
135
|
return text;
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const rendered = renderFindResults(d.text);
|
|
142
|
-
const info = `${FG_DIM}${d.matchCount} files${RST}`;
|
|
143
|
-
text.setText(fillToolBackground(` ${info}\n${rendered}`));
|
|
144
|
-
return text;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const fallback = result.content?.[0];
|
|
148
|
-
const fallbackText =
|
|
149
|
-
fallback && isTextContent(fallback) ? fallback.text : "found";
|
|
150
|
-
text.setText(
|
|
151
|
-
fillToolBackground(
|
|
152
|
-
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
153
|
-
),
|
|
154
|
-
);
|
|
138
|
+
const output = getTextContent(result) || "found";
|
|
139
|
+
text.setText(renderDimPreview(output, theme));
|
|
155
140
|
return text;
|
|
156
141
|
},
|
|
157
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
|
|
|
@@ -42,6 +40,7 @@ export function registerGrepTool(
|
|
|
42
40
|
pi.registerTool({
|
|
43
41
|
...origGrep,
|
|
44
42
|
name: "grep",
|
|
43
|
+
renderShell: "self",
|
|
45
44
|
|
|
46
45
|
async execute(
|
|
47
46
|
tid: string,
|
|
@@ -154,7 +153,7 @@ export function registerGrepTool(
|
|
|
154
153
|
result: ToolResultLike<GrepResultDetails>,
|
|
155
154
|
_opt: ToolRenderResultOptions,
|
|
156
155
|
theme: ThemeLike,
|
|
157
|
-
renderCtx: RenderContextLike
|
|
156
|
+
renderCtx: RenderContextLike,
|
|
158
157
|
) {
|
|
159
158
|
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
160
159
|
|
|
@@ -164,37 +163,15 @@ export function registerGrepTool(
|
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
const d = result.details;
|
|
167
|
-
|
|
168
|
-
const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
|
|
169
|
-
if (renderCtx.state._gk !== key) {
|
|
170
|
-
renderCtx.state._gk = key;
|
|
171
|
-
const info = `${FG_DIM}${d.matchCount} matches${RST}`;
|
|
172
|
-
renderCtx.state._gt = fillToolBackground(` ${info}`);
|
|
173
|
-
|
|
174
|
-
renderGrepResults(d.text, d.pattern)
|
|
175
|
-
.then((rendered: string) => {
|
|
176
|
-
if (renderCtx.state._gk !== key) return;
|
|
177
|
-
renderCtx.state._gt = fillToolBackground(
|
|
178
|
-
` ${info}\n${rendered}`,
|
|
179
|
-
);
|
|
180
|
-
renderCtx.invalidate();
|
|
181
|
-
})
|
|
182
|
-
.catch(() => {});
|
|
183
|
-
}
|
|
184
|
-
text.setText(
|
|
185
|
-
renderCtx.state._gt ??
|
|
186
|
-
fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`),
|
|
187
|
-
);
|
|
188
|
-
return text;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const fallback = result.content?.[0];
|
|
192
|
-
const fallbackText =
|
|
193
|
-
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
166
|
+
const output = getTextContent(result) || "searched";
|
|
194
167
|
text.setText(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}),
|
|
198
175
|
);
|
|
199
176
|
return text;
|
|
200
177
|
},
|
package/src/tools/ls.ts
CHANGED
|
@@ -4,8 +4,6 @@ import type {
|
|
|
4
4
|
LsToolInput,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import { FG_DIM, RST } from "../ansi.js";
|
|
8
|
-
import { renderTree } from "../renderers.js";
|
|
9
7
|
import type {
|
|
10
8
|
LsParams,
|
|
11
9
|
PiPrettyApi,
|
|
@@ -14,10 +12,11 @@ import type {
|
|
|
14
12
|
ToolFactory,
|
|
15
13
|
ToolResultLike,
|
|
16
14
|
} from "../types.js";
|
|
15
|
+
import { FG_DIM, RST } from "../ansi.js";
|
|
16
|
+
import { renderTree } from "../renderers.js";
|
|
17
17
|
import {
|
|
18
18
|
fillToolBackground,
|
|
19
19
|
getTextContent,
|
|
20
|
-
isTextContent,
|
|
21
20
|
renderToolError,
|
|
22
21
|
setResultDetails,
|
|
23
22
|
} from "../utils.js";
|
|
@@ -34,6 +33,7 @@ export function registerLsTool(
|
|
|
34
33
|
pi.registerTool({
|
|
35
34
|
...origLs,
|
|
36
35
|
name: "ls",
|
|
36
|
+
renderShell: "self",
|
|
37
37
|
|
|
38
38
|
async execute(
|
|
39
39
|
tid: string,
|
|
@@ -97,13 +97,9 @@ export function registerLsTool(
|
|
|
97
97
|
return text;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const
|
|
101
|
-
const fallbackText =
|
|
102
|
-
fallback && isTextContent(fallback) ? fallback.text : "listed";
|
|
100
|
+
const output = getTextContent(result) || "listed";
|
|
103
101
|
text.setText(
|
|
104
|
-
fillToolBackground(
|
|
105
|
-
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
106
|
-
),
|
|
102
|
+
fillToolBackground(` ${theme.fg("dim", output.slice(0, 120))}`),
|
|
107
103
|
);
|
|
108
104
|
return text;
|
|
109
105
|
},
|
package/src/tools/multi-grep.ts
CHANGED
|
@@ -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";
|
|
@@ -51,6 +50,7 @@ export function registerMultiGrepTool(
|
|
|
51
50
|
pi.registerTool({
|
|
52
51
|
name: "multi_grep",
|
|
53
52
|
label: "multi_grep",
|
|
53
|
+
renderShell: "self",
|
|
54
54
|
description: [
|
|
55
55
|
"Search file contents for lines matching ANY of multiple patterns (OR logic).",
|
|
56
56
|
"Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
|
|
@@ -277,7 +277,7 @@ export function registerMultiGrepTool(
|
|
|
277
277
|
theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
|
|
278
278
|
content += path;
|
|
279
279
|
if (constraints) content += theme.fg("muted", ` (${constraints})`);
|
|
280
|
-
text.setText(content);
|
|
280
|
+
text.setText(fillToolBackground(content));
|
|
281
281
|
return text;
|
|
282
282
|
},
|
|
283
283
|
|
|
@@ -285,43 +285,26 @@ export function registerMultiGrepTool(
|
|
|
285
285
|
result: ToolResultLike<GrepResultDetails | { error?: string }>,
|
|
286
286
|
_opt: ToolRenderResultOptions,
|
|
287
287
|
theme: ThemeLike,
|
|
288
|
-
renderCtx: RenderContextLike
|
|
288
|
+
renderCtx: RenderContextLike,
|
|
289
289
|
) {
|
|
290
290
|
const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
|
|
291
291
|
|
|
292
292
|
if (renderCtx.isError) {
|
|
293
|
-
text.setText(
|
|
294
|
-
`\n${theme.fg("error", getTextContent(result) || "Error")}`,
|
|
295
|
-
);
|
|
293
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
296
294
|
return text;
|
|
297
295
|
}
|
|
298
296
|
|
|
299
297
|
const d = result.details;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
renderCtx.state._mgt = ` ${info}\n${rendered}`;
|
|
311
|
-
renderCtx.invalidate();
|
|
312
|
-
})
|
|
313
|
-
.catch(() => {});
|
|
314
|
-
}
|
|
315
|
-
text.setText(
|
|
316
|
-
renderCtx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`,
|
|
317
|
-
);
|
|
318
|
-
return text;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const fallback = result.content?.[0];
|
|
322
|
-
const fallbackText =
|
|
323
|
-
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
324
|
-
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
|
+
);
|
|
325
308
|
return text;
|
|
326
309
|
},
|
|
327
310
|
});
|
package/src/tools/read.ts
CHANGED
|
@@ -39,6 +39,9 @@ export function registerReadTool(
|
|
|
39
39
|
pi.registerTool({
|
|
40
40
|
...origRead,
|
|
41
41
|
name: "read",
|
|
42
|
+
// Full-width framing baked at termW(); default Box shell pads x by 1
|
|
43
|
+
// and re-wraps at width-2, splitting every line into a padding row.
|
|
44
|
+
renderShell: "self",
|
|
42
45
|
|
|
43
46
|
async execute(
|
|
44
47
|
tid: string,
|
package/src/tools/write.ts
CHANGED
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,
|
|
8
9
|
FG_LNUM,
|
|
9
10
|
FG_RULE,
|
|
11
|
+
FG_YELLOW,
|
|
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,12 +51,85 @@ 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 (yellow 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_YELLOW}${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;
|
|
113
|
+
let _termWResizeBound = false;
|
|
114
|
+
|
|
115
|
+
function _bindTermWResize(): void {
|
|
116
|
+
if (_termWResizeBound) return;
|
|
117
|
+
_termWResizeBound = true;
|
|
118
|
+
// Persistent listeners: every SIGWINCH invalidates the cache so the next
|
|
119
|
+
// termW() re-reads. `.once` only caught the first resize, leaving width
|
|
120
|
+
// stale on subsequent resizes.
|
|
121
|
+
const invalidate = () => {
|
|
122
|
+
_cachedTermW = undefined;
|
|
123
|
+
};
|
|
124
|
+
process.stdout.on("resize", invalidate);
|
|
125
|
+
process.stdin.on("resize", invalidate);
|
|
126
|
+
}
|
|
52
127
|
|
|
53
128
|
/** Read terminal width — checks all available sources in priority order.
|
|
54
129
|
* Falls back to querying the controlling tty via fd 1/2/stdin ioctl.
|
|
55
130
|
* Result is cached and invalidated on SIGWINCH / stdout resize. */
|
|
56
131
|
export function termW(): number {
|
|
132
|
+
_bindTermWResize();
|
|
57
133
|
if (_cachedTermW !== undefined) return _cachedTermW;
|
|
58
134
|
|
|
59
135
|
const stderrWithColumns = process.stderr as NodeJS.WriteStream & {
|
|
@@ -67,14 +143,6 @@ export function termW(): number {
|
|
|
67
143
|
120;
|
|
68
144
|
_cachedTermW = Math.max(1, Math.min(raw, 210));
|
|
69
145
|
|
|
70
|
-
// Invalidate on resize so next call re-reads
|
|
71
|
-
process.stdout.once("resize", () => {
|
|
72
|
-
_cachedTermW = undefined;
|
|
73
|
-
});
|
|
74
|
-
process.stdin.once("resize", () => {
|
|
75
|
-
_cachedTermW = undefined;
|
|
76
|
-
});
|
|
77
|
-
|
|
78
146
|
return _cachedTermW;
|
|
79
147
|
}
|
|
80
148
|
|