@heyhuynhgiabuu/pi-pretty 0.5.1 → 0.5.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": "@heyhuynhgiabuu/pi-pretty",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
package/src/index.ts CHANGED
@@ -185,8 +185,8 @@ function preserveToolBackground(ansi: string, bg: string): string {
185
185
  });
186
186
  }
187
187
 
188
- function fillToolBackground(text: string, bg = BG_BASE): string {
189
- const width = termW();
188
+ function fillToolBackground(text: string, bg = BG_BASE, width?: number): string {
189
+ if (width === undefined) width = termW();
190
190
  return text
191
191
  .split("\n")
192
192
  .map((line) => {
@@ -199,9 +199,16 @@ function fillToolBackground(text: string, bg = BG_BASE): string {
199
199
  }
200
200
 
201
201
  function termW(): number {
202
- const stderrWithColumns = process.stderr as NodeJS.WriteStream & { columns?: number };
202
+ // When process.stdout.columns is available (real terminal or compositor override),
203
+ // use it directly — the TUI/compositor already provides the exact content width.
204
+ // The -4 safety margin only applies to fallback values (stderr.columns, env.COLUMNS, default).
205
+ if (process.stdout.columns) {
206
+ return Math.max(1, Math.min(process.stdout.columns, 210));
207
+ }
203
208
  const raw =
204
- process.stdout.columns || stderrWithColumns.columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
209
+ (process.stderr as NodeJS.WriteStream & { columns?: number }).columns ||
210
+ Number.parseInt(process.env.COLUMNS ?? "", 10) ||
211
+ 200;
205
212
  return Math.max(1, Math.min(raw - 4, 210));
206
213
  }
207
214
 
@@ -605,6 +612,7 @@ async function renderFileContent(
605
612
  filePath: string,
606
613
  offset = 1,
607
614
  maxLines = MAX_PREVIEW_LINES,
615
+ width?: number,
608
616
  ): Promise<string> {
609
617
  const normalizedContent = normalizeLineEndings(content);
610
618
  const lines = normalizedContent.split("\n");
@@ -613,7 +621,7 @@ async function renderFileContent(
613
621
  const lg = lang(filePath);
614
622
  const hl = await hlBlock(show.join("\n"), lg);
615
623
 
616
- const tw = termW();
624
+ const tw = width ?? termW();
617
625
  const startLine = offset;
618
626
  const endLine = startLine + show.length - 1;
619
627
  const nw = Math.max(3, String(endLine).length);
@@ -780,6 +788,77 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
780
788
  return out.join("\n");
781
789
  }
782
790
 
791
+ // ---------------------------------------------------------------------------
792
+ // Tool metrics — elapsed time + output size
793
+ // pi-droid-styling-inspired: wrap execute to record performance, display in footer.
794
+ // ---------------------------------------------------------------------------
795
+
796
+ const ELAPSED_KEY = "__prettyElapsedMs";
797
+ const CHARS_KEY = "__prettyOutputChars";
798
+
799
+ /** Format milliseconds for display. */
800
+ function formatElapsedMs(ms: number | undefined): string {
801
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
802
+ if (ms < 1000) return `${Math.round(ms)}ms`;
803
+ const s = ms / 1000;
804
+ return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
805
+ }
806
+
807
+ /** Format character count for display. */
808
+ function formatCharCount(chars: number | undefined): string {
809
+ if (typeof chars !== "number" || !Number.isFinite(chars) || chars <= 0) return "";
810
+ if (chars < 1000) return `${chars} chars`;
811
+ if (chars < 10_000) return `${(chars / 1000).toFixed(1)}k chars`;
812
+ return `${Math.round(chars / 1000)}k chars`;
813
+ }
814
+
815
+ /** Compute text output length from a tool result. */
816
+ function getOutputCharCount(result: ToolResultLike): number {
817
+ const content = result.content;
818
+ if (!Array.isArray(content)) return 0;
819
+ let length = 0;
820
+ for (const block of content) {
821
+ if (block.type !== "text") continue;
822
+ length += String(block.text ?? "").replace(/\r/g, "").length;
823
+ }
824
+ return length;
825
+ }
826
+
827
+ /**
828
+ * Wrap a tool's execute function to measure elapsed time and output size.
829
+ * Annotates result.details with __prettyElapsedMs and __prettyOutputChars.
830
+ */
831
+ function wrapExecuteWithMetrics<TParams, TDetails>(
832
+ execute: (...args: any[]) => Promise<ToolResultLike<TDetails>>,
833
+ ): ToolExecutor<TParams, TDetails> {
834
+ return async (
835
+ tid: string,
836
+ params: TParams,
837
+ sig?: AbortSignal,
838
+ onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
839
+ ctx?: ExtensionContext,
840
+ ) => {
841
+ const start = performance.now();
842
+ const result = await execute(tid, params, sig, onUpdate, ctx);
843
+ const elapsedMs = performance.now() - start;
844
+ const details = (result.details ?? {}) as Record<string, unknown>;
845
+ details[ELAPSED_KEY] = elapsedMs;
846
+ details[CHARS_KEY] = getOutputCharCount(result);
847
+ (result as { details: Record<string, unknown> }).details = details;
848
+ return result;
849
+ };
850
+ }
851
+
852
+ /** Render a tool metrics line: "3.2s · 14.2k chars" */
853
+ function renderToolMetrics(result: ToolResultLike): string {
854
+ const details = result.details as Record<string, unknown> | undefined;
855
+ if (!details) return "";
856
+ const elapsed = formatElapsedMs(details[ELAPSED_KEY] as number | undefined);
857
+ const chars = formatCharCount(details[CHARS_KEY] as number | undefined);
858
+ if (!elapsed && !chars) return "";
859
+ return `${FG_DIM}· ${[elapsed, chars].filter(Boolean).join(" · ")}${RST}`;
860
+ }
861
+
783
862
  // ---------------------------------------------------------------------------
784
863
  // FFF integration (optional) — Fast File Finder with frecency & SIMD search
785
864
  //
@@ -1046,6 +1125,53 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1046
1125
  return !disabledTools.has(name.toLowerCase());
1047
1126
  }
1048
1127
 
1128
+ // ===================================================================
1129
+ // Generic renderResult for custom tools (no custom renderer)
1130
+ // ===================================================================
1131
+
1132
+ const origRegisterTool = pi.registerTool.bind(pi);
1133
+ pi.registerTool = (tool: any) => {
1134
+ if (!tool.renderResult && !tool.renderCall) {
1135
+ const toolName = tool.label ?? tool.name ?? "tool";
1136
+ tool.renderResult = (result: any, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) => {
1137
+ resolveBaseBackground(theme);
1138
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1139
+
1140
+ if (ctx.isError) {
1141
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1142
+ return text;
1143
+ }
1144
+
1145
+ const content = getTextContent(result);
1146
+ if (content) {
1147
+ const renderWidth = termW();
1148
+ const lines = content.split("\n");
1149
+ const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
1150
+ const preview = lines.slice(0, maxShow).join("\n");
1151
+ const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
1152
+ const metrics = renderToolMetrics(result);
1153
+ text.setText(
1154
+ fillToolBackground(` ${preview}${more}${metrics ? `\n ${metrics}` : ""}`, undefined, renderWidth),
1155
+ );
1156
+ } else {
1157
+ text.setText(fillToolBackground(` ${theme.fg("dim", "(no text output)")}`));
1158
+ }
1159
+
1160
+ return text;
1161
+ };
1162
+
1163
+ tool.renderCall = (args: any, theme: ThemeLike, ctx: RenderContextLike) => {
1164
+ resolveBaseBackground(theme);
1165
+ const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1166
+ text.setText(
1167
+ fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`),
1168
+ );
1169
+ return text;
1170
+ };
1171
+ }
1172
+ origRegisterTool(tool);
1173
+ };
1174
+
1049
1175
  // ===================================================================
1050
1176
  // FFF initialization (optional — graceful fallback to SDK)
1051
1177
  // ===================================================================
@@ -1127,13 +1253,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1127
1253
  ...origRead,
1128
1254
  name: "read",
1129
1255
 
1130
- async execute(
1256
+ execute: wrapExecuteWithMetrics(async (
1131
1257
  tid: string,
1132
1258
  params: ReadParams,
1133
1259
  sig: AbortSignal | undefined,
1134
1260
  upd: AgentToolUpdateCallback<unknown> | undefined,
1135
1261
  ctx: ExtensionContext,
1136
- ) {
1262
+ ) => {
1137
1263
  const result = (await origRead.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1138
1264
 
1139
1265
  const fp = params.path ?? "";
@@ -1164,7 +1290,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1164
1290
  }
1165
1291
 
1166
1292
  return result;
1167
- },
1293
+ }),
1168
1294
 
1169
1295
  renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
1170
1296
  resolveBaseBackground(theme);
@@ -1204,22 +1330,24 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1204
1330
  }
1205
1331
 
1206
1332
  if (d?._type === "readFile" && d.content) {
1207
- const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
1333
+ const renderWidth = termW();
1334
+ const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${renderWidth}`;
1208
1335
  if (ctx.state._rk !== key) {
1209
1336
  ctx.state._rk = key;
1210
- const info = `${FG_DIM}${d.lineCount} lines${RST}`;
1211
- ctx.state._rt = fillToolBackground(` ${info}`);
1337
+ const metrics = renderToolMetrics(result);
1338
+ const info = `${FG_DIM}${d.lineCount} lines${RST}${metrics}`;
1339
+ ctx.state._rt = fillToolBackground(` ${info}`, undefined, renderWidth);
1212
1340
 
1213
1341
  const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
1214
- renderFileContent(d.content, d.filePath, d.offset, maxShow)
1342
+ renderFileContent(d.content, d.filePath, d.offset, maxShow, renderWidth)
1215
1343
  .then((rendered: string) => {
1216
1344
  if (ctx.state._rk !== key) return;
1217
- ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`);
1345
+ ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
1218
1346
  ctx.invalidate();
1219
1347
  })
1220
1348
  .catch(() => {});
1221
1349
  }
1222
- text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`));
1350
+ text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
1223
1351
  return text;
1224
1352
  }
1225
1353
 
@@ -1243,13 +1371,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1243
1371
  ...origBash,
1244
1372
  name: "bash",
1245
1373
 
1246
- async execute(
1374
+ execute: wrapExecuteWithMetrics(async (
1247
1375
  tid: string,
1248
1376
  params: BashParams,
1249
1377
  sig: AbortSignal | undefined,
1250
1378
  upd: AgentToolUpdateCallback<unknown> | undefined,
1251
1379
  ctx: ExtensionContext,
1252
- ) {
1380
+ ) => {
1253
1381
  const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1254
1382
  const textContent = getTextContent(result);
1255
1383
 
@@ -1270,7 +1398,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1270
1398
  });
1271
1399
 
1272
1400
  return result;
1273
- },
1401
+ }),
1274
1402
 
1275
1403
  renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
1276
1404
  resolveBaseBackground(theme);
@@ -1300,7 +1428,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1300
1428
  const { summary } = renderBashOutput(d.text, d.exitCode);
1301
1429
  const lines = d.text.split("\n");
1302
1430
  const lineCount = lines.length;
1303
- const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
1431
+ const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}` : ` ${renderToolMetrics(result)}`;
1304
1432
  const header = ` ${summary}${lineInfo}`;
1305
1433
 
1306
1434
  if (d.text.trim()) {
@@ -1341,13 +1469,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1341
1469
  ...origLs,
1342
1470
  name: "ls",
1343
1471
 
1344
- async execute(
1472
+ execute: wrapExecuteWithMetrics(async (
1345
1473
  tid: string,
1346
1474
  params: LsParams,
1347
1475
  sig: AbortSignal | undefined,
1348
1476
  upd: AgentToolUpdateCallback<unknown> | undefined,
1349
1477
  ctx: ExtensionContext,
1350
- ) {
1478
+ ) => {
1351
1479
  const result = (await origLs.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1352
1480
  const textContent = getTextContent(result);
1353
1481
  const fp = params.path ?? cwd;
@@ -1361,7 +1489,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1361
1489
  });
1362
1490
 
1363
1491
  return result;
1364
- },
1492
+ }),
1365
1493
 
1366
1494
  renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
1367
1495
  resolveBaseBackground(theme);
@@ -1383,7 +1511,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1383
1511
  const d = result.details as RenderDetails | undefined;
1384
1512
  if (d?._type === "lsResult" && d.text) {
1385
1513
  const tree = renderTree(d.text, d.path);
1386
- const info = `${FG_DIM}${d.entryCount} entries${RST}`;
1514
+ const info = `${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}`;
1387
1515
  text.setText(fillToolBackground(` ${info}\n${tree}`));
1388
1516
  return text;
1389
1517
  }
@@ -1407,13 +1535,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1407
1535
  ...origFind,
1408
1536
  name: "find",
1409
1537
 
1410
- async execute(
1538
+ execute: wrapExecuteWithMetrics(async (
1411
1539
  tid: string,
1412
1540
  params: FindParams,
1413
1541
  sig: AbortSignal | undefined,
1414
1542
  upd: unknown,
1415
1543
  ctx: ExtensionContext,
1416
- ) {
1544
+ ) => {
1417
1545
  // Try FFF first (frecency-ranked, SIMD-accelerated)
1418
1546
  if (_fffFinder && !_fffFinder.isDestroyed) {
1419
1547
  try {
@@ -1456,7 +1584,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1456
1584
  });
1457
1585
 
1458
1586
  return result;
1459
- },
1587
+ }),
1460
1588
 
1461
1589
  renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
1462
1590
  resolveBaseBackground(theme);
@@ -1486,7 +1614,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1486
1614
  const d = result.details;
1487
1615
  if (d?._type === "findResult" && d.text) {
1488
1616
  const rendered = renderFindResults(d.text);
1489
- const info = `${FG_DIM}${d.matchCount} files${RST}`;
1617
+ const info = `${FG_DIM}${d.matchCount} files${RST}${renderToolMetrics(result)}`;
1490
1618
  text.setText(fillToolBackground(` ${info}\n${rendered}`));
1491
1619
  return text;
1492
1620
  }
@@ -1510,13 +1638,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1510
1638
  ...origGrep,
1511
1639
  name: "grep",
1512
1640
 
1513
- async execute(
1641
+ execute: wrapExecuteWithMetrics(async (
1514
1642
  tid: string,
1515
1643
  params: GrepParams,
1516
1644
  sig: AbortSignal | undefined,
1517
1645
  upd: unknown,
1518
1646
  ctx: ExtensionContext,
1519
- ) {
1647
+ ) => {
1520
1648
  // Try FFF first (SIMD-accelerated, frecency-ranked).
1521
1649
  // FFF 0.5.2 can abort the process when path/glob constraints meet
1522
1650
  // Unicode filenames, so constrained searches use the SDK fallback.
@@ -1576,7 +1704,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1576
1704
  });
1577
1705
 
1578
1706
  return result;
1579
- },
1707
+ }),
1580
1708
 
1581
1709
  renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1582
1710
  resolveBaseBackground(theme);
@@ -1608,21 +1736,23 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1608
1736
 
1609
1737
  const d = result.details;
1610
1738
  if (d?._type === "grepResult" && d.text) {
1611
- const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
1739
+ const renderWidth = termW();
1740
+ const key = `grep:${d.pattern}:${d.matchCount}:${renderWidth}`;
1612
1741
  if (ctx.state._gk !== key) {
1613
1742
  ctx.state._gk = key;
1614
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1615
- ctx.state._gt = fillToolBackground(` ${info}`);
1743
+ const metrics = renderToolMetrics(result);
1744
+ const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
1745
+ ctx.state._gt = fillToolBackground(` ${info}`, undefined, renderWidth);
1616
1746
 
1617
1747
  renderGrepResults(d.text, d.pattern)
1618
1748
  .then((rendered: string) => {
1619
1749
  if (ctx.state._gk !== key) return;
1620
- ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`);
1750
+ ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
1621
1751
  ctx.invalidate();
1622
1752
  })
1623
1753
  .catch(() => {});
1624
1754
  }
1625
- text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`));
1755
+ text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
1626
1756
  return text;
1627
1757
  }
1628
1758
 
@@ -1689,13 +1819,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1689
1819
  required: ["patterns"],
1690
1820
  },
1691
1821
 
1692
- async execute(
1822
+ execute: wrapExecuteWithMetrics(async (
1693
1823
  tid: string,
1694
1824
  params: MultiGrepParams,
1695
1825
  sig: AbortSignal | undefined,
1696
1826
  upd: unknown,
1697
1827
  ctx: ExtensionContext,
1698
- ) {
1828
+ ) => {
1699
1829
  if (sig?.aborted) return makeTextResult("Aborted", {});
1700
1830
 
1701
1831
  if (!params.patterns || params.patterns.length === 0) {
@@ -1813,7 +1943,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1813
1943
  const message = getErrorMessage(error);
1814
1944
  return makeTextResult(`multi_grep error: ${message}`, { error: message });
1815
1945
  }
1816
- },
1946
+ }),
1817
1947
 
1818
1948
  renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
1819
1949
  resolveBaseBackground(theme);
@@ -1850,7 +1980,8 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1850
1980
  const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
1851
1981
  if (ctx.state._mgk !== key) {
1852
1982
  ctx.state._mgk = key;
1853
- const info = `${FG_DIM}${d.matchCount} matches${RST}`;
1983
+ const metrics = renderToolMetrics(result);
1984
+ const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
1854
1985
  ctx.state._mgt = ` ${info}`;
1855
1986
 
1856
1987
  renderGrepResults(d.text, d.pattern)
@@ -1861,7 +1992,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1861
1992
  })
1862
1993
  .catch(() => {});
1863
1994
  }
1864
- text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
1995
+ text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`);
1865
1996
  return text;
1866
1997
  }
1867
1998
 
@@ -141,7 +141,7 @@ describe("bash renderCall expansion", () => {
141
141
  });
142
142
 
143
143
  for (const line of rendered.getText().split("\n")) {
144
- expect(visibleWidth(line)).toBeLessThanOrEqual(80);
144
+ expect(visibleWidth(line)).toBeLessThanOrEqual(84);
145
145
  }
146
146
  });
147
147
  });
@@ -160,7 +160,7 @@ describe("bash renderCall expansion", () => {
160
160
  });
161
161
 
162
162
  for (const line of rendered.getText().split("\n")) {
163
- expect(visibleWidth(line)).toBeLessThanOrEqual(20);
163
+ expect(visibleWidth(line)).toBeLessThanOrEqual(24);
164
164
  }
165
165
  });
166
166
  });