@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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",
@@ -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
- // Delegate to utils.termW which has tty ioctl fallback + resize invalidation
205
- const stderrCols = (process.stderr as { columns?: number }).columns;
206
- const raw =
207
- process.stdout.columns ||
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} 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
  //
@@ -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 displayCmd =
92
- renderCtx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
93
- text.setText(
94
- fillToolBackground(
95
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
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
- d.text as string,
136
+ normalizedText,
118
137
  d.exitCode as number | null,
119
138
  );
120
- const lines = (d.text as string).split("\n");
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 ((d.text as string).trim()) {
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 d = result.details;
140
- if (d?._type === "findResult" && d.text) {
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<GrepRenderState>,
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
- if (d?._type === "grepResult" && d.text) {
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
- fillToolBackground(
196
- ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
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 fallback = result.content?.[0];
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
  },
@@ -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<MultiGrepRenderState>,
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
- if (d && "_type" in d && d._type === "grepResult" && d.text) {
301
- const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
302
- if (renderCtx.state._mgk !== key) {
303
- renderCtx.state._mgk = key;
304
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
305
- renderCtx.state._mgt = ` ${info}`;
306
-
307
- renderGrepResults(d.text, d.pattern)
308
- .then((rendered: string) => {
309
- if (renderCtx.state._mgk !== key) return;
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,
@@ -47,6 +47,7 @@ export function registerWriteTool(
47
47
  pi.registerTool({
48
48
  ...origWrite,
49
49
  name: "write",
50
+ renderShell: "self",
50
51
 
51
52
  async execute(
52
53
  tid: string,
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