@heyhuynhgiabuu/pi-pretty 0.4.2 → 0.4.4

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": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
5
5
  "author": "huynhgiabuu",
6
6
  "license": "MIT",
@@ -0,0 +1,28 @@
1
+ # pi-pretty 0.4.4
2
+
3
+ ## Summary
4
+ This patch release fixes terminal background tearing/misaligned cells by enforcing the pi-tui line-width contract for background-filled tool output.
5
+
6
+ ## What changed
7
+ - Clamp every background-filled tool line to the active terminal width before adding background padding.
8
+ - Use `@mariozechner/pi-tui` `truncateToWidth()` and `visibleWidth()` so ANSI escapes and wide Unicode cells are measured correctly.
9
+ - Preserve the configured tool background after ANSI reset sequences even when truncation occurs.
10
+ - Remove the 80-column minimum width floor so narrow terminals do not receive over-wide rendered lines.
11
+ - Replace custom read-output truncation with pi-tui's ANSI-aware truncation helper.
12
+ - Add regression coverage for expanded ANSI tool headers, wide characters, and narrow terminal widths.
13
+
14
+ ## Files
15
+ - `src/index.ts`
16
+ - `test/bash-rendering.test.ts`
17
+ - `package.json`
18
+ - `package-lock.json`
19
+
20
+ ## Verification
21
+ - `npm run lint` ✅
22
+ - `npm run typecheck` ✅
23
+ - `npm test` ✅ (69 tests)
24
+
25
+ ## Upgrade notes
26
+ No configuration changes are required.
27
+
28
+ This release specifically follows the pi-tui requirement that rendered component lines must not exceed the available width, preventing terminal auto-wrap artifacts in red/error and themed tool backgrounds.
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import type {
41
41
  ReadToolInput,
42
42
  ToolRenderResultOptions,
43
43
  } from "@mariozechner/pi-coding-agent";
44
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
44
45
  import { codeToANSI } from "@shikijs/cli";
45
46
  import type { BundledLanguage, BundledTheme } from "shiki";
46
47
 
@@ -134,13 +135,11 @@ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
134
135
  }
135
136
 
136
137
  /** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
137
- * Call once when theme is first available. Idempotent. */
138
- let _bgBaseResolved = false;
138
+ * Recompute on each render so runtime theme changes are respected. */
139
139
  function resolveBaseBackground(theme: BgTheme | null | undefined): void {
140
- if (_bgBaseResolved || !theme?.getBgAnsi) return;
141
- _bgBaseResolved = true;
140
+ if (!theme?.getBgAnsi) return;
142
141
 
143
- BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? BG_DEFAULT;
142
+ BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
144
143
  BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
145
144
  RST = `\x1b[0m${BG_BASE}`;
146
145
  }
@@ -150,7 +149,6 @@ function renderToolError(error: string, theme: FgTheme): string {
150
149
  }
151
150
 
152
151
  const ESC_RE = "\u001b";
153
- const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
154
152
  const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
155
153
 
156
154
  // ---------------------------------------------------------------------------
