@pify/pretty 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -22,10 +22,34 @@ Expand with pi's standard toggle (Ctrl+O on a tool block). Failures always show
22
22
  Each renderer toggles independently (persisted per session):
23
23
 
24
24
  ```
25
- /pretty # show which renderers are on
26
- /pretty bash # toggle one back to pi's default rendering
25
+ /pretty # show which renderers are on
26
+ /pretty bash # toggle one back to pi's default rendering
27
+ /pretty bash grep # toggle several at once (v0.2)
28
+ /pretty off # everything back to pi's rendering (v0.2)
29
+ /pretty on read # explicit on/off instead of toggling (v0.2)
30
+ /pretty reset # all renderers back on (v0.2)
27
31
  ```
28
32
 
33
+ Aliases are accepted where they're obvious: `list`/`dir` → `ls`, `search`/`rg` → `grep`, `cat` → `read`, `sh`/`shell` → `bash`.
34
+
35
+ Expanded bodies are capped at 200 lines (v0.2) — a 5,000-line diff or grep result used to render in full and scroll the conversation away.
36
+
37
+ ## Settings (v0.3)
38
+
39
+ Every cap in a renderer is somebody's taste, and the right number depends on your terminal. Put them in `.pi/pretty.json` (project) or `<agentDir>/pretty.json` (global — the project file wins):
40
+
41
+ ```json
42
+ {
43
+ "collapsedLines": 12,
44
+ "expandedLines": 200,
45
+ "diffLines": 200,
46
+ "syntaxHighlight": true,
47
+ "summaryClip": 100
48
+ }
49
+ ```
50
+
51
+ Unknown keys, wrong types, and absurd numbers are reported at session start and fall back to the shipped defaults rather than taking the renderers down — a typo tells you it was a typo instead of quietly doing nothing. `/pretty` shows the settings in force and where they came from. (The case for making these configurable is [`pi-cc-extensions`](https://github.com/minuque/pi-cc-extensions)' `/ccstyle`, which does it with a whole settings panel.)
52
+
29
53
  ## How it works
30
54
 
31
55
  The official `built-in-tool-renderer` pattern: each tool is re-registered with `createReadTool()`/`createBashTool()`/… delegating `execute` untouched, overriding only `renderCall`/`renderResult`. Highlighting uses pi's exported `highlightCode` + `getLanguageFromPath` — zero extra dependencies, colors always match your theme.
@@ -21,15 +21,27 @@ import {
21
21
  createLsTool,
22
22
  createReadTool,
23
23
  createWriteTool,
24
+ getAgentDir,
24
25
  getLanguageFromPath,
25
26
  highlightCode,
26
27
  type ExtensionAPI,
27
28
  type ExtensionContext,
28
29
  } from "@earendil-works/pi-coding-agent";
29
30
  import { Text } from "@earendil-works/pi-tui";
31
+ import { readFileSync } from "node:fs";
32
+ import { join } from "node:path";
30
33
 
31
- import { PRETTY_CONFIG, isPrettyTool, replayBranch, statusLines, toggleTool } from "../src/config.ts";
34
+ import {
35
+ PRETTY_CONFIG,
36
+ PRETTY_USAGE,
37
+ applyCommand,
38
+ parsePrettyCommand,
39
+ replayBranch,
40
+ statusLines,
41
+ } from "../src/config.ts";
32
42
  import { colorizeDiff, diffStats, statsLabel } from "../src/diff.ts";
43
+ import { limitsFrom, preview } from "../src/preview.ts";
44
+ import { DEFAULT_SETTINGS, formatSettings, resolveSettings, type PrettySettings } from "../src/settings.ts";
33
45
  import {
34
46
  bashCall,
35
47
  bashSummary,
@@ -50,8 +62,6 @@ import {
50
62
  type ThemeLike,
51
63
  } from "../src/types.ts";
52
64
 
53
- const PREVIEW_LINES = 12;
54
-
55
65
  type AnyTool = {
56
66
  name: string;
57
67
  description: string;
@@ -62,19 +72,42 @@ type AnyTool = {
62
72
 
63
73
  export default function pretty(pi: ExtensionAPI) {
64
74
  let config: PrettyConfig = DEFAULT_CONFIG;
75
+ let settings: PrettySettings = DEFAULT_SETTINGS;
76
+ let settingsSource: string | null = null;
77
+ let settingsWarnings: string[] = [];
78
+
79
+ /** Project settings win over global ones; neither is required. */
80
+ function loadSettings(cwd: string): void {
81
+ const candidates = [join(cwd, ".pi", "pretty.json"), join(getAgentDir(), "pretty.json")];
82
+ for (const file of candidates) {
83
+ let raw: string;
84
+ try {
85
+ raw = readFileSync(file, "utf8");
86
+ } catch {
87
+ continue;
88
+ }
89
+ try {
90
+ const parsed = resolveSettings(JSON.parse(raw));
91
+ settings = parsed.settings;
92
+ settingsWarnings = parsed.warnings;
93
+ settingsSource = file;
94
+ } catch (err) {
95
+ settings = DEFAULT_SETTINGS;
96
+ settingsSource = null;
97
+ settingsWarnings = [`${file}: ${err instanceof Error ? err.message : String(err)}`];
98
+ }
99
+ return;
100
+ }
101
+ settings = DEFAULT_SETTINGS;
102
+ settingsSource = null;
103
+ settingsWarnings = [];
104
+ }
65
105
  let originals: Record<PrettyTool, AnyTool> | null = null;
66
106
 
67
107
  function isFailed(result: unknown): boolean {
68
108
  return isRecord(result) && result.isError === true;
69
109
  }
70
110
 
71
- function clip(text: string, expanded: boolean): string {
72
- if (expanded) return text;
73
- const lines = text.split("\n");
74
- if (lines.length <= PREVIEW_LINES) return text;
75
- return `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…`;
76
- }
77
-
78
111
  function buildOriginals(cwd: string): Record<PrettyTool, AnyTool> {
79
112
  return {
80
113
  read: createReadTool(cwd) as unknown as AnyTool,
@@ -133,11 +166,11 @@ export default function pretty(pi: ExtensionAPI) {
133
166
  ) => {
134
167
  const output = textContent(result);
135
168
  if (options.isPartial) {
136
- return new Text(`${theme.fg("warning", "Running…")}\n${clip(output, false)}`, 0, 0);
169
+ return new Text(`${theme.fg("warning", "Running…")}\n${preview(output, false, limitsFrom(settings))}`, 0, 0);
137
170
  }
138
171
  const failed = isFailed(result);
139
172
  const summary = bashSummary(theme, output, failed);
140
- const body = output && (options.expanded || failed) ? `\n${clip(output, options.expanded === true)}` : "";
173
+ const body = output && (options.expanded || failed) ? `\n${preview(output, options.expanded === true, limitsFrom(settings))}` : "";
141
174
  return new Text(summary + body, 0, 0);
142
175
  },
143
176
  };
@@ -159,7 +192,7 @@ export default function pretty(pi: ExtensionAPI) {
159
192
  : "";
160
193
  const stats = statsLabel(theme, diffStats(diff));
161
194
  if (!options.expanded) return new Text(stats, 0, 0);
162
- return new Text(`${stats}\n${colorizeDiff(theme, diff)}`, 0, 0);
195
+ return new Text(`${stats}\n${colorizeDiff(theme, preview(diff, true, { collapsed: settings.collapsedLines, expanded: settings.diffLines }))}`, 0, 0);
163
196
  },
164
197
  };
165
198
  case "write":
@@ -200,7 +233,7 @@ export default function pretty(pi: ExtensionAPI) {
200
233
  tool === "grep" ? { one: "match", many: "matches" } : { one: "result", many: "results" },
201
234
  );
202
235
  if (!options.expanded || failed || !output) return new Text(summary, 0, 0);
203
- return new Text(`${summary}\n${clip(output, true)}`, 0, 0);
236
+ return new Text(`${summary}\n${preview(output, true, limitsFrom(settings))}`, 0, 0);
204
237
  },
205
238
  };
206
239
  case "ls":
@@ -216,7 +249,7 @@ export default function pretty(pi: ExtensionAPI) {
216
249
  const failed = isFailed(result);
217
250
  const summary = matchSummary(theme, output, failed, { one: "entry", many: "entries" });
218
251
  if (!options.expanded || failed) return new Text(summary, 0, 0);
219
- return new Text(`${summary}\n${clip(output, true)}`, 0, 0);
252
+ return new Text(`${summary}\n${preview(output, true, limitsFrom(settings))}`, 0, 0);
220
253
  },
221
254
  };
222
255
  }
@@ -242,8 +275,12 @@ export default function pretty(pi: ExtensionAPI) {
242
275
 
243
276
  pi.on("session_start", async (_event, ctx) => {
244
277
  originals = buildOriginals(ctx.cwd);
278
+ loadSettings(ctx.cwd);
245
279
  config = replayBranch(ctx.sessionManager.getBranch() as never);
246
280
  applyAll();
281
+ if (settingsWarnings.length > 0 && ctx.hasUI) {
282
+ ctx.ui.notify(`pretty settings: ${settingsWarnings.join("; ")}`, "warning");
283
+ }
247
284
  });
248
285
 
249
286
  pi.on("session_tree", async (_event, ctx) => {
@@ -254,23 +291,31 @@ export default function pretty(pi: ExtensionAPI) {
254
291
  // ── Command ──────────────────────────────────────────────────────────
255
292
 
256
293
  pi.registerCommand("pretty", {
257
- description: "Toggle pretty tool rendering: /pretty [read|bash|edit|write|grep|find|ls]",
294
+ description: "Pretty tool rendering: /pretty [status | on|off [tool…] | reset | <tool…>]",
258
295
  handler: async (args, ctx: ExtensionContext) => {
259
- if (!ctx.hasUI) return;
260
- const target = (args ?? "").trim().toLowerCase();
261
- if (!target) {
262
- ctx.ui.notify(`Pretty renderers\n${statusLines(config).join("\n")}\nToggle with /pretty <tool>`, "info");
296
+ const command = parsePrettyCommand(args ?? "");
297
+ if (command.kind === "error") {
298
+ if (ctx.hasUI) ctx.ui.notify(command.message, "warning");
263
299
  return;
264
300
  }
265
- if (!isPrettyTool(target)) {
266
- ctx.ui.notify(`Unknown tool "${target}". Tools: read, bash, edit, write, grep, find, ls`, "warning");
301
+ if (command.kind === "status") {
302
+ if (ctx.hasUI) {
303
+ ctx.ui.notify(`Pretty renderers\n${statusLines(config).join("\n")}\n${PRETTY_USAGE}`, "info");
304
+ }
267
305
  return;
268
306
  }
269
- config = toggleTool(config, target);
307
+
308
+ const { config: next, changed } = applyCommand(config, command);
309
+ config = next;
270
310
  pi.appendEntry(PRETTY_CONFIG, config);
271
- applyTool(target);
272
- const on = !config.disabled.includes(target);
273
- ctx.ui.notify(`pretty ${target}: ${on ? "on" : "off"}`, "info");
311
+ for (const tool of changed) applyTool(tool);
312
+ if (!ctx.hasUI) return;
313
+ ctx.ui.notify(
314
+ changed.length === 0
315
+ ? "Nothing changed."
316
+ : changed.map((t) => `pretty ${t}: ${config.disabled.includes(t) ? "off" : "on"}`).join("\n"),
317
+ "info",
318
+ );
274
319
  },
275
320
  });
276
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/pretty",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Compact, theme-aware rendering for pi's built-in tools: one-line summaries, syntax-highlighted reads, colorized diffs — behavior untouched",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -27,12 +27,13 @@
27
27
  "files": [
28
28
  "extensions",
29
29
  "src",
30
- "skills",
31
30
  "README.md",
32
31
  "LICENSE"
33
32
  ],
34
33
  "pi": {
35
- "extensions": ["./extensions/pretty.ts"]
34
+ "extensions": [
35
+ "./extensions/pretty.ts"
36
+ ]
36
37
  },
37
38
  "scripts": {
38
39
  "typecheck": "tsc --noEmit",
@@ -44,12 +45,16 @@
44
45
  "@earendil-works/pi-tui": "*"
45
46
  },
46
47
  "peerDependenciesMeta": {
47
- "@earendil-works/pi-coding-agent": { "optional": true },
48
- "@earendil-works/pi-tui": { "optional": true }
48
+ "@earendil-works/pi-coding-agent": {
49
+ "optional": true
50
+ },
51
+ "@earendil-works/pi-tui": {
52
+ "optional": true
53
+ }
49
54
  },
50
55
  "devDependencies": {
51
- "@earendil-works/pi-coding-agent": "^0.84.4",
52
- "@earendil-works/pi-tui": "^0.84.4",
56
+ "@earendil-works/pi-coding-agent": "^0.85.1",
57
+ "@earendil-works/pi-tui": "^0.85.1",
53
58
  "@types/node": "^22.10.2",
54
59
  "typescript": "^5.7.2"
55
60
  },
package/src/config.ts CHANGED
@@ -33,6 +33,91 @@ export function toggleTool(config: PrettyConfig, tool: PrettyTool): PrettyConfig
33
33
  : { disabled: [...config.disabled, tool] };
34
34
  }
35
35
 
36
+ export const PRETTY_USAGE = [
37
+ "Usage: /pretty [status | on|off [tool…] | reset | <tool…>] tools: read, bash, edit, write, grep, find, ls",
38
+ "Settings: .pi/pretty.json (project) or <agentDir>/pretty.json",
39
+ ].join("\n");
40
+
41
+ /** Aliases the model or a hurried user is likely to type. */
42
+ const ALIASES: Record<string, PrettyTool> = {
43
+ list: "ls",
44
+ dir: "ls",
45
+ search: "grep",
46
+ rg: "grep",
47
+ cat: "read",
48
+ shell: "bash",
49
+ sh: "bash",
50
+ };
51
+
52
+ export type PrettyCommand =
53
+ | { kind: "status" }
54
+ | { kind: "toggle"; tools: PrettyTool[] }
55
+ | { kind: "set"; tools: PrettyTool[]; on: boolean }
56
+ | { kind: "reset" }
57
+ | { kind: "error"; message: string };
58
+
59
+ function resolveTools(words: string[]): { tools: PrettyTool[]; unknown: string[] } {
60
+ const tools: PrettyTool[] = [];
61
+ const unknown: string[] = [];
62
+ for (const word of words) {
63
+ if (word === "all") {
64
+ tools.push(...PRETTY_TOOLS);
65
+ continue;
66
+ }
67
+ const name = ALIASES[word] ?? word;
68
+ if (isPrettyTool(name)) {
69
+ if (!tools.includes(name)) tools.push(name);
70
+ } else {
71
+ unknown.push(word);
72
+ }
73
+ }
74
+ return { tools, unknown };
75
+ }
76
+
77
+ export function parsePrettyCommand(raw: string): PrettyCommand {
78
+ const words = (raw ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
79
+ if (words.length === 0) return { kind: "status" };
80
+
81
+ const [head, ...rest] = words;
82
+ if (head === "status" || head === "list") return { kind: "status" };
83
+ if (head === "reset") return { kind: "reset" };
84
+
85
+ if (head === "on" || head === "off") {
86
+ const { tools, unknown } = resolveTools(rest.length > 0 ? rest : ["all"]);
87
+ if (unknown.length > 0) return { kind: "error", message: `Unknown tool "${unknown[0]}". ${PRETTY_USAGE}` };
88
+ return { kind: "set", tools, on: head === "on" };
89
+ }
90
+
91
+ const { tools, unknown } = resolveTools(words);
92
+ if (unknown.length > 0) return { kind: "error", message: `Unknown tool "${unknown[0]}". ${PRETTY_USAGE}` };
93
+ return { kind: "toggle", tools };
94
+ }
95
+
96
+ /** Apply a parsed command; returns the new config and the tools that changed. */
97
+ export function applyCommand(
98
+ config: PrettyConfig,
99
+ command: PrettyCommand,
100
+ ): { config: PrettyConfig; changed: PrettyTool[] } {
101
+ switch (command.kind) {
102
+ case "toggle": {
103
+ let next = config;
104
+ for (const tool of command.tools) next = toggleTool(next, tool);
105
+ return { config: next, changed: command.tools };
106
+ }
107
+ case "set": {
108
+ const disabled = command.on
109
+ ? config.disabled.filter((t) => !command.tools.includes(t))
110
+ : [...new Set([...config.disabled, ...command.tools])];
111
+ const changed = command.tools.filter((t) => config.disabled.includes(t) !== disabled.includes(t));
112
+ return { config: { disabled }, changed };
113
+ }
114
+ case "reset":
115
+ return { config: DEFAULT_CONFIG, changed: [...config.disabled] };
116
+ default:
117
+ return { config, changed: [] };
118
+ }
119
+ }
120
+
36
121
  export function statusLines(config: PrettyConfig): string[] {
37
122
  return PRETTY_TOOLS.map(
38
123
  (tool) => `${config.disabled.includes(tool) ? "○" : "●"} ${tool}`,
package/src/diff.ts CHANGED
@@ -5,12 +5,21 @@ export interface DiffStats {
5
5
  removed: number;
6
6
  }
7
7
 
8
- /** Count +/− lines in a display diff (ignores headers like +++/---). */
8
+ /**
9
+ * Count +/− lines in a display diff. File headers (+++/---) are skipped, but
10
+ * only where they can actually appear — before the first hunk. Inside a hunk,
11
+ * a removed line whose content starts with `--` is a real removal.
12
+ */
9
13
  export function diffStats(diff: string): DiffStats {
10
14
  let added = 0;
11
15
  let removed = 0;
16
+ let inHunk = false;
12
17
  for (const line of diff.split("\n")) {
13
- if (line.startsWith("+++") || line.startsWith("---")) continue;
18
+ if (line.startsWith("@@")) {
19
+ inHunk = true;
20
+ continue;
21
+ }
22
+ if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
14
23
  if (line.startsWith("+")) added++;
15
24
  else if (line.startsWith("-")) removed++;
16
25
  }
package/src/preview.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One capping primitive for every body the renderers print. Collapsed rows get
3
+ * a short peek; expanded rows get a generous but bounded view — a 5,000-line
4
+ * diff or grep result should not be able to scroll the whole conversation out
5
+ * of the terminal just because someone pressed expand.
6
+ */
7
+
8
+ export interface PreviewLimits {
9
+ collapsed: number;
10
+ expanded: number;
11
+ }
12
+
13
+ export const DEFAULT_LIMITS: PreviewLimits = { collapsed: 12, expanded: 200 };
14
+
15
+ /** Limits from user settings (v0.3); the caps themselves are unchanged. */
16
+ export function limitsFrom(settings: { collapsedLines: number; expandedLines: number }): PreviewLimits {
17
+ return { collapsed: settings.collapsedLines, expanded: settings.expandedLines };
18
+ }
19
+
20
+ export function preview(text: string, expanded: boolean, limits: PreviewLimits = DEFAULT_LIMITS): string {
21
+ const max = expanded ? limits.expanded : limits.collapsed;
22
+ const lines = text.split("\n");
23
+ if (lines.length <= max) return text;
24
+ const hidden = lines.length - max;
25
+ return `${lines.slice(0, max).join("\n")}\n… +${hidden} more line${hidden === 1 ? "" : "s"}`;
26
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * User settings for the renderers.
3
+ *
4
+ * pi-cc-extensions makes the case by example: every number in a renderer is
5
+ * someone's taste, and the ones that matter are the caps. A 200-line expanded
6
+ * body is right on a 4K monitor and absurd in a 24-row terminal, and nobody
7
+ * wants to fork a package to change an integer.
8
+ *
9
+ * Read from `.pi/pretty.json` in the project, else `<agentDir>/pretty.json`.
10
+ * Unknown keys are ignored and bad values fall back to the default, so an
11
+ * old or hand-edited file can never take the renderers down — the worst case
12
+ * is the shipped defaults, which is where everyone starts anyway.
13
+ */
14
+
15
+ export interface PrettySettings {
16
+ /** Lines shown in a collapsed body (bash streaming, tool output). */
17
+ collapsedLines: number;
18
+ /** Lines shown after expanding, before "+N more lines". */
19
+ expandedLines: number;
20
+ /** Lines of an edit diff shown after expanding. */
21
+ diffLines: number;
22
+ /** Highlight expanded read results with pi's own highlighter. */
23
+ syntaxHighlight: boolean;
24
+ /** Longest path/command shown in a one-line summary. */
25
+ summaryClip: number;
26
+ }
27
+
28
+ export const DEFAULT_SETTINGS: PrettySettings = {
29
+ collapsedLines: 12,
30
+ expandedLines: 200,
31
+ diffLines: 200,
32
+ syntaxHighlight: true,
33
+ summaryClip: 100,
34
+ };
35
+
36
+ const LIMITS: Record<keyof PrettySettings, { min: number; max: number } | null> = {
37
+ collapsedLines: { min: 1, max: 200 },
38
+ expandedLines: { min: 5, max: 10_000 },
39
+ diffLines: { min: 5, max: 10_000 },
40
+ syntaxHighlight: null,
41
+ summaryClip: { min: 20, max: 500 },
42
+ };
43
+
44
+ /**
45
+ * Merge a parsed settings file over the defaults. Returns the settings that
46
+ * will actually be used plus a note for every value that was rejected —
47
+ * silently ignoring a typo is how someone spends an afternoon wondering why
48
+ * their config does nothing.
49
+ */
50
+ export function resolveSettings(raw: unknown): { settings: PrettySettings; warnings: string[] } {
51
+ const settings: PrettySettings = { ...DEFAULT_SETTINGS };
52
+ const warnings: string[] = [];
53
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
54
+ if (raw !== undefined && raw !== null) warnings.push("settings file is not an object — ignored");
55
+ return { settings, warnings };
56
+ }
57
+
58
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
59
+ if (!(key in DEFAULT_SETTINGS)) {
60
+ warnings.push(`unknown setting "${key}"`);
61
+ continue;
62
+ }
63
+ const name = key as keyof PrettySettings;
64
+ const limit = LIMITS[name];
65
+ if (limit === null) {
66
+ if (typeof value !== "boolean") {
67
+ warnings.push(`"${key}" must be true or false — using ${String(DEFAULT_SETTINGS[name])}`);
68
+ continue;
69
+ }
70
+ (settings[name] as boolean) = value;
71
+ continue;
72
+ }
73
+ if (typeof value !== "number" || !Number.isFinite(value)) {
74
+ warnings.push(`"${key}" must be a number — using ${String(DEFAULT_SETTINGS[name])}`);
75
+ continue;
76
+ }
77
+ const clamped = Math.round(Math.min(limit.max, Math.max(limit.min, value)));
78
+ if (clamped !== value) {
79
+ warnings.push(`"${key}" clamped to ${clamped} (allowed ${limit.min}–${limit.max})`);
80
+ }
81
+ (settings[name] as number) = clamped;
82
+ }
83
+
84
+ return { settings, warnings };
85
+ }
86
+
87
+ export function formatSettings(settings: PrettySettings, source: string | null): string {
88
+ const width = Object.keys(settings).reduce((m, k) => Math.max(m, k.length), 0);
89
+ const lines = Object.entries(settings).map(
90
+ ([key, value]) => ` ${key.padEnd(width)} ${String(value)}`,
91
+ );
92
+ return [source ? `Settings from ${source}` : "Settings (defaults — no pretty.json found)", ...lines].join("\n");
93
+ }