pi-compact-tools 0.7.8 → 0.8.1

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Dong Hyeong Lee
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dong Hyeong Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,48 +1,19 @@
1
1
  # pi-compact-tools
2
2
 
3
- Compact, expandable rendering for Pi's built-in tools, with a polished GitHub Dark theme.
3
+ Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
4
4
 
5
- > Formerly `@nedleeds/pi-compact-ui`. New installations should use `pi-compact-tools`.
6
-
7
- ## Demo
8
-
9
- The UI uses portable Unicode symbols and does not require a Nerd Font.
10
-
11
- ### Compact tool workflow
12
-
13
- Active, successful, and failed tool calls stay compact while preserving useful status and timing information.
14
-
15
- ![Compact tool workflow with Nerd Font](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif)
16
-
17
- ### Progress and duration display
18
-
19
- The working label above the prompt uses the Thinking summary color with a soft white light sweep. Every tool state uses the same `⦁` glyph; running tools use a fourteen-frame cosine-eased RGB pulse between dim and bright gray, then settle to green on success or red on failure. Elapsed time remains visible in the neutral control color.
5
+ ![Compact tool workflow](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif)
20
6
 
21
7
  ![Spinner and elapsed-time display](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/spinner-duration-optimized.gif)
22
8
 
23
- ### Bounded result previews
24
-
25
- Completed `edit` rows show only the lines actually removed (`-`) or added (`+`), without unchanged context, hunk metadata, or success prose. The per-tool `auto_compact` setting chooses between hidden results and a bounded preview; click or `Ctrl+O` uses Pi's normal expansion flow to reveal all changed lines.
26
-
27
9
  ![Edit diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
28
10
 
29
11
  ## Features
30
12
 
31
- - Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
32
- - One continuously glowing working label above the input prompt across thinking and tool execution
33
- - Semantic built-in progress labels and automatically cleaned custom/MCP tool names; collapsed text-search rows include the searched pattern
34
- - Four-step `Ctrl+T` thinking cycle when provider detail is available: summary, detail, summary, then hidden
35
- - Millisecond-precision duration and completed output line counts with neutral status text
36
- - Per-tool hidden or persistent bounded-preview collapsed state with `auto_compact: true` or `false`
37
- - Primary invocation targets such as paths and patterns stay visible, including common collapsed shell file operations
38
- - Click and `Ctrl+O` follow Pi's built-in expansion state while preview rows collapse back to their preview
39
- - Edit patches display only removed (`-`) and added (`+`) lines, omitting unchanged context
40
- - Configurable `previewLines` prevents large diffs, HTML, and shell output from flooding the transcript
41
- - Width-cached expanded results, edit-diff processing, and unchanged result reuse during status animation
42
- - Dark `│` and `└─` visual grouping
43
- - Distinct cool-blue thinking text
44
- - Truecolor and 256-color terminal support, plus Windows, Unix, and classic Mac line endings
45
- - No Nerd Font requirement
13
+ - Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
14
+ - Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
15
+ - Animated working label above the prompt and a `Ctrl+T` thinking summary/detail cycle
16
+ - No Nerd Font required
46
17
 
47
18
  ## Install
48
19
 
@@ -50,91 +21,27 @@ Completed `edit` rows show only the lines actually removed (`-`) or added (`+`),
50
21
  pi install npm:pi-compact-tools
51
22
  ```
52
23
 
53
- Or install from GitHub:
54
-
55
- ```bash
56
- pi install git:github.com/nedleeds/pi-compact-tools
57
- ```
58
-
59
- Restart Pi or run `/reload`.
60
-
61
- Migrating from the previous package name:
62
-
63
- ```bash
64
- pi remove npm:@nedleeds/pi-compact-ui
65
- pi install npm:pi-compact-tools
66
- ```
67
-
68
- ## Theme
69
-
70
- Select `github-dark-pro` in Pi's settings, or add it to `~/.pi/agent/settings.json`:
71
-
72
- ```json
73
- {
74
- "theme": "github-dark-pro"
75
- }
76
- ```
24
+ Then restart Pi or run `/reload`. For the bundled theme, set `"theme": "github-dark-pro"` in `~/.pi/agent/settings.json`.
77
25
 
78
26
  ## Configuration
79
27
 
80
- Configuration is optional. Create `~/.pi/agent/compact-tools.json` for global settings or `<project>/.pi/compact-tools.json` for a trusted project.
81
-
82
- Default configuration:
28
+ Optional. Put it in `~/.pi/agent/compact-tools.json` or `<project>/.pi/compact-tools.json`:
83
29
 
84
30
  ```json
85
31
  {
86
- "$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
87
32
  "tools": ["read", "write", "edit", "bash"],
88
33
  "previewLines": 10,
89
- "auto_compact": {
90
- "read": true,
91
- "write": true,
92
- "edit": false,
93
- "bash": true
94
- }
95
- }
96
- ```
97
-
98
- Every tool call title uses `⦁`. Pending calls use dark gray; active calls smoothly pulse through fourteen cosine-eased RGB frames at a 45 ms interval without disappearing. Animation starts while arguments stream, so `write` and `edit` remain visibly active before their filesystem operation begins. Successful titles use green, and failed titles use red. `Done`, `Failed`, elapsed time, and line counts use the same neutral control color without printing inline click or keyboard hints. A single Pi working label animates independently of token arrival and reports `Thinking…`, `Responding…`, semantic built-in states such as `Reading file…`, or cleaned custom states such as `Using jira search…`. Invocation arguments remain in the tool row instead of being duplicated above the prompt. No separate spinner glyph is shown above the prompt.
99
-
100
- `auto_compact` controls each tool's initial result state:
101
-
102
- - `true` starts with the result hidden.
103
- - `false` starts with at most `previewLines` rendered rows visible.
104
- - Clicking or pressing `Ctrl+O` expands the complete result through Pi's built-in expansion state.
105
-
106
- `previewLines` accepts `1`–`100` and defaults to `10`. It limits the collapsed preview; the complete result remains available. Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start hidden, while `edit` starts with a short changed-lines preview.
107
-
108
- To enable all Unix-compatible tools:
109
-
110
- ```json
111
- {
112
- "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"]
34
+ "auto_compact": { "read": true, "write": true, "edit": false, "bash": true }
113
35
  }