@@ -176,10 +174,6 @@ function normalizeShikiContrast(ansi: string): string {
176
174
  // Utilities
177
175
  // ---------------------------------------------------------------------------
178
176
 
179
- function strip(s: string): string {
180
- return s.replace(ANSI_RE, "");
181
- }
182
-
183
177
  function normalizeLineEndings(text: string): string {
184
178
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
185
179
  }
@@ -197,8 +191,9 @@ function fillToolBackground(text: string, bg = BG_BASE): string {
197
191
  .split("\n")
198
192
  .map((line) => {
199
193
  const normalized = preserveToolBackground(line, bg);
200
- const padding = Math.max(0, width - strip(normalized).length);
201
- return `${bg}${normalized}${" ".repeat(padding)}${RST}`;
194
+ const fitted = preserveToolBackground(truncateToWidth(normalized, width, ""), bg);
195
+ const padding = Math.max(0, width - visibleWidth(fitted));
196
+ return `${bg}${fitted}${" ".repeat(padding)}${RST}`;
202
197
  })
203
198
  .join("\n");
204
199
  }
@@ -207,7 +202,7 @@ function termW(): number {
207
202
  const stderrWithColumns = process.stderr as NodeJS.WriteStream & { columns?: number };
208
203
  const raw =
209
204
  process.stdout.columns || stderrWithColumns.columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
210
- return Math.max(80, Math.min(raw - 4, 210));
205
+ return Math.max(1, Math.min(raw - 4, 210));
211
206
  }
212
207
 
213
208
  function shortPath(cwd: string, home: string, p: string): string {
@@ -440,47 +435,6 @@ export const __imageInternals = {
440
435
  },
441
436
  };
442
437
 
443
- /**
444
- * Render base64 image inline using iTerm2 inline image protocol.
445
- * Protocol: ESC ] 1337 ; File=[args] : base64data BEL
446
- */
447
- function renderIterm2Image(base64Data: string, opts: { width?: string; name?: string } = {}): string {
448
- const args: string[] = ["inline=1", "preserveAspectRatio=1"];
449
- if (opts.width) args.push(`width=${opts.width}`);
450
- if (opts.name) args.push(`name=${Buffer.from(opts.name).toString("base64")}`);
451
- const byteSize = Math.ceil((base64Data.length * 3) / 4);
452
- args.push(`size=${byteSize}`);
453
- const seq = `\x1b]1337;File=${args.join(";")}:${base64Data}\x07`;
454
- return tmuxWrap(seq);
455
- }
456
-
457
- /**
458
- * Render base64 image inline using Kitty graphics protocol.
459
- * Protocol: ESC _G <key>=<value>,...; <base64data> ESC \
460
- * Chunked in 4096-byte pieces as required by protocol.
461
- * Supported by: Kitty, Ghostty
462
- */
463
- function renderKittyImage(base64Data: string, opts: { cols?: number } = {}): string {
464
- const chunks: string[] = [];
465
- const CHUNK_SIZE = 4096;
466
-
467
- for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
468
- const chunk = base64Data.slice(i, i + CHUNK_SIZE);
469
- const isFirst = i === 0;
470
- const isLast = i + CHUNK_SIZE >= base64Data.length;
471
- const more = isLast ? 0 : 1;
472
-
473
- if (isFirst) {
474
- const colPart = opts.cols ? `,c=${opts.cols}` : "";
475
- chunks.push(tmuxWrap(`\x1b_Ga=T,f=100,t=d,m=${more}${colPart};${chunk}\x1b\\`));
476
- } else {
477
- chunks.push(tmuxWrap(`\x1b_Gm=${more};${chunk}\x1b\\`));
478
- }
479
- }
480
-
481
- return chunks.join("");
482
- }
483
-
484
438
  /**
485
439
  * Get human-readable file size
486
440
  */
