pi-compact-tools 0.10.0 → 0.11.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
@@ -1,22 +1,8 @@
1
1
  # pi-compact-tools
2
2
 
3
- Compact, expandable rendering for Pi's built-in tools and for custom tools from other packages, with a GitHub Dark theme.
3
+ Compact, expandable tool rows for Pi in its own compact style or Claude Code's, with a silent mode and a GitHub Dark theme. Works with custom tools from other packages.
4
4
 
5
- ![Compact rows for built-in and custom tools, including a web search and an edit with its diff and line counts](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
6
-
7
- ![Syntax-highlighted edit diff with lines added and removed](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
8
-
9
- ![Numbered read code view](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/code-aware-results.gif)
10
-
11
- ## Features
12
-
13
- - Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
14
- - The same compact rows for custom tools registered by other packages, such as web search or MCP tools, with no setup
15
- - Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
16
- - Edits report what they changed on their status line, lines added and removed in the diff colors: `(+3 -1)`
17
- - Animated working label above the prompt, lit in the session's own thinking-level color, and a `Ctrl+T` thinking summary/detail cycle
18
- - Theme-agnostic chrome: rails, the tool indicator, and the working label are derived from the active theme and stay legible on light and dark backgrounds
19
- - No Nerd Font required
5
+ ![The compact style, then the Claude style, each switched to silent mode mid-run and back](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/overview.gif)
20
6
 
21
7
  ## Install
22
8
 
@@ -24,70 +10,66 @@ Compact, expandable rendering for Pi's built-in tools and for custom tools from
24
10
  pi install npm:pi-compact-tools
25
11
  ```
26
12
 
27
- Then restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
13
+ Restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
28
14
 
29
- ## Configuration
15
+ ## Usage
30
16
 
31
- Optional. Put it in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
17
+ Pick a style. Silent mode works on top of either.
32
18
 
33
- ```json
34
- {
35
- "tools": ["read", "write", "edit", "bash"],
36
- "previewLines": 10,
37
- "auto_compact": { "read": true, "write": true, "edit": false, "bash": true }
38
- }
39
- ```
19
+ | | Setting | Shows | How |
20
+ |---|---|---|---|
21
+ | **Compact** | Style | every tool call, one short row each | `"style": "compact"` (default) |
22
+ | **Claude** | Style | reads and searches folded into one line, like Claude Code | `"style": "claude"` |
23
+ | **Silent** | Mode | only your prompts and the answers | `Ctrl+'` or `/silent` |
40
24
 
41
- - `tools`: tools to render compactly. Also available: `grep`, `find`, `ls`, `powershell`.
42
- - `auto_compact`: `true` starts hidden, `false` starts with a preview of up to `previewLines` rows (`1`–`100`). Custom tools can be listed by name too, such as `"web_search": false`.
43
- - `mode`: `"normal"` (default) or `"silent"`. See [Silent mode](#silent-mode).
44
- - `custom_tools`: compact rendering for tools from other packages. See [Custom tools](#custom-tools).
25
+ Set `"style"` in the config below, then `/reload`. `"style": "off"` restores Pi's own rows.
45
26
 
46
- ## Custom tools
27
+ ### Style: Compact
47
28
 
48
- Tools registered by other packages get the same row as the built-ins: a status indicator, the tool name, a one-line summary of the call, then duration and line count once it finishes. It works for any tool, whichever package registered it, with nothing to configure.
29
+ ![Compact style: a web search, ls, and an edit as compact rows with status dots, then the edit's diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
49
30
 
50
- Expanding a row shows the call's arguments and then the result as the tool's own package renders it, so a package's custom result view is kept. Tools without a result renderer show their text output.
31
+ - One row per call: status, duration, and line count.
32
+ - Edits show a highlighted diff with `(+3 -1)`; reads and writes show numbered code.
51
33
 
52
- ```json
53
- {
54
- "custom_tools": { "enabled": true, "auto_compact": true, "exclude": ["my_tool"] }
55
- }
56
- ```
34
+ ### Style: Claude
57
35
 
58
- - `enabled`: `false` leaves every custom tool with its own renderer. `"custom_tools": false` is shorthand.
59
- - `auto_compact`: like the built-in setting, `true` starts rows hidden and `false` starts with a preview. A tool named in the top-level `auto_compact` uses its own setting instead.
60
- - `exclude`: tool names that keep their own renderer.
36
+ ![Claude style: reads and searches fold into one line, then Bash and Update rows; silent mode hides them mid-run and brings them back](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/claude-style.gif)
61
37
 
62
- Built-in tools follow `tools` above: a built-in left out of that list keeps Pi's default renderer.
38
+ - Reads, searches, and listings fold into one line: `Searched for 2 patterns, read 3 files`.
39
+ - `Bash`, `Update`, `Write`, and custom tools keep their own rows with short previews.
63
40
 
64
- Pi has no public API for changing how another package's tool is drawn, so this wraps the renderer lookup of Pi's tool row component. Nothing in Pi's installation is modified, and it only applies while this extension is loaded. If a Pi release changes that component, custom tools fall back to their own renderers and Pi shows a warning once.
41
+ ### Mode: Silent
65
42
 
66
- ## Silent mode
43
+ ![Silent mode switched on mid-run: the rows fold into a moving light, the answer stays, and every row comes back when it is off](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/silent-mode.gif)
67
44
 
68
- ![Silent mode switched on mid-run: the tool rows fold away into a moving light, the answer stays, and every row comes back when it is off](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/silent-mode.gif)
45
+ Shows only your prompts and the answers, in either style.
69
46
 
70
- Silent mode shows only your prompts and the final answers. Tool calls (including custom tools from other packages), the assistant's in-between turns, thinking, error and abort notices, extension notices such as web-search progress, and Pi's own status lines are hidden while the animated working label shows what is running.
47
+ - Toggle with `Ctrl+'` or `/silent`. Tool rows and thinking fold into a moving light while the agent works.
48
+ - Turn it off and every row comes back. `"mode": "silent"` starts sessions with it on.
71
49
 
72
- - While the agent works, a light runs back and forth along a short line beneath your latest prompt, above the working label and just as long, leaving a fading afterimage in the thinking level's color. It steps aside once the answer starts.
73
- - `Ctrl+'` or `/silent` toggles it for the current session; `/silent on` and `/silent off` set it explicitly.
74
- - `Ctrl+'` needs a terminal that reports it as its own key (the Kitty keyboard protocol: Ghostty, Kitty, WezTerm, and others). Where it only types `'`, use `/silent`.
75
- - Set `"mode": "silent"` in `compact-tools.json` to start every session silent.
76
- - Turning it off brings back every hidden row. Nothing is removed from the session or the model's context.
77
- - Errors are hidden too, so a run that fails ends without an answer. Turn silent mode off to see what went wrong.
50
+ ## Configuration
78
51
 
79
- Pi has no public API for hiding other extensions' tool rows, so silent mode wraps Pi's transcript components. If a future Pi release changes them, `/silent` reports that it is unavailable and the transcript renders normally.
52
+ Optional, in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
80
53
 
81
- ## Controls
54
+ ```json
55
+ {
56
+ "style": "compact",
57
+ "mode": "normal",
58
+ "tools": ["read", "write", "edit", "bash"],
59
+ "previewLines": 10,
60
+ "auto_compact": { "read": true, "write": true, "edit": false, "bash": true },
61
+ "custom_tools": { "enabled": true, "auto_compact": true, "exclude": [] }
62
+ }
63
+ ```
82
64
 
83
- - Click a row or press `Ctrl+O` to expand or collapse results.
84
- - In fullscreen mode, expanding or collapsing every row, cycling thinking, and toggling silent mode keep the text you were reading in place instead of jumping.
85
- - `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
86
- - `Ctrl+'` or `/silent` toggles silent mode.
65
+ - `tools`: built-ins to compact. Also `grep`, `find`, `ls`, `powershell`.
66
+ - `auto_compact`: `true` starts a row collapsed; `false` shows up to `previewLines` rows. Custom tool names work too.
67
+ - `custom_tools`: rows for tools from other packages. `exclude` keeps a tool's own look.
87
68
 
88
- ## Release notes
69
+ ## Controls
89
70
 
90
- After an update, the next new conversation opens with a bordered "What's new" block, the way Pi shows its own changelog: once, listing every release since the last one shown. A resumed session keeps it for the next new one, and so does silent mode. The block lives only in the TUI; it is never saved to the session or sent to the model. When publishing a new version, add its changes to `release-notes.json`; `npm run check` requires an entry matching `package.json`.
71
+ - Click a row or group, or press `Ctrl+O`, to expand and collapse.
72
+ - `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
91
73
 
92
74
  ## Development
93
75
 
@@ -97,6 +79,8 @@ npm run check
97
79
  pi -e .
98
80
  ```
99
81
 
82
+ Renders are pinned in `tests/golden/render.json`. After an intended change to the output, record it with `UPDATE_GOLDEN=1 npm test` and review the diff. Before publishing, add the version's notes to `release-notes.json` (`[]` for none).
83
+
100
84
  ## License
101
85
 
102
86
  MIT
@@ -0,0 +1,227 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+ import { normalizeLineEndings } from "./compact-tools-core.ts";
4
+ import {
5
+ countEditChanges,
6
+ formatResultLineSummary,
7
+ shellSegments,
8
+ summarizeCustomArguments,
9
+ summarizeFailure,
10
+ } from "./compact-tools-invocation.ts";
11
+ import type { ToolArgs } from "./compact-tools-types.ts";
12
+
13
+ /**
14
+ * Rows Claude Code shows of a command's output before "… +N lines". Output is
15
+ * wrapped to the width first, so a long line counts as the rows it takes.
16
+ */
17
+ export const CLAUDE_OUTPUT_ROWS = 3;
18
+ /** Rows Claude Code shows of what a write put in the file. */
19
+ export const CLAUDE_WRITE_ROWS = 10;
20
+ /** Lines of context Claude Code keeps around each change in an edit's diff. */
21
+ export const CLAUDE_DIFF_CONTEXT_LINES = 3;
22
+
23
+ /** Claude Code's names for the tools Pi shares with it. */
24
+ const LABELS: Readonly<Record<string, string>> = {
25
+ read: "Read",
26
+ grep: "Search",
27
+ find: "Search",
28
+ ls: "List",
29
+ bash: "Bash",
30
+ powershell: "PowerShell",
31
+ edit: "Update",
32
+ write: "Write",
33
+ };
34
+
35
+ export function claudeLabel(name: string): string {
36
+ return LABELS[name] ?? name;
37
+ }
38
+
39
+ export function pathOf(args: ToolArgs): string {
40
+ const value = args.path ?? args.file_path;
41
+ return typeof value === "string" ? normalizeLineEndings(value) : "";
42
+ }
43
+
44
+ function oneLine(text: string): string {
45
+ const lines = normalizeLineEndings(stripTerminalSequences(text)).split("\n").map((line) => line.trim()).filter(Boolean);
46
+ if (lines.length === 0) return "";
47
+ return lines.length > 1 ? `${lines[0]} …` : lines[0]!;
48
+ }
49
+
50
+ function commandOf(args: ToolArgs): string {
51
+ return typeof args.command === "string" ? args.command : "";
52
+ }
53
+
54
+ /** What goes between the parentheses: `Bash(npm test)`, `Search(pattern: "x", path: "src")`. */
55
+ export function claudeArgument(name: string, args: ToolArgs): string {
56
+ const path = pathOf(args);
57
+ if (name === "bash" || name === "powershell") return oneLine(commandOf(args));
58
+ if (name === "grep" || name === "find") {
59
+ const pattern = typeof args.pattern === "string" ? oneLine(args.pattern) : "";
60
+ // Arguments stream in; until the pattern arrives there is nothing to name.
61
+ if (!pattern) return "";
62
+ return path ? `pattern: "${pattern}", path: "${path}"` : `pattern: "${pattern}"`;
63
+ }
64
+ if (name === "ls") return path || ".";
65
+ if (name === "read" || name === "edit" || name === "write") return path;
66
+ return formatArguments(args);
67
+ }
68
+
69
+ function formatValue(value: unknown): string | undefined {
70
+ if (typeof value === "string") return JSON.stringify(oneLine(value));
71
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
72
+ if (Array.isArray(value) && value.every((item) => typeof item !== "object" || item === null)) {
73
+ return `[${value.map((item) => formatValue(item) ?? "null").join(", ")}]`;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** A custom tool's arguments as Claude Code lists them: `query: "pi tui", limit: 5`. */
79
+ export function formatArguments(args: ToolArgs): string {
80
+ const parts: string[] = [];
81
+ for (const [key, value] of Object.entries(args)) {
82
+ const formatted = formatValue(value);
83
+ if (formatted !== undefined) parts.push(`${key}: ${formatted}`);
84
+ }
85
+ return parts.length > 0 ? parts.join(", ") : summarizeCustomArguments(args);
86
+ }
87
+
88
+ export function claudeTitle(name: string, args: ToolArgs): { label: string; argument: string } {
89
+ return { label: claudeLabel(name), argument: claudeArgument(name, args) };
90
+ }
91
+
92
+ // Claude Code's own lists: a command made only of these only looks around, so it
93
+ // folds into a group with reads and searches.
94
+ const SEARCH_COMMANDS = new Set(["find", "grep", "rg", "ag", "ack", "locate", "which", "whereis"]);
95
+ const READ_COMMANDS = new Set([
96
+ "cat", "head", "tail", "less", "more", "wc", "stat", "file", "strings", "jq", "awk", "cut", "sort", "uniq", "tr",
97
+ ]);
98
+ const LIST_COMMANDS = new Set(["ls", "tree", "du"]);
99
+ const NEUTRAL_COMMANDS = new Set(["echo", "printf", "true", "false", ":"]);
100
+
101
+ export type LookKind = "search" | "read" | "list";
102
+
103
+ /** Split one shell step at its unquoted pipes. */
104
+ function pipeStages(segment: string): string[] {
105
+ const stages: string[] = [];
106
+ let current = "";
107
+ let quote: string | undefined;
108
+ for (let index = 0; index < segment.length; index++) {
109
+ const character = segment[index]!;
110
+ if (quote) {
111
+ if (character === quote) quote = undefined;
112
+ } else if (character === "'" || character === '"' || character === "`") {
113
+ quote = character;
114
+ } else if (character === "|" && segment[index + 1] !== "|") {
115
+ stages.push(current);
116
+ current = "";
117
+ continue;
118
+ }
119
+ current += character;
120
+ }
121
+ stages.push(current);
122
+ return stages.map((stage) => stage.trim()).filter(Boolean);
123
+ }
124
+
125
+ /**
126
+ * How a shell command looks around, if that is all it does: `rg x | head` searches,
127
+ * `cat a.ts` reads, `ls src` lists. Anything that runs something else is undefined.
128
+ */
129
+ export function classifyShellCommand(command: string): LookKind | undefined {
130
+ let search = false;
131
+ let read = false;
132
+ let list = false;
133
+ for (const stage of shellSegments(normalizeLineEndings(command)).flatMap(pipeStages)) {
134
+ const program = stage.split(/\s+/u)[0];
135
+ if (!program || NEUTRAL_COMMANDS.has(program)) continue;
136
+ if (SEARCH_COMMANDS.has(program)) search = true;
137
+ else if (READ_COMMANDS.has(program)) read = true;
138
+ else if (LIST_COMMANDS.has(program)) list = true;
139
+ else return undefined;
140
+ }
141
+ // Claude Code counts a call once: as a listing first, then a search, then a read.
142
+ return list ? "list" : search ? "search" : read ? "read" : undefined;
143
+ }
144
+
145
+ /** Whether a call only looks around, and how; such calls fold into one group. */
146
+ export function lookKind(name: string, args: ToolArgs): LookKind | undefined {
147
+ if (name === "read") return "read";
148
+ if (name === "grep" || name === "find") return "search";
149
+ if (name === "ls") return "list";
150
+ if (name === "bash" || name === "powershell") return classifyShellCommand(commandOf(args));
151
+ return undefined;
152
+ }
153
+
154
+ /** What a group's second line names while a call runs: a path, a pattern, a command. */
155
+ export function lookHint(name: string, args: ToolArgs): string {
156
+ if (name === "grep" || name === "find") {
157
+ const pattern = typeof args.pattern === "string" ? oneLine(args.pattern) : "";
158
+ return pattern ? `"${pattern}"` : "";
159
+ }
160
+ if (name === "bash" || name === "powershell") {
161
+ const command = oneLine(commandOf(args));
162
+ return command ? `$ ${command}` : "";
163
+ }
164
+ return name === "ls" ? pathOf(args) || "." : pathOf(args);
165
+ }
166
+
167
+ function plural(count: number, singular: string): string {
168
+ return count === 1 ? singular : `${singular}s`;
169
+ }
170
+
171
+ function countOf(summary: string | undefined): number {
172
+ return Number.parseInt(summary ?? "0", 10) || 0;
173
+ }
174
+
175
+ /**
176
+ * The result line for tools whose outcome is a sentence rather than output, worded
177
+ * as Claude Code words it: "Read 42 lines", "Added 3 lines, removed 1 line". `bold`
178
+ * marks the counts and paths Claude Code sets in bold. Tools whose output is the
179
+ * result, commands and custom tools, return undefined and show it instead.
180
+ */
181
+ export function claudeOutcome(
182
+ name: string,
183
+ args: ToolArgs,
184
+ result: AgentToolResult<unknown>,
185
+ output: string,
186
+ bold: (text: string) => string = (text) => text,
187
+ ): string | undefined {
188
+ const lines = countOf(formatResultLineSummary(name, args, result, output));
189
+ const count = (value: number, singular: string) => `${bold(String(value))} ${plural(value, singular)}`;
190
+ switch (name) {
191
+ case "read": return `Read ${count(lines, "line")}`;
192
+ case "grep": return `Found ${count(lines, "line")}`;
193
+ case "find": return `Found ${count(lines, "file")}`;
194
+ case "ls": return `Listed ${count(lines, "path")}`;
195
+ case "write": return `Wrote ${count(lines, "line")} to ${bold(pathOf(args))}`;
196
+ case "edit": {
197
+ const { added, removed } = countEditChanges(result) ?? { added: 0, removed: 0 };
198
+ if (added > 0 && removed > 0) return `Added ${count(added, "line")}, removed ${count(removed, "line")}`;
199
+ if (added > 0) return `Added ${count(added, "line")}`;
200
+ if (removed > 0) return `Removed ${count(removed, "line")}`;
201
+ return undefined;
202
+ }
203
+ default: return undefined;
204
+ }
205
+ }
206
+
207
+ const SHELL_STATUS = /^Command (?:exited with code (\d+)|(timed out.*|aborted|terminated.*))$/iu;
208
+
209
+ /**
210
+ * A failure as Claude Code reports it. A command leads with its exit code and
211
+ * then shows what it printed; another tool leads with its reason alone.
212
+ */
213
+ export function claudeFailure(name: string, output: string): { headline: string; detail: string } {
214
+ if (name === "bash" || name === "powershell" || !TOOL_NAMES.has(name)) {
215
+ const lines = normalizeLineEndings(output).trimEnd().split("\n");
216
+ const status = lines.at(-1)?.trim().match(SHELL_STATUS);
217
+ if (status) {
218
+ const headline = status[1] !== undefined ? `Error: Exit code ${status[1]}` : `Error: ${status[2]}`;
219
+ return { headline, detail: lines.slice(0, -1).join("\n").trimEnd() };
220
+ }
221
+ if (name === "bash" || name === "powershell") return { headline: "Error", detail: output.trimEnd() };
222
+ }
223
+ const reason = summarizeFailure(name, output);
224
+ return { headline: reason ? `Error: ${reason}` : "Error", detail: "" };
225
+ }
226
+
227
+ const TOOL_NAMES = new Set(Object.keys(LABELS));
@@ -3,10 +3,12 @@ import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
5
  DISPLAY_MODES,
6
+ DISPLAY_STYLES,
6
7
  SUPPORTED_TOOL_SET,
7
8
  type CompactToolName,
8
9
  type CompactToolsConfig,
9
10
  type DisplayMode,
11
+ type DisplayStyle,
10
12
  } from "./compact-tools-types.ts";
11
13
 
12
14
  const CONFIG_FILE = "compact-tools.json";
@@ -14,6 +16,7 @@ type JsonObject = Record<string, unknown>;
14
16
 
15
17
  export const DEFAULT_CONFIG: CompactToolsConfig = {
16
18
  mode: "normal",
19
+ style: "compact",
17
20
  tools: ["read", "write", "edit", "bash"],
18
21
  previewLines: 10,
19
22
  auto_compact: {
@@ -45,9 +48,9 @@ function isIntegerInRange(value: unknown, minimum: number, maximum: number): val
45
48
  return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
46
49
  }
47
50
 
48
- function parseMode(value: unknown, path: string): DisplayMode | undefined {
49
- if ((DISPLAY_MODES as readonly unknown[]).includes(value)) return value as DisplayMode;
50
- warn(path, `mode must be one of ${DISPLAY_MODES.join(", ")}; using previous value`);
51
+ function parseChoice<T extends string>(value: unknown, choices: readonly T[], key: string, path: string): T | undefined {
52
+ if ((choices as readonly unknown[]).includes(value)) return value as T;
53
+ warn(path, `${key} must be one of ${choices.join(", ")}; using previous value`);
51
54
  return undefined;
52
55
  }
53
56
 
@@ -121,7 +124,8 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
121
124
  warn(path, "expected a JSON object; using previous values");
122
125
  return base;
123
126
  }
124
- const mode = value.mode === undefined ? base.mode : (parseMode(value.mode, path) ?? base.mode);
127
+ const mode = value.mode === undefined ? base.mode : (parseChoice<DisplayMode>(value.mode, DISPLAY_MODES, "mode", path) ?? base.mode);
128
+ const style = value.style === undefined ? base.style : (parseChoice<DisplayStyle>(value.style, DISPLAY_STYLES, "style", path) ?? base.style);
125
129
  const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
126
130
  const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
127
131
  const custom_tools = parseCustomTools(base.custom_tools, value.custom_tools, path);
@@ -130,7 +134,7 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
130
134
  if (value.previewLines !== undefined && !isIntegerInRange(value.previewLines, 1, 100)) {
131
135
  warn(path, "previewLines must be 1–100; using previous value");
132
136
  }
133
- return { mode, tools, auto_compact, custom_tools, previewLines };
137
+ return { mode, style, tools, auto_compact, custom_tools, previewLines };
134
138
  }
135
139
 
136
140
  function readJson(path: string): unknown {