114
36
  ```
115
37
 
116
- Add `"powershell"` on Windows if desired.
38
+ - `tools`: tools to render compactly. Also available: `grep`, `find`, `ls`, `powershell`.
39
+ - `auto_compact`: `true` starts hidden, `false` starts with a preview of up to `previewLines` rows (`1`–`100`).
117
40
 
118
41
  ## Controls
119
42
 
120
- `Ctrl+T` cycles every thinking block through:
121
-
122
- ```text
123
- one-line summary → summary + │-indented detail → one-line summary → Thinking...
124
- ```
125
-
126
- Standalone bold lines and Markdown headings split one provider thinking run into logical sections. Each section gets its own summary and optional `│`-indented detail. Summary-only views stay uncluttered with no control hint; the control row appears only while provider-supplied detail is visible. When no thinking detail exists, `Ctrl+T` falls back to Pi's normal visible/hidden toggle instead of showing a duplicate detail phase. While thinking streams, a slightly faster highlight sweeps from the Thinking summary color to white across Pi's working label at a steady interval rather than depending on token updates. The transcript summary remains visually stable. The transformation is display-only; complete thinking remains unchanged in the session and model context.
127
-
128
- Primary file and search targets stay visible. Collapsed `bash` and `powershell` rows describe intent—such as `Run tests` or `Check repository status`. Text searches retain their primary pattern (for example, `Search text "TODO|FIXME"`), and common file commands retain their operands (for example, `Run rm "build"` or `Find files "*.test.ts" in "src"`). Expanding the row reveals the exact command. After every supported tool completes, the status reports the expanded result size as `Done in …s (N lines)` instead of repeating options such as context and limit. Counts use the returned text for reads, searches, listings, and shell commands, written content for `write`, and only the displayed `-`/`+` lines for `edit`. The count is computed once after completion and cached with the row, so streaming and repeated expansion do not add ongoing work. Large payloads represented as results follow the result toggle.
129
-
130
- A mouse click toggles one row using Pi's native result state. `Ctrl+O` remains owned by Pi and expands or collapses all tool rows consistently, including tools not rendered by this extension. A row with `auto_compact: false` cycles predictably between its bounded preview and the complete result, so repeated clicks never remove the row and fall through to a neighboring Thinking block. The extension does not intercept the global keybinding.
131
-
132
- ## Notes
133
-
134
- - Pi `0.85.1+` and Node.js `22.19.0+` are recommended.
135
- - The working indicator and mouse interaction are provided by Pi's TUI; `Ctrl+O` also works in regular mode.
136
- - Duration covers the full rendered tool-call lifecycle, including streamed `write` and `edit` arguments.
137
- - Selected tools override definitions with the same names so their compact renderers are restored reliably after `/reload`; other extensions overriding those tools may conflict by load order.
43
+ - Click a row or press `Ctrl+O` to expand or collapse results.
44
+ - `Ctrl+T` cycles thinking: summary → detail → summary → hidden.
138
45
 
139
46
  ## Development
140
47
 
@@ -144,8 +51,6 @@ npm run check
144
51
  pi -e .
145
52
  ```