@@ -664,7 +618,7 @@ async function renderFileContent(
664
618
  const endLine = startLine + show.length - 1;
665
619
  const nw = Math.max(3, String(endLine).length);
666
620
  const gw = nw + 3; // num + " │ "
667
- const cw = Math.max(20, tw - gw);
621
+ const cw = Math.max(1, tw - gw);
668
622
 
669
623
  const out: string[] = [];
670
624
  out.push(rule(tw));
@@ -672,25 +626,7 @@ async function renderFileContent(
672
626
  for (let i = 0; i < hl.length; i++) {
673
627
  const ln = startLine + i;
674
628
  const code = hl[i] ?? show[i] ?? "";
675
- const plain = strip(code);
676
- // Truncate if wider than available
677
- let display = code;
678
- if (plain.length > cw) {
679
- let vis = 0;
680
- let j = 0;
681
- while (j < code.length && vis < cw - 1) {
682
- if (code[j] === "\x1b") {
683
- const e = code.indexOf("m", j);
684
- if (e !== -1) {
685
- j = e + 1;
686
- continue;
687
- }
688
- }
689
- vis++;
690
- j++;
691
- }
692
- display = `${code.slice(0, j)}${RST}${FG_DIM}›${RST}`;
693
- }
629
+ const display = truncateToWidth(code, cw, `${FG_DIM}›`);
694
630
  out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
695
631
  }
696
632
 
@@ -1156,8 +1092,9 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1156
1092
  if (_fffPartialIndex) {
1157
1093
  ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
1158
1094
  } else {
1159
- ctx.ui?.setStatus?.("fff", "FFF indexed");
1160
- setTimeout(() => ctx.ui?.setStatus?.("fff", undefined), 3000);
1095
+ const ui = ctx.ui;
1096
+ ui?.setStatus?.("fff", "FFF indexed");
1097
+ setTimeout(() => ui?.setStatus?.("fff", undefined), 3000);
1161
1098
  }
1162
1099
  } catch (error: unknown) {
1163
1100
  ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
@@ -1242,45 +1179,15 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1242
1179
 
1243
1180
  const d = result.details as RenderDetails | undefined;
1244
1181
 
1245
- // Image rendering
1182
+ // Image reads keep the original image content so Pi's native TUI renderer
1183
+ // can display it exactly once. pi-pretty only renders metadata here;
1184
+ // rendering another inline image caused duplicate previews.
1246
1185
  if (d?._type === "readImage") {
1247
- const tw = termW();
1248
- const out: string[] = [];
1249
- const fname = basename(d.filePath);
1250
1186
  const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
1251
1187
  const sizeStr = humanSize(byteSize);
1252
1188
  const mimeStr = d.mimeType ?? "image";
1253
1189
 
1254
- out.push(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`);
1255
- out.push(rule(tw));
1256
-
1257
- const protocol = detectImageProtocol();
1258
- const passthroughWarning = getTmuxPassthroughWarning(protocol);
1259
- if (passthroughWarning) {
1260
- out.push(` ${FG_YELLOW}${passthroughWarning}${RST}`);
1261
- } else if (protocol === "kitty") {
1262
- if (d.mimeType && d.mimeType !== "image/png") {
1263
- out.push(
1264
- ` ${FG_YELLOW}Kitty/Ghostty inline preview currently supports PNG payloads (got ${d.mimeType})${RST}`,
1265
- );
1266
- } else {
1267
- const imgCols = Math.min(tw - 4, 80);
1268
- out.push(renderKittyImage(d.data, { cols: imgCols }));
1269
- }
1270
- } else if (protocol === "iterm2") {
1271
- const imgWidth = Math.min(tw - 4, 80);
1272
- out.push(
1273
- renderIterm2Image(d.data, {
1274
- width: `${imgWidth}`,
1275
- name: fname,
1276
- }),
1277
- );
1278
- } else {
1279
- out.push(` ${FG_DIM}(Inline image preview requires Ghostty, iTerm2, WezTerm, or Kitty)${RST}`);
1280
- }
1281
-
1282
- out.push(rule(tw));
1283
- text.setText(fillToolBackground(out.join("\n")));
1190
+ text.setText(fillToolBackground(` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`));
1284
1191
  return text;
1285
1192
  }
1286
1193
 
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "@mariozechner/pi-tui";
1
2
  import { describe, expect, it } from "vitest";
2
3
 
3
4
  import piPrettyExtension from "../src/index.js";
@@ -18,6 +19,11 @@ const mockTheme = {
18
19
  bold: (text: string) => text,
19
20
  };
20
21
 
22
+ const ansiMockTheme = {
23
+ fg: (_key: string, text: string) => `\x1b[31m${text}\x1b[0m`,
24
+ bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
25
+ };
26
+
21
27
  function mockToolFactory(exec: any) {
22
28
  return (_cwd: string) => ({
23
29
  name: "mock",
@@ -27,6 +33,20 @@ function mockToolFactory(exec: any) {
27
33
  });
28
34
  }
29
35
 
36
+ function withStdoutColumns<T>(columns: number, fn: () => T): T {
37
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
38
+ Object.defineProperty(process.stdout, "columns", { configurable: true, value: columns });
39
+ try {
40
+ return fn();
41
+ } finally {
42
+ if (descriptor) {
43
+ Object.defineProperty(process.stdout, "columns", descriptor);
44
+ } else {
45
+ delete (process.stdout as NodeJS.WriteStream & { columns?: number }).columns;
46
+ }
47
+ }
48
+ }
49
+
30
50
  function loadBashTool() {
31
51
  const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
32
52
  const tools = new Map<string, any>();
@@ -106,4 +126,42 @@ describe("bash renderCall expansion", () => {
106
126
  expect(collapsed.getText()).toContain("5s timeout");
107
127
  expect(expanded.getText()).toContain("5s timeout");
108
128
  });
129
+
130
+ it("truncates expanded ANSI tool headers to the terminal width before padding backgrounds", () => {
131
+ withStdoutColumns(84, () => {
132
+ const bashTool = loadBashTool();
133
+ const command = `printf '${"界".repeat(120)}'`;
134
+
135
+ const rendered = bashTool.renderCall({ command }, ansiMockTheme, {
136
+ lastComponent: new MockText(),
137
+ isError: false,
138
+ state: {},
139
+ expanded: true,
140
+ invalidate: () => {},
141
+ });
142
+
143
+ for (const line of rendered.getText().split("\n")) {
144
+ expect(visibleWidth(line)).toBeLessThanOrEqual(80);
145
+ }
146
+ });
147
+ });
148
+
149
+ it("does not exceed narrow terminal widths", () => {
150
+ withStdoutColumns(24, () => {
151
+ const bashTool = loadBashTool();
152
+ const command = `printf '${"x".repeat(120)}'`;
153
+
154
+ const rendered = bashTool.renderCall({ command }, ansiMockTheme, {
155
+ lastComponent: new MockText(),
156
+ isError: false,
157
+ state: {},
158
+ expanded: true,
159
+ invalidate: () => {},
160
+ });
161
+
162
+ for (const line of rendered.getText().split("\n")) {
163
+ expect(visibleWidth(line)).toBeLessThanOrEqual(20);
164
+ }
165
+ });
166
+ });
109
167
  });
@@ -11,7 +11,7 @@
11
11
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { describe, it, expect, vi, beforeEach } from "vitest";
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
15
15
  import { CursorStore, fffFormatGrepText } from "../src/fff-helpers.js";
16
16
  import piPrettyExtension, { type PiPrettyDeps } from "../src/index.js";
17
17
  import {
@@ -314,6 +314,7 @@ describe("piPrettyExtension integration", () => {
314
314
  }
315
315
 
316
316
  beforeEach(() => {
317
+ vi.useRealTimers();
317
318
  tools = new Map();
318
319
  events = new Map();
319
320
  mockPi = {
@@ -336,6 +337,10 @@ describe("piPrettyExtension integration", () => {
336
337
  piPrettyExtension(mockPi, deps);
337
338
  }
338
339
 
340
+ afterEach(() => {
341
+ vi.useRealTimers();
342
+ });
343
+
339
344
  async function loadWithFFF(finderOverrides?: Record<string, any>) {
340
345
  load(true, finderOverrides);
341
346
  const start = events.get("session_start")!;
@@ -714,6 +719,29 @@ describe("piPrettyExtension integration", () => {
714
719
  }));
715
720
  });
716
721
 
722
+ it("delayed FFF status clear does not read a stale session ctx", async () => {
723
+ vi.useFakeTimers();
724
+ const setStatus = vi.fn();
725
+ let stale = false;
726
+ const ctx = {
727
+ cwd: "/tmp/test",
728
+ get ui() {
729
+ if (stale) throw new Error("stale ctx");
730
+ return { setStatus };
731
+ },
732
+ };
733
+
734
+ load(true);
735
+ const start = events.get("session_start")!;
736
+ await start({}, ctx);
737
+ stale = true;
738
+
739
+ vi.advanceTimersByTime(3000);
740
+
741
+ expect(setStatus).toHaveBeenNthCalledWith(1, "fff", "FFF indexed");
742
+ expect(setStatus).toHaveBeenNthCalledWith(2, "fff", undefined);
743
+ });
744
+
717
745
  it("shutdown → subsequent find falls back to SDK", async () => {
718
746
  await loadWithFFF();
719
747
  await events.get("session_shutdown")!();
@@ -128,11 +128,8 @@ describe("image rendering terminal detection", () => {
128
128
  expect(__imageInternals.getTmuxPassthroughWarning("kitty")).toBeNull();
129
129
  });
130
130
 
131
- it("renders explicit warning for read image when tmux passthrough is off", async () => {
132
- process.env.TMUX = "/tmp/tmux-1000/default,123,0";
133
- process.env.TERM_PROGRAM = "tmux";
134
- process.env.KITTY_WINDOW_ID = "1";
135
- __imageInternals.setTmuxAllowPassthroughOverrideForTests(false);
131
+ it("renders image metadata without a second inline preview", async () => {
132
+ process.env.TERM_PROGRAM = "kitty";
136
133
 
137
134
  const readTool = loadReadTool(async () => ({
138
135
  content: [{ type: "image", data: Buffer.from("fake").toString("base64"), mimeType: "image/png" }],
@@ -147,25 +144,10 @@ describe("image rendering terminal detection", () => {
147
144
  invalidate: () => {},
148
145
  });
149
146
 
150
- expect(rendered.getText()).toContain("allow-passthrough is off");
151
- });
152
-
153
- it("warns on non-PNG payloads for kitty protocol", async () => {
154
- process.env.TERM_PROGRAM = "kitty";
155
-
156
- const readTool = loadReadTool(async () => ({
157
- content: [{ type: "image", data: Buffer.from("jpeg").toString("base64"), mimeType: "image/jpeg" }],
158
- }));
159
-
160
- const result = await readTool.execute("t1", { path: "media/photo.jpg" }, null, null, {});
161
- const rendered = readTool.renderResult(result, {}, {}, {
162
- lastComponent: new MockText(),
163
- isError: false,
164
- state: {},
165
- expanded: false,
166
- invalidate: () => {},
167
- });
168
-
169
- expect(rendered.getText()).toContain("supports PNG payloads");
147
+ expect(rendered.getText()).toContain("image/png");
148
+ expect(rendered.getText()).not.toContain("\x1b_G");
149
+ expect(result.content).toEqual([
150
+ { type: "image", data: Buffer.from("fake").toString("base64"), mimeType: "image/png" },
151
+ ]);
170
152
  });
171
153
  });