146
53
 
147
- Pi extensions execute with full system access. Review third-party extension source before installation.
148
-
149
54
  ## License
150
55
 
151
56
  MIT
@@ -63,3 +63,27 @@ export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
63
63
  }
64
64
  return `\x1b[38;2;${color.r};${color.g};${color.b}m${text}\x1b[39m`;
65
65
  }
66
+
67
+ function isDarkTheme(theme: Theme): boolean {
68
+ const text = themeColorRgb(theme, "text");
69
+ if (!text) return true;
70
+ return text.r * 0.299 + text.g * 0.587 + text.b * 0.114 > 128;
71
+ }
72
+
73
+ /**
74
+ * Diff rows need a tint that reads on the terminal background without hijacking `toolSuccessBg`
75
+ * and `toolErrorBg`, which Pi paints across the whole tool row.
76
+ */
77
+ export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.16): Rgb | undefined {
78
+ const rgb = themeColorRgb(theme, color);
79
+ if (!rgb) return undefined;
80
+ const base: Rgb = isDarkTheme(theme) ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };
81
+ return interpolateRgb(base, rgb, amount);
82
+ }
83
+
84
+ export function fillRgb(theme: Theme, color: Rgb, text: string): string {
85
+ if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
86
+ return `\x1b[48;5;${rgbToAnsi256(color)}m${text}\x1b[49m`;
87
+ }
88
+ return `\x1b[48;2;${color.r};${color.g};${color.b}m${text}\x1b[49m`;
89
+ }
@@ -321,6 +321,7 @@ export function getTextResult(result: AgentToolResult<unknown>): string {
321
321
 
322
322
  type ResultDetails = {
323
323
  diff?: unknown;
324
+ patch?: unknown;
324
325
  truncation?: {
325
326
  firstLineExceedsLimit?: boolean;
326
327
  outputLines?: number;
@@ -341,21 +342,42 @@ function countTextLines(text: string): number {
341
342
  return count;
342
343
  }
343
344
 
345
+ export function getEditDiff(result: AgentToolResult<unknown>): string {
346
+ const diff = (result.details as ResultDetails | undefined)?.diff;
347
+ return typeof diff === "string" ? normalizeLineEndings(diff).trimEnd() : "";
348
+ }
349
+
350
+ export function getEditPatch(result: AgentToolResult<unknown>): string {
351
+ const patch = (result.details as ResultDetails | undefined)?.patch;
352
+ return typeof patch === "string" ? normalizeLineEndings(patch).trimEnd() : "";
353
+ }
354
+
344
355
  /** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
345
356
  export function getEditChanges(result: AgentToolResult<unknown>): string {
346
- const diff = (result.details as ResultDetails | undefined)?.diff;
347
- if (typeof diff !== "string") return "";
348
- return normalizeLineEndings(diff)
357
+ return getEditDiff(result)
349
358
  .split("\n")
350
359
  .filter((line) => line.startsWith("-") || line.startsWith("+"))
351
360
  .join("\n")
352
361
  .trimEnd();
353
362
  }
354
363
 
364
+ const READ_FOOTER = /\n\n(\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\])$/u;
365
+
366
+ /** Separate a read's file contents from the continuation notice Pi appends to them. */
367
+ export function splitReadFooter(text: string): { body: string; footer?: string } {
368
+ const match = text.match(READ_FOOTER);
369
+ return match ? { body: text.slice(0, match.index), footer: match[1] } : { body: text };
370
+ }
371
+
372
+ /** Whether a read result is plain file text that can be shown as numbered code. */
373
+ export function isReadTextResult(result: AgentToolResult<unknown>): boolean {
374
+ const details = result.details as ResultDetails | undefined;
375
+ return !details?.truncation?.firstLineExceedsLimit
376
+ && !result.content.some((item) => item.type === "image");
377
+ }
378
+
355
379
  function stripGeneratedFooter(name: string, text: string, details: ResultDetails | undefined): string {
356
- if (name === "read") {
357
- return text.replace(/\n\n\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\]$/u, "");
358
- }
380
+ if (name === "read") return splitReadFooter(text).body;
359
381
  const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
360
382
  || details?.matchLimitReached !== undefined
361
383
  || details?.resultLimitReached !== undefined
@@ -1,4 +1,4 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  Container,
@@ -7,7 +7,9 @@ import {
7
7
  visibleWidth,
8
8
  wrapTextWithAnsi,
9
9
  } from "@earendil-works/pi-tui";
10
+ import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
10
11
  import { normalizeLineEndings } from "./compact-tools-core.ts";
12
+ import { highlightMarkdown } from "./compact-tools-markdown.ts";
11
13
  import type { ToolArgs } from "./compact-tools-types.ts";
12
14
 
13
15
  class CachedComponent implements Component {
@@ -129,14 +131,252 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
129
131
  return prefixedText(styled, theme.fg("border", " │ "));
130
132
  }
131
133
 
132
- /** Render an edit's changed lines without unchanged context, hunk metadata, or result prose. */
133
- export function renderEditChanges(changes: string, theme: Theme): Component | undefined {
134
- const normalized = normalizeLineEndings(changes).trimEnd();
134
+ export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
135
+
136
+ export type CodeDiffLine = {
137
+ kind: CodeDiffLineKind;
138
+ lineNumber?: number;
139
+ content: string;
140
+ hunk: number;
141
+ };
142
+
143
+ export type CodeDiffOptions = {
144
+ /** Context lines kept around each change; omit to keep every context line the diff carries. */
145
+ contextLines?: number;
146
+ };
147
+
148
+ const DIFF_SEPARATOR = "\u22ee";
149
+
150
+ function parseUnifiedPatch(patch: string): CodeDiffLine[] {
151
+ const result: CodeDiffLine[] = [];
152
+ let oldLine = 0;
153
+ let newLine = 0;
154
+ let hunk = -1;
155
+ for (const line of normalizeLineEndings(patch).split("\n")) {
156
+ const header = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
157
+ if (header) {
158
+ if (hunk >= 0) result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk });
159
+ hunk++;
160
+ oldLine = Number(header[1]);
161
+ newLine = Number(header[2]);
162
+ continue;
163
+ }
164
+ if (hunk < 0 || line.startsWith("\")) continue;
165
+ const prefix = line[0];
166
+ const content = line.slice(1).replace(/\t/gu, " ");
167
+ if (prefix === "+") {
168
+ result.push({ kind: "add", lineNumber: newLine++, content, hunk });
169
+ } else if (prefix === "-") {
170
+ result.push({ kind: "remove", lineNumber: oldLine++, content, hunk });
171
+ } else if (prefix === " ") {
172
+ result.push({ kind: "context", lineNumber: newLine++, content, hunk });
173
+ oldLine++;
174
+ }
175
+ }
176
+ return result;
177
+ }
178
+
179
+ function parseDisplayDiff(diff: string): CodeDiffLine[] {
180
+ const result: CodeDiffLine[] = [];
181
+ let hunk = 0;
182
+ for (const line of normalizeLineEndings(diff).split("\n")) {
183
+ if (/^\s*\.\.\.\s*$/u.test(line)) {
184
+ result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: hunk++ });
185
+ continue;
186
+ }
187
+ const match = line.match(/^([ +\-])(\s*\d+)\s(.*)$/u);
188
+ if (!match) continue;
189
+ result.push({
190
+ kind: match[1] === "+" ? "add" : match[1] === "-" ? "remove" : "context",
191
+ lineNumber: Number(match[2]),
192
+ content: match[3]!.replace(/\t/gu, " "),
193
+ hunk,
194
+ });
195
+ }
196
+ return result;
197
+ }
198
+
199
+ /** Parse a standard patch, falling back to Pi's display-oriented numbered diff. */
200
+ export function parseCodeDiff(patch: string, displayDiff = ""): CodeDiffLine[] {
201
+ const parsedPatch = patch ? parseUnifiedPatch(patch) : [];
202
+ return parsedPatch.length > 0 ? parsedPatch : parseDisplayDiff(displayDiff);
203
+ }
204
+
205
+ /** Drop context that sits far from any change so a collapsed preview still reaches the edit. */
206
+ export function trimDiffContext(lines: CodeDiffLine[], contextLines: number): CodeDiffLine[] {
207
+ const kept = lines.map((line) => line.kind !== "context");
208
+ lines.forEach((line, index) => {
209
+ if (line.kind !== "add" && line.kind !== "remove") return;
210
+ for (let offset = 1; offset <= contextLines; offset++) {
211
+ if (lines[index - offset]?.kind === "context") kept[index - offset] = true;
212
+ if (lines[index + offset]?.kind === "context") kept[index + offset] = true;
213
+ }
214
+ });
215
+ const result: CodeDiffLine[] = [];
216
+ lines.forEach((line, index) => {
217
+ if (kept[index]) {
218
+ result.push(line);
219
+ return;
220
+ }
221
+ if (result.length > 0 && result[result.length - 1]!.kind !== "separator") {
222
+ result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: line.hunk });
223
+ }
224
+ });
225
+ while (result.length > 0 && result[result.length - 1]!.kind === "separator") result.pop();
226
+ return result;
227
+ }
228
+
229
+ /** Past these sizes syntax highlighting costs more than it helps; mirrors Codex's diff renderer. */
230
+ const HIGHLIGHT_MAX_CHARS = 512 * 1024;
231
+ const HIGHLIGHT_MAX_LINES = 10_000;
232
+ const HIGHLIGHT_MAX_LINE_CHARS = 4 * 1024;
233
+
234
+ function canHighlight(lines: readonly CodeDiffLine[]): boolean {
235
+ if (lines.length > HIGHLIGHT_MAX_LINES) return false;
236
+ let total = 0;
237
+ for (const line of lines) {
238
+ if (line.content.length > HIGHLIGHT_MAX_LINE_CHARS) return false;
239
+ total += line.content.length + 1;
240
+ if (total > HIGHLIGHT_MAX_CHARS) return false;
241
+ }
242
+ return true;
243
+ }
244
+
245
+ /** Highlight each hunk as one block so multi-line strings and comments keep their state. */
246
+ function highlightCodeLines(lines: CodeDiffLine[], language: string | undefined, theme: Theme): string[] {
247
+ if (!language || !canHighlight(lines)) {
248
+ return lines.map((line) => line.kind === "separator" ? line.content : theme.fg("toolOutput", line.content));
249
+ }
250
+ const rendered = new Array<string>(lines.length);
251
+ let index = 0;
252
+ while (index < lines.length) {
253
+ if (lines[index]!.kind === "separator") {
254
+ rendered[index] = lines[index]!.content;
255
+ index++;
256
+ continue;
257
+ }
258
+ let end = index;
259
+ while (end < lines.length && lines[end]!.kind !== "separator") end++;
260
+ const block = lines.slice(index, end);
261
+ const contents = block.map((line) => line.content);
262
+ const highlighted = language === "markdown"
263
+ ? highlightMarkdown(contents, theme)
264
+ : highlightCode(contents.join("\n"), language);
265
+ block.forEach((line, offset) => {
266
+ rendered[index + offset] = highlighted[offset] ?? line.content;
267
+ });
268
+ index = end;
269
+ }
270
+ return rendered;
271
+ }
272
+
273
+ function hardSliceAnsi(text: string, width: number): string[] {
274
+ const safeWidth = Math.max(1, width);
275
+ const lineWidth = visibleWidth(text);
276
+ if (lineWidth === 0) return [""];
277
+ const slices: string[] = [];
278
+ for (let offset = 0; offset < lineWidth; offset += safeWidth) {
279
+ slices.push(sliceByColumn(text, offset, safeWidth, true));
280
+ }
281
+ return slices;
282
+ }
283
+
284
+ function codeGutter(
285
+ kind: CodeDiffLineKind,
286
+ lineNumber: number | undefined,
287
+ numberWidth: number,
288
+ signs: boolean,
289
+ theme: Theme,
290
+ ): string {
291
+ const number = (lineNumber === undefined ? "" : String(lineNumber)).padStart(numberWidth);
292
+ if (!signs) return theme.fg("toolDiffContext", `${number} `);
293
+ const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
294
+ const color = kind === "add" ? "toolDiffAdded" : kind === "remove" ? "toolDiffRemoved" : "toolDiffContext";
295
+ return theme.fg(color, `${number} ${sign} `);
296
+ }
297
+
298
+ type CodeRowsOptions = {
299
+ /** Show the `+`/`-` sign column and tint changed rows. */
300
+ signs: boolean;
301
+ /** Dim note rendered below the code, such as a read truncation notice. */
302
+ footer?: string;
303
+ };
304
+
305
+ /** Render numbered, syntax-highlighted code rows shared by edit diffs and read/write file views. */
306
+ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, options: CodeRowsOptions): Component {
307
+ const highlighted = highlightCodeLines(lines, getLanguageFromPath(path), theme);
308
+ const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
309
+ const prefix = theme.fg("border", " \u2502 ");
310
+ const prefixWidth = visibleWidth(prefix);
311
+ const gutterWidth = numberWidth + (options.signs ? 3 : 2);
312
+ const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
313
+ const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
314
+ return new CachedComponent((width) => {
315
+ const bodyWidth = Math.max(1, width - prefixWidth);
316
+ const codeWidth = Math.max(1, bodyWidth - gutterWidth);
317
+ const output: string[] = [];
318
+ lines.forEach((line, index) => {
319
+ if (line.kind === "separator") {
320
+ output.push(prefix + " ".repeat(numberWidth + 1) + theme.fg("toolDiffContext", line.content));
321
+ return;
322
+ }
323
+ const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
324
+ for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
325
+ const gutter = codeGutter(
326
+ line.kind,
327
+ chunkIndex === 0 ? line.lineNumber : undefined,
328
+ numberWidth,
329
+ options.signs,
330
+ theme,
331
+ );
332
+ const body = gutter + chunk;
333
+ if (!tint) {
334
+ output.push(prefix + body);
335
+ continue;
336
+ }
337
+ const padded = body + " ".repeat(Math.max(0, bodyWidth - visibleWidth(body)));
338
+ output.push(prefix + fillRgb(theme, tint, padded));
339
+ }
340
+ });
341
+ if (options.footer) {
342
+ for (const line of wrapTextWithAnsi(options.footer, bodyWidth)) output.push(prefix + theme.fg("dim", line));
343
+ }
344
+ return output;
345
+ });
346
+ }
347
+
348
+ /** Render an edit as a syntax-highlighted diff with a line-number gutter and tinted change rows. */
349
+ export function renderCodeDiff(
350
+ patch: string,
351
+ displayDiff: string,
352
+ path: string,
353
+ theme: Theme,
354
+ options: CodeDiffOptions = {},
355
+ ): Component | undefined {
356
+ const diff = parseCodeDiff(patch, displayDiff);
357
+ const lines = options.contextLines === undefined ? diff : trimDiffContext(diff, options.contextLines);
358
+ if (lines.length === 0) return undefined;
359
+ return renderCodeRows(lines, path, theme, { signs: true });
360
+ }
361
+
362
+ export type CodeViewOptions = {
363
+ /** File line number of the first code line, e.g. a read's `offset`. */
364
+ startLine?: number;
365
+ footer?: string;
366
+ };
367
+
368
+ /** Render file contents with line numbers and syntax highlighting, matching the edit diff layout. */
369
+ export function renderCodeView(code: string, path: string, theme: Theme, options: CodeViewOptions = {}): Component | undefined {
370
+ const normalized = normalizeLineEndings(code).replace(/\n+$/u, "");
135
371
  if (!normalized) return undefined;
136
- const styled = normalized.split("\n").map((line) =>
137
- theme.fg(line.startsWith("+") ? "success" : "error", line)
138
- ).join("\n");
139
- return prefixedText(styled, theme.fg("border", " │ "));
372
+ const startLine = Math.max(1, Math.floor(options.startLine ?? 1));
373
+ const lines = normalized.split("\n").map((content, index): CodeDiffLine => ({
374
+ kind: "context",
375
+ lineNumber: startLine + index,
376
+ content: content.replace(/\t/gu, " "),
377
+ hunk: 0,
378
+ }));
379
+ return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer });
140
380
  }
141
381
 
142
382
  /** Limit a result preview by rendered rows while preserving the full source component for expansion. */
@@ -0,0 +1,94 @@
1
+ import { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ type Fence = { char: string; length: number; language: string | undefined; start: number };
4
+
5
+ const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/u;
6
+ const FENCE_CLOSE = /^\s{0,3}(`{3,}|~{3,})\s*$/u;
7
+ const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/u;
8
+ const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/u;
9
+ const QUOTE = /^(\s{0,3}(?:>\s?)+)(.*)$/u;
10
+ const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+(?:\[[ xX]\]\s+)?)(.*)$/u;
11
+ const INLINE = /(`+)(.+?)\1|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*(?=\S)(.+?)\*\*|__(?=\S)(.+?)__|\*(?=[^\s*])(.+?)\*|(?<!\w)_(?=[^\s_])(.+?)_(?!\w)/gu;
12
+
13
+ /** Resolve a fence info string such as `ts` or `typescript` to a highlighter language. */
14
+ function fenceLanguage(info: string): string | undefined {
15
+ const name = info.toLowerCase();
16
+ if (!name) return undefined;
17
+ return getLanguageFromPath(`fence.${name}`) ?? name;
18
+ }
19
+
20
+ function highlightInline(text: string, theme: Theme): string {
21
+ const plain = (value: string) => value ? theme.fg("toolOutput", value) : "";
22
+ const marker = (value: string) => theme.fg("dim", value);
23
+ let output = "";
24
+ let last = 0;
25
+ for (const match of text.matchAll(INLINE)) {
26
+ output += plain(text.slice(last, match.index));
27
+ const [whole, ticks, code, label, url, strong, strongAlt, emphasis, emphasisAlt] = match;
28
+ if (ticks !== undefined) {
29
+ output += marker(ticks) + theme.fg("mdCode", code!) + marker(ticks);
30
+ } else if (label !== undefined) {
31
+ output += marker("[") + theme.fg("mdLink", label) + marker("](") + theme.fg("mdLinkUrl", url!) + marker(")");
32
+ } else if (strong !== undefined || strongAlt !== undefined) {
33
+ const delimiter = whole.slice(0, 2);
34
+ output += marker(delimiter) + theme.bold(plain(strong ?? strongAlt!)) + marker(delimiter);
35
+ } else {
36
+ const delimiter = whole[0]!;
37
+ output += marker(delimiter) + theme.italic(plain(emphasis ?? emphasisAlt!)) + marker(delimiter);
38
+ }
39
+ last = match.index + whole.length;
40
+ }
41
+ return output + plain(text.slice(last));
42
+ }
43
+
44
+ function highlightProse(line: string, theme: Theme): string {
45
+ if (HEADING.test(line)) return theme.bold(theme.fg("mdHeading", line));
46
+ if (RULE.test(line)) return theme.fg("mdHr", line);
47
+ const quote = line.match(QUOTE);
48
+ if (quote) return theme.fg("mdQuoteBorder", quote[1]!) + theme.italic(theme.fg("mdQuote", quote[2]!));
49
+ const item = line.match(LIST_ITEM);
50
+ if (item) return item[1]! + theme.fg("mdListBullet", item[2]! + item[3]!) + highlightInline(item[4]!, theme);
51
+ return highlightInline(line, theme);
52
+ }
53
+
54
+ function highlightFenceBody(lines: string[], language: string | undefined, theme: Theme): string[] {
55
+ if (lines.length === 0) return [];
56
+ if (!language) return lines.map((line) => theme.fg("mdCodeBlock", line));
57
+ return highlightCode(lines.join("\n"), language);
58
+ }
59
+
60
+ /**
61
+ * Highlight Markdown source line by line: prose gets Markdown styling, fenced code is
62
+ * highlighted as its declared language, and every character of the source stays visible.
63
+ */
64
+ export function highlightMarkdown(lines: string[], theme: Theme): string[] {
65
+ const output = new Array<string>(lines.length);
66
+ let fence: Fence | undefined;
67
+ const flush = (end: number) => {
68
+ if (!fence) return;
69
+ const body = highlightFenceBody(lines.slice(fence.start, end), fence.language, theme);
70
+ body.forEach((line, offset) => {
71
+ output[fence!.start + offset] = line;
72
+ });
73
+ };
74
+ lines.forEach((line, index) => {
75
+ if (fence) {
76
+ const close = line.match(FENCE_CLOSE);
77
+ if (close && close[1]![0] === fence.char && close[1]!.length >= fence.length) {
78
+ flush(index);
79
+ output[index] = theme.fg("mdCodeBlockBorder", line);
80
+ fence = undefined;
81
+ }
82
+ return;
83
+ }
84
+ const open = line.match(FENCE_OPEN);
85
+ if (open) {
86
+ fence = { char: open[1]![0]!, length: open[1]!.length, language: fenceLanguage(open[2]!), start: index + 1 };
87
+ output[index] = theme.fg("mdCodeBlockBorder", line);
88
+ return;
89
+ }
90
+ output[index] = highlightProse(line, theme);
91
+ });
92
+ flush(lines.length);
93
+ return output.map((line, index) => line ?? lines[index]!);
94
+ }
@@ -33,9 +33,12 @@ import {
33
33
  formatResultLineSummary,
34
34
  getArgumentDetails,
35
35
  getCallDetails,
36
- getEditChanges,
36
+ getEditDiff,
37
+ getEditPatch,
37
38
  getFileOutput,
38
39
  getTextResult,
40
+ isReadTextResult,
41
+ splitReadFooter,
39
42
  summarizeShellCommand,
40
43
  } from "./compact-tools-invocation.ts";
41
44
  import {
@@ -43,7 +46,8 @@ import {
43
46
  limitComponentLines,
44
47
  prefixedText,
45
48
  renderArguments,
46
- renderEditChanges,
49
+ renderCodeDiff,
50
+ renderCodeView,
47
51
  renderOutput,
48
52
  renderToolCall,
49
53
  styleMultiline,
@@ -60,6 +64,9 @@ import type {
60
64
  ToolArgs,
61
65
  } from "./compact-tools-types.ts";
62
66
 
67
+ /** Context lines kept around each change while an edit result is collapsed. */
68
+ const PREVIEW_DIFF_CONTEXT_LINES = 1;
69
+
63
70
  const runtime = new ToolRuntime();
64
71
  const registeredTools = new Set<CompactToolName>();
65
72
  let registeredConfiguration: string | undefined;
@@ -161,19 +168,45 @@ function renderFileCall(
161
168
  return container;
162
169
  }
163
170
 
171
+ function renderFileBody(
172
+ name: CompactToolName,
173
+ args: ToolArgs,
174
+ result: AgentToolResult<unknown>,
175
+ output: string,
176
+ expanded: boolean,
177
+ theme: Theme,
178
+ isError: boolean,
179
+ ): Component | undefined {
180
+ if (isError) return renderOutput(output, theme, isError);
181
+ const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
182
+ if (name === "edit") {
183
+ const diff = getEditDiff(result);
184
+ const component = diff
185
+ ? renderCodeDiff(getEditPatch(result), diff, path, theme, expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES })
186
+ : undefined;
187
+ return component ?? renderOutput(output, theme, isError);
188
+ }
189
+ if (name === "read" && isReadTextResult(result)) {
190
+ const { body, footer } = splitReadFooter(output);
191
+ const startLine = typeof args.offset === "number" ? args.offset : 1;
192
+ return renderCodeView(body, path, theme, { startLine, footer }) ?? renderOutput(output, theme, isError);
193
+ }
194
+ if (name === "write") return renderCodeView(output, path, theme) ?? renderOutput(output, theme, isError);
195
+ return renderOutput(output, theme, isError);
196
+ }
197
+
164
198
  function appendFileResult(
165
199
  container: Container,
166
- definition: BuiltInDefinition,
200
+ name: CompactToolName,
167
201
  state: RowState,
202
+ args: ToolArgs,
203
+ result: AgentToolResult<unknown>,
168
204
  output: string,
169
- editChanges: string,
170
205
  theme: Theme,
171
206
  isError: boolean,
172
207
  ): void {
173
208
  if (!state.expanded && !state.preview) return;
174
- const component = definition.name === "edit" && editChanges
175
- ? renderEditChanges(editChanges, theme)
176
- : renderOutput(output, theme, isError);
209
+ const component = renderFileBody(name, args, result, output, state.expanded === true, theme, isError);
177
210
  if (!component) return;
178
211
  container.addChild(state.expanded ? component : limitComponentLines(component, runtime.config.previewLines, theme));
179
212
  }
@@ -190,10 +223,10 @@ function renderFileResult(
190
223
  runtime.syncExpansion(state, ctx.expanded, name);
191
224
  if (canReuseResult(state, result, options, ctx)) return ctx.lastComponent;
192
225
  const output = getFileOutput(name, ctx.args, result, ctx.isError);
193
- const editChanges = name === "edit" ? getEditChanges(result) : "";
194
- runtime.setResultAvailable(state, name, editChanges.length > 0 || output.length > 0);
226
+ const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
227
+ runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0);
195
228
  const container = new CachedContainer();
196
- appendFileResult(container, definition, state, output, editChanges, theme, ctx.isError);
229
+ appendFileResult(container, name, state, ctx.args, result, output, theme, ctx.isError);
197
230
  if (!options.isPartial && !state.resultLineSummaryComputed) {
198
231
  state.resultLineSummary = formatResultLineSummary(name, ctx.args, result, output);
199
232
  state.resultLineSummaryComputed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.7.8",
3
+ "version": "0.8.1",
4
4
  "description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",