@pify/pretty 0.5.0 → 0.6.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
@@ -16,7 +16,7 @@ Nothing here changes what a tool does. If this extension is removed, every comma
16
16
  |---|---|---|
17
17
  | `read` | `Read src/app.ts · lines 1–50` → `50 lines` | Syntax-highlighted content, theme-matched |
18
18
  | `bash` | `Bash npm test` → `✓ 12 output lines` / `✗ first error line` | Output, with a 12-line preview while it runs |
19
- | `edit` | `Edit src/app.ts` → `+12 -3` | Colourised diff with word-level emphasis |
19
+ | `edit` | `Edit src/app.ts` → `+12 -3` | Syntax-highlighted diff with line numbers and word-level emphasis |
20
20
  | `write` | `Write y.md · 34 lines` → `✓ written` | — |
21
21
  | `grep` / `find` | `Grep TODO in src` → `7 matches` | The match list |
22
22
  | `ls` | `List packages` → `23 entries` | The listing |
@@ -40,6 +40,19 @@ Colours come from the theme's own `toolDiffAdded` / `toolDiffRemoved` / `toolDif
40
40
 
41
41
  Very long lines are not word-diffed at all: the comparison is quadratic, and a minified bundle on one line is not something anyone reads a word diff of.
42
42
 
43
+ ### Syntax, line numbers, and split
44
+
45
+ The diff body is syntax-highlighted with pi's own highlighter — the same one the `read` renderer uses, so no extra dependency and the colours match your theme. Foreground is spent on the syntax, so the `+`/`−` signal moves to a subtle line background (the theme's `toolSuccessBg` / `toolErrorBg`; this package's themes map those to real diff tints), and the changed words are marked with reverse video *on top* of the syntax colour — the one combination where syntax, the add/remove signal, and word emphasis are all visible at once. A theme with no background support keeps the coloured `+`/`−` marker instead, so nothing is lost.
46
+
47
+ Old/new line numbers run down the left. And with `diffSplit` on, a wide enough terminal shows the change side-by-side — old on the left, new on the right — falling back to the unified view below 100 columns. Turn either off in settings.
48
+
49
+ ```
50
+ 1 1 export function total(items) {
51
+ 2 - const sum = items.reduce((a, b) => a + b, 0); ← "0" reversed on a faint red line
52
+ 2 + const sum = items.reduce((a, b) => a + b, 1); ← "1" reversed on a faint green line
53
+ 3 3 return sum;
54
+ ```
55
+
43
56
  ## Per-tool opt-out
44
57
 
45
58
  Each renderer toggles independently, and the choice is persisted per session:
@@ -65,10 +78,15 @@ Every cap in a renderer is somebody's taste, and the right number depends on you
65
78
  "expandedLines": 200,
66
79
  "diffLines": 200,
67
80
  "syntaxHighlight": true,
81
+ "diffSyntax": true,
82
+ "diffLineNumbers": true,
83
+ "diffSplit": false,
68
84
  "summaryClip": 100
69
85
  }
70
86
  ```
71
87
 
88
+ `syntaxHighlight` highlights expanded `read` results; `diffSyntax` does the same for the body of an edit diff (turn it off to get the older foreground-only diff); `diffLineNumbers` toggles the old/new gutter; `diffSplit` renders the diff side-by-side when the terminal is at least 100 columns wide, unified otherwise.
89
+
72
90
  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 should tell you it was a typo instead of quietly doing nothing. `/pretty` shows the settings in force and where they came from.
73
91
 
74
92
  Expanded bodies are capped by `expandedLines`, because a 5,000-line diff or grep result rendered in full scrolls the conversation away — which is the problem this extension exists to solve.
@@ -14,13 +14,13 @@
14
14
  * (giladbarnea/pi-pretty-bash), per-surface opt-in (pi-zentui).
15
15
  */
16
16
  import {
17
- createBashTool,
18
- createEditTool,
19
- createFindTool,
20
- createGrepTool,
21
- createLsTool,
22
- createReadTool,
23
- createWriteTool,
17
+ createBashToolDefinition,
18
+ createEditToolDefinition,
19
+ createFindToolDefinition,
20
+ createGrepToolDefinition,
21
+ createLsToolDefinition,
22
+ createReadToolDefinition,
23
+ createWriteToolDefinition,
24
24
  getAgentDir,
25
25
  getLanguageFromPath,
26
26
  highlightCode,
@@ -39,7 +39,8 @@ import {
39
39
  replayBranch,
40
40
  statusLines,
41
41
  } from "../src/config.ts";
42
- import { colorizeDiff, diffStats, statsLabel } from "../src/diff.ts";
42
+ import { colorizeDiff, diffStats, statsLabel, type DiffRenderOptions } from "../src/diff.ts";
43
+ import { buildSplit, splitFits } from "../src/split.ts";
43
44
  import { limitsFrom, preview } from "../src/preview.ts";
44
45
  import { DEFAULT_SETTINGS, formatSettings, resolveSettings, type PrettySettings } from "../src/settings.ts";
45
46
  import {
@@ -59,6 +60,7 @@ import {
59
60
  DEFAULT_CONFIG,
60
61
  isRecord,
61
62
  textContent,
63
+ type HighlightLine,
62
64
  type PrettyConfig,
63
65
  type PrettyTool,
64
66
  type ThemeLike,
@@ -120,14 +122,42 @@ export default function pretty(pi: ExtensionAPI) {
120
122
  }
121
123
 
122
124
  function buildOriginals(cwd: string): Record<PrettyTool, AnyTool> {
125
+ // The *ToolDefinition* factories, not the createReadTool wrappers: the
126
+ // wrapper (wrapToolDefinition) copies only name/label/description/
127
+ // parameters/execute and DROPS promptSnippet, promptGuidelines and the
128
+ // built-in renderers. Re-registering the wrapped form removed six of the
129
+ // seven builtins from the system prompt's "Available tools" list —
130
+ // measured: with pretty loaded the list shrank to bash alone, and the
131
+ // Guidelines steered the model to bash for file operations because read,
132
+ // edit and write were no longer named. A renderer package must never
133
+ // change what the model is told it can do.
123
134
  return {
124
- read: createReadTool(cwd) as unknown as AnyTool,
125
- bash: createBashTool(cwd) as unknown as AnyTool,
126
- edit: createEditTool(cwd) as unknown as AnyTool,
127
- write: createWriteTool(cwd) as unknown as AnyTool,
128
- grep: createGrepTool(cwd) as unknown as AnyTool,
129
- find: createFindTool(cwd) as unknown as AnyTool,
130
- ls: createLsTool(cwd) as unknown as AnyTool,
135
+ read: createReadToolDefinition(cwd) as unknown as AnyTool,
136
+ bash: createBashToolDefinition(cwd) as unknown as AnyTool,
137
+ edit: createEditToolDefinition(cwd) as unknown as AnyTool,
138
+ write: createWriteToolDefinition(cwd) as unknown as AnyTool,
139
+ grep: createGrepToolDefinition(cwd) as unknown as AnyTool,
140
+ find: createFindToolDefinition(cwd) as unknown as AnyTool,
141
+ ls: createLsToolDefinition(cwd) as unknown as AnyTool,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * pi's own highlighter, one line at a time. `highlightCode` returns an array
147
+ * of ANSI lines; a diff feeds it one content line at a time, so join back to
148
+ * a single string. Best-effort — the diff renderer falls back to raw text if
149
+ * this throws on a grammar it cannot parse.
150
+ */
151
+ const highlightLine: HighlightLine = (code, language) => highlightCode(code, language).join("");
152
+
153
+ /** The diff-rendering options in force, given the current settings and file. */
154
+ function diffOptions(path: string | undefined): DiffRenderOptions {
155
+ const language = path ? getLanguageFromPath(path) : undefined;
156
+ return {
157
+ emphasis: true,
158
+ lineNumbers: settings.diffLineNumbers,
159
+ language: settings.diffSyntax ? language ?? undefined : undefined,
160
+ highlight: settings.diffSyntax ? highlightLine : undefined,
131
161
  };
132
162
  }
133
163
 
@@ -192,6 +222,7 @@ export default function pretty(pi: ExtensionAPI) {
192
222
  result: unknown,
193
223
  options: { expanded?: boolean; isPartial?: boolean },
194
224
  theme: ThemeLike,
225
+ context?: { args?: { path?: string } },
195
226
  ) => {
196
227
  if (options.isPartial) return new Text(theme.fg("warning", "Editing…"), 0, 0);
197
228
  if (isFailed(result)) {
@@ -203,7 +234,13 @@ export default function pretty(pi: ExtensionAPI) {
203
234
  : "";
204
235
  const stats = statsLabel(theme, diffStats(diff));
205
236
  if (!options.expanded) return new Text(stats, 0, 0);
206
- return new Text(`${stats}\n${colorizeDiff(theme, preview(diff, true, { collapsed: settings.collapsedLines, expanded: settings.diffLines }))}`, 0, 0);
237
+ const body = preview(diff, true, { collapsed: settings.collapsedLines, expanded: settings.diffLines });
238
+ const opts = diffOptions(context?.args?.path);
239
+ const columns = terminalColumns();
240
+ if (settings.diffSplit && splitFits(columns)) {
241
+ return new Text(`${stats}\n${buildSplit(theme, body, opts, columns!)}`, 0, 0);
242
+ }
243
+ return new Text(`${stats}\n${colorizeDiff(theme, body, opts)}`, 0, 0);
207
244
  },
208
245
  };
209
246
  case "write":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/pretty",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
package/src/ansi.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Overlaying one attribute onto already-coloured text.
3
+ *
4
+ * Syntax highlighting spends the foreground: after `highlightCode` runs, a
5
+ * line is a string of printable characters interleaved with SGR escape
6
+ * sequences, and the character offsets a word diff computed against the raw
7
+ * text no longer line up with positions in that string. So to mark *which*
8
+ * words changed on a syntax-highlighted line — the thing that makes a long
9
+ * call's one changed argument findable — the marker cannot be another
10
+ * foreground colour. It has to be an attribute that composes with whatever is
11
+ * underneath: reverse video.
12
+ *
13
+ * `overlayRanges` walks the coloured string, counting only printable columns
14
+ * and copying escape sequences through untouched, and wraps the requested
15
+ * column ranges in an on/off pair. Reverse-video's pair (`\x1b[7m` / `\x1b[27m`)
16
+ * touches neither foreground nor background, so the syntax colours and any
17
+ * surrounding line background both survive.
18
+ *
19
+ * Zero dependencies, like the rest of this package.
20
+ */
21
+
22
+ /** A half-open [start, end) run of printable columns. */
23
+ export type Range = [number, number];
24
+
25
+ /**
26
+ * Wrap each printable-column range of `ansi` in the `on`/`off` pair. Ranges
27
+ * must be sorted and non-overlapping; escape sequences are passed through and
28
+ * do not advance the column count, so offsets stay in raw-text coordinates.
29
+ */
30
+ export function overlayRanges(ansi: string, ranges: readonly Range[], on: string, off: string): string {
31
+ if (ranges.length === 0) return ansi;
32
+ let out = "";
33
+ let col = 0;
34
+ let ri = 0;
35
+ let inside = false;
36
+ let i = 0;
37
+ while (i < ansi.length) {
38
+ if (ansi[i] === "\x1b") {
39
+ // Copy the whole CSI/SGR sequence: ESC, then usually '[', then
40
+ // parameter/intermediate bytes, then a final letter.
41
+ let j = i + 1;
42
+ if (ansi[j] === "[") {
43
+ j++;
44
+ while (j < ansi.length && !/[A-Za-z]/.test(ansi[j]!)) j++;
45
+ }
46
+ if (j < ansi.length) j++;
47
+ out += ansi.slice(i, j);
48
+ i = j;
49
+ continue;
50
+ }
51
+ while (ri < ranges.length && col >= ranges[ri]![1]) ri++;
52
+ const shouldBeInside = ri < ranges.length && col >= ranges[ri]![0];
53
+ if (shouldBeInside && !inside) {
54
+ out += on;
55
+ inside = true;
56
+ } else if (!shouldBeInside && inside) {
57
+ out += off;
58
+ inside = false;
59
+ }
60
+ out += ansi[i];
61
+ col++;
62
+ i++;
63
+ }
64
+ if (inside) out += off;
65
+ return out;
66
+ }
67
+
68
+ /** Printable width of an ANSI string — its length with escape sequences removed. */
69
+ export function visibleLength(ansi: string): number {
70
+ let n = 0;
71
+ let i = 0;
72
+ while (i < ansi.length) {
73
+ if (ansi[i] === "\x1b") {
74
+ let j = i + 1;
75
+ if (ansi[j] === "[") {
76
+ j++;
77
+ while (j < ansi.length && !/[A-Za-z]/.test(ansi[j]!)) j++;
78
+ }
79
+ if (j < ansi.length) j++;
80
+ i = j;
81
+ continue;
82
+ }
83
+ n++;
84
+ i++;
85
+ }
86
+ return n;
87
+ }
package/src/diff.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { overlayRanges, type Range } from "./ansi.ts";
1
2
  import { MIN_SIMILARITY, wordDiff, type Segment } from "./words.ts";
2
- import type { ThemeLike } from "./types.ts";
3
+ import type { HighlightLine, ThemeLike } from "./types.ts";
3
4
 
4
5
  export interface DiffStats {
5
6
  added: number;
@@ -46,9 +47,103 @@ function fg(theme: ThemeLike, color: string, fallback: string, text: string): st
46
47
  }
47
48
  }
48
49
 
50
+ /**
51
+ * A changed line's background. Foreground is already spent on syntax colours,
52
+ * so the +/- signal moves here. The keys are pi's tool-state backgrounds,
53
+ * which its builtin themes define and this package's themes map to real diff
54
+ * tints (`diff_add`/`diff_del`). A theme without `bg`, or one that does not
55
+ * know the key, leaves the line un-backgrounded — the coloured marker still
56
+ * carries the signal.
57
+ */
58
+ function withBg(theme: ThemeLike, key: string, text: string): string {
59
+ if (!theme.bg) return text;
60
+ try {
61
+ return theme.bg(key, text);
62
+ } catch {
63
+ return text;
64
+ }
65
+ }
66
+
49
67
  const ADDED = "toolDiffAdded";
50
68
  const REMOVED = "toolDiffRemoved";
51
69
  const CONTEXT = "toolDiffContext";
70
+ const ADDED_BG = "toolSuccessBg";
71
+ const REMOVED_BG = "toolErrorBg";
72
+
73
+ // Reverse-video's SGR pair — see ansi.ts. Hard-coded rather than derived from
74
+ // theme.inverse so the on/off can be injected mid-string around a range.
75
+ const INV_ON = "\x1b[7m";
76
+ const INV_OFF = "\x1b[27m";
77
+
78
+ export interface DiffRenderOptions {
79
+ /** Word-level emphasis on paired lines (default true). */
80
+ emphasis?: boolean;
81
+ /** Prepend old/new line-number gutters (default false; the extension sets it). */
82
+ lineNumbers?: boolean;
83
+ /** Language for syntax highlighting; undefined leaves the body un-highlighted. */
84
+ language?: string;
85
+ /** Injected highlighter (the extension passes pi's `highlightCode`). */
86
+ highlight?: HighlightLine;
87
+ }
88
+
89
+ function normalize(options: boolean | DiffRenderOptions | undefined): DiffRenderOptions {
90
+ if (options === undefined) return { emphasis: true };
91
+ if (typeof options === "boolean") return { emphasis: options };
92
+ return { emphasis: options.emphasis ?? true, ...options };
93
+ }
94
+
95
+ interface LineNo {
96
+ old: number | null;
97
+ new: number | null;
98
+ }
99
+
100
+ /** Per-original-line old/new numbers, tracked across hunks. */
101
+ function computeLineNos(lines: string[]): LineNo[] {
102
+ const nos: LineNo[] = [];
103
+ let oldNo = 0;
104
+ let newNo = 0;
105
+ let inHunk = false;
106
+ for (const line of lines) {
107
+ if (line.startsWith("@@")) {
108
+ const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
109
+ if (m) {
110
+ oldNo = Number(m[1]);
111
+ newNo = Number(m[2]);
112
+ }
113
+ inHunk = true;
114
+ nos.push({ old: null, new: null });
115
+ continue;
116
+ }
117
+ // "\ No newline…" notes and this package's own "… +N more" truncation
118
+ // marker are not file lines and take no number.
119
+ if (!inHunk || line.startsWith("\\") || line.startsWith("…")) {
120
+ nos.push({ old: null, new: null });
121
+ continue;
122
+ }
123
+ if (line.startsWith("+") && !line.startsWith("+++")) {
124
+ nos.push({ old: null, new: newNo++ });
125
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
126
+ nos.push({ old: oldNo++, new: null });
127
+ } else {
128
+ nos.push({ old: oldNo++, new: newNo++ });
129
+ }
130
+ }
131
+ return nos;
132
+ }
133
+
134
+ function gutterWidth(nos: LineNo[]): number {
135
+ let max = 0;
136
+ for (const n of nos) {
137
+ if (n.old !== null) max = Math.max(max, n.old);
138
+ if (n.new !== null) max = Math.max(max, n.new);
139
+ }
140
+ return String(max).length;
141
+ }
142
+
143
+ function formatGutter(theme: ThemeLike, no: LineNo, w: number): string {
144
+ const cell = (v: number | null) => (v === null ? " ".repeat(w) : String(v).padStart(w));
145
+ return theme.fg("dim", `${cell(no.old)} ${cell(no.new)} `);
146
+ }
52
147
 
53
148
  function plain(theme: ThemeLike, line: string): string {
54
149
  if (line.startsWith("+++") || line.startsWith("---")) return theme.fg("dim", line);
@@ -59,24 +154,62 @@ function plain(theme: ThemeLike, line: string): string {
59
154
  }
60
155
 
61
156
  /**
62
- * Paint one side of a matched pair: what changed keeps the diff colour, what
63
- * carried over from the other line goes quiet. Segments are emitted one after
64
- * another and never nested, so there is no inner reset to swallow an outer
65
- * colour — the failure mode that makes hand-built ANSI look corrupted.
157
+ * Paint one side of a matched pair with foreground emphasis: what changed
158
+ * keeps the diff colour, what carried over goes quiet. Segments are emitted
159
+ * one after another and never nested, so there is no inner reset to swallow an
160
+ * outer colour — the failure mode that makes hand-built ANSI look corrupted.
66
161
  */
67
- function emphasize(
162
+ function emphasize(theme: ThemeLike, marker: string, segments: Segment[], color: string, fallback: string): string {
163
+ return (
164
+ fg(theme, color, fallback, marker) +
165
+ segments.map((s) => (s.changed ? fg(theme, color, fallback, s.text) : fg(theme, CONTEXT, "dim", s.text))).join("")
166
+ );
167
+ }
168
+
169
+ /** Char ranges of the changed segments, in raw-content coordinates. */
170
+ function changedRanges(segments: Segment[]): Range[] {
171
+ const ranges: Range[] = [];
172
+ let pos = 0;
173
+ for (const s of segments) {
174
+ if (s.changed) ranges.push([pos, pos + s.text.length]);
175
+ pos += s.text.length;
176
+ }
177
+ return ranges;
178
+ }
179
+
180
+ function highlight(theme: ThemeLike, opts: DiffRenderOptions, content: string): string {
181
+ if (!opts.highlight || !opts.language) return content;
182
+ try {
183
+ return opts.highlight(content, opts.language);
184
+ } catch {
185
+ return content;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * A syntax-highlighted diff line: the content keeps its syntax colours, the
191
+ * +/- signal is a subtle line background, and the changed words are marked
192
+ * with reverse video overlaid on top — the only combination where all three
193
+ * survive at once (foreground is spent on syntax, so emphasis cannot be a
194
+ * fourth foreground).
195
+ */
196
+ function syntaxLine(
68
197
  theme: ThemeLike,
198
+ opts: DiffRenderOptions,
69
199
  marker: string,
70
- segments: Segment[],
200
+ content: string,
201
+ segments: Segment[] | null,
71
202
  color: string,
72
203
  fallback: string,
204
+ bgKey: string | null,
73
205
  ): string {
74
- return (
75
- fg(theme, color, fallback, marker) +
76
- segments
77
- .map((s) => (s.changed ? fg(theme, color, fallback, s.text) : fg(theme, CONTEXT, "dim", s.text)))
78
- .join("")
79
- );
206
+ let body = highlight(theme, opts, content);
207
+ if (segments && theme.inverse) {
208
+ const ranges = changedRanges(segments);
209
+ if (ranges.length > 0) body = overlayRanges(body, ranges, INV_ON, INV_OFF);
210
+ }
211
+ const line = fg(theme, color, fallback, marker) + body;
212
+ return bgKey ? withBg(theme, bgKey, line) : line;
80
213
  }
81
214
 
82
215
  /**
@@ -84,24 +217,42 @@ function emphasize(
84
217
  *
85
218
  * Line colour alone says *that* a line changed; when the change is one
86
219
  * argument in a long call, finding it is still the reader's job. So a removed
87
- * line and the added line that replaced it are compared word by word, and
88
- * only where they are similar enough for "what changed" to mean anything.
89
- * Lines with no counterpart — a pure insertion, a pure deletion, an unequal
90
- * run — are coloured whole, exactly as before.
220
+ * line and the added line that replaced it are compared word by word, and only
221
+ * where they are similar enough for "what changed" to mean anything. Lines
222
+ * with no counterpart — a pure insertion, a pure deletion, an unequal run —
223
+ * are coloured whole.
224
+ *
225
+ * With a `highlight` and `language`, the body is syntax-highlighted and the
226
+ * +/- signal moves to the line background so both are visible; without them it
227
+ * falls back to the foreground-only rendering. Line numbers are prepended when
228
+ * asked for.
91
229
  */
92
- export function colorizeDiff(theme: ThemeLike, diff: string, emphasis = true): string {
230
+ export function colorizeDiff(theme: ThemeLike, diff: string, options?: boolean | DiffRenderOptions): string {
231
+ const opts = normalize(options);
93
232
  const lines = diff.split("\n");
94
- if (!emphasis) return lines.map((line) => plain(theme, line)).join("\n");
233
+ const nos = opts.lineNumbers ? computeLineNos(lines) : null;
234
+ const width = nos ? gutterWidth(nos) : 0;
235
+ const syntax = Boolean(opts.highlight && opts.language);
236
+
237
+ const rendered: string[] = new Array(lines.length);
238
+ const put = (idx: number, text: string) => {
239
+ rendered[idx] = nos ? formatGutter(theme, nos[idx]!, width) + text : text;
240
+ };
241
+
242
+ const contextLine = (idx: number, line: string) => {
243
+ if (!syntax || line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("\\")) {
244
+ put(idx, plain(theme, line));
245
+ return;
246
+ }
247
+ // A context line keeps its leading space and gets syntax colour, no bg.
248
+ put(idx, line.slice(0, 1) + highlight(theme, opts, line.slice(1)));
249
+ };
95
250
 
96
- const out: string[] = [];
97
251
  let inHunk = false;
98
252
  for (let i = 0; i < lines.length; i++) {
99
253
  const line = lines[i]!;
100
254
  if (line.startsWith("@@")) inHunk = true;
101
255
 
102
- // A removed run followed by an added run of the same length is the shape
103
- // an in-place change takes; anything else is an insertion or a deletion,
104
- // and pairing those would invent a correspondence that is not there.
105
256
  if (inHunk && line.startsWith("-") && !line.startsWith("---")) {
106
257
  const removed: string[] = [];
107
258
  let j = i;
@@ -111,30 +262,53 @@ export function colorizeDiff(theme: ThemeLike, diff: string, emphasis = true): s
111
262
  while (k < lines.length && lines[k]!.startsWith("+") && !lines[k]!.startsWith("+++")) added.push(lines[k++]!);
112
263
 
113
264
  const pairs =
114
- removed.length > 0 && removed.length === added.length
265
+ opts.emphasis && removed.length > 0 && removed.length === added.length
115
266
  ? removed.map((minus, index) => wordDiff(minus.slice(1), added[index]!.slice(1)))
116
267
  : null;
117
268
 
118
269
  if (pairs && pairs.every((pair) => pair.similarity >= MIN_SIMILARITY)) {
119
270
  for (const [index, pair] of pairs.entries()) {
120
- out.push(emphasize(theme, removed[index]![0]!, pair.removed, REMOVED, "error"));
271
+ const marker = removed[index]![0]!;
272
+ const content = removed[index]!.slice(1);
273
+ put(
274
+ i + index,
275
+ syntax
276
+ ? syntaxLine(theme, opts, marker, content, pair.removed, REMOVED, "error", REMOVED_BG)
277
+ : emphasize(theme, marker, pair.removed, REMOVED, "error"),
278
+ );
121
279
  }
122
280
  for (const [index, pair] of pairs.entries()) {
123
- out.push(emphasize(theme, added[index]![0]!, pair.added, ADDED, "success"));
281
+ const marker = added[index]![0]!;
282
+ const content = added[index]!.slice(1);
283
+ put(
284
+ j + index,
285
+ syntax
286
+ ? syntaxLine(theme, opts, marker, content, pair.added, ADDED, "success", ADDED_BG)
287
+ : emphasize(theme, marker, pair.added, ADDED, "success"),
288
+ );
124
289
  }
125
290
  i = k - 1;
126
291
  continue;
127
292
  }
128
293
 
129
- // The run has no line-for-line correspondence, so nothing inside it
130
- // does either. Emitting it whole is what stops the tail of a 2-for-1
131
- // rewrite from being re-scanned and paired with the line that replaced
132
- // both of them.
133
- for (const minus of removed) out.push(plain(theme, minus));
294
+ // No line-for-line correspondence, so nothing inside does either.
295
+ for (let index = 0; index < removed.length; index++) {
296
+ const minus = removed[index]!;
297
+ put(
298
+ i + index,
299
+ syntax ? syntaxLine(theme, opts, minus[0]!, minus.slice(1), null, REMOVED, "error", REMOVED_BG) : plain(theme, minus),
300
+ );
301
+ }
134
302
  i = j - 1;
135
303
  continue;
136
304
  }
137
- out.push(plain(theme, line));
305
+
306
+ if (syntax && inHunk && line.startsWith("+") && !line.startsWith("+++")) {
307
+ put(i, syntaxLine(theme, opts, line[0]!, line.slice(1), null, ADDED, "success", ADDED_BG));
308
+ continue;
309
+ }
310
+
311
+ contextLine(i, line);
138
312
  }
139
- return out.join("\n");
313
+ return rendered.join("\n");
140
314
  }
package/src/settings.ts CHANGED
@@ -21,6 +21,12 @@ export interface PrettySettings {
21
21
  diffLines: number;
22
22
  /** Highlight expanded read results with pi's own highlighter. */
23
23
  syntaxHighlight: boolean;
24
+ /** Syntax-highlight the body of an edit diff, not just tint the lines. */
25
+ diffSyntax: boolean;
26
+ /** Prepend old/new line-number gutters to an expanded edit diff. */
27
+ diffLineNumbers: boolean;
28
+ /** Render the edit diff side-by-side (old | new) when the terminal is wide enough. */
29
+ diffSplit: boolean;
24
30
  /** Longest path/command shown in a one-line summary. */
25
31
  summaryClip: number;
26
32
  }
@@ -30,6 +36,9 @@ export const DEFAULT_SETTINGS: PrettySettings = {
30
36
  expandedLines: 200,
31
37
  diffLines: 200,
32
38
  syntaxHighlight: true,
39
+ diffSyntax: true,
40
+ diffLineNumbers: true,
41
+ diffSplit: false,
33
42
  summaryClip: 100,
34
43
  };
35
44
 
@@ -38,6 +47,9 @@ const LIMITS: Record<keyof PrettySettings, { min: number; max: number } | null>
38
47
  expandedLines: { min: 5, max: 10_000 },
39
48
  diffLines: { min: 5, max: 10_000 },
40
49
  syntaxHighlight: null,
50
+ diffSyntax: null,
51
+ diffLineNumbers: null,
52
+ diffSplit: null,
41
53
  summaryClip: { min: 20, max: 500 },
42
54
  };
43
55
 
package/src/split.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Side-by-side diff, old on the left and new on the right.
3
+ *
4
+ * A unified diff answers "what changed"; a split answers "what did it become",
5
+ * which is the question you have when a block was rewritten rather than nudged.
6
+ * It costs width — two columns of code plus gutters and a separator — so it is
7
+ * opt-in and falls back to unified when the terminal cannot spare the columns
8
+ * (`splitFits`). Rows are still one `Text`: cells are padded by *visible*
9
+ * width, counting printable columns and not the escape sequences syntax
10
+ * highlighting leaves behind.
11
+ *
12
+ * Word-level emphasis is deliberately not carried over here — the spatial
13
+ * pairing is the emphasis in a split — so this stays a straightforward
14
+ * highlight-and-tint. Zero dependencies, like the rest of the package.
15
+ */
16
+ import { visibleLength } from "./ansi.ts";
17
+ import type { DiffRenderOptions } from "./diff.ts";
18
+ import type { ThemeLike } from "./types.ts";
19
+
20
+ /** Narrowest terminal a split is worth rendering in; below this, use unified. */
21
+ export const MIN_SPLIT_WIDTH = 100;
22
+ /** Narrowest a single code column may be squeezed to. */
23
+ const MIN_CELL = 20;
24
+ const SEP = " │ ";
25
+
26
+ export function splitFits(columns: number | undefined): boolean {
27
+ return typeof columns === "number" && Number.isFinite(columns) && columns >= MIN_SPLIT_WIDTH;
28
+ }
29
+
30
+ interface Cell {
31
+ no: number | null;
32
+ marker: string;
33
+ content: string;
34
+ }
35
+
36
+ interface Row {
37
+ /** A row that spans both columns (hunk/file headers), pre-rendered. */
38
+ full?: string;
39
+ left?: Cell | null;
40
+ right?: Cell | null;
41
+ }
42
+
43
+ function fg(theme: ThemeLike, color: string, fallback: string, text: string): string {
44
+ try {
45
+ return theme.fg(color, text);
46
+ } catch {
47
+ return theme.fg(fallback, text);
48
+ }
49
+ }
50
+
51
+ function withBg(theme: ThemeLike, key: string, text: string): string {
52
+ if (!theme.bg) return text;
53
+ try {
54
+ return theme.bg(key, text);
55
+ } catch {
56
+ return text;
57
+ }
58
+ }
59
+
60
+ function highlight(opts: DiffRenderOptions, content: string): string {
61
+ if (!opts.highlight || !opts.language) return content;
62
+ try {
63
+ return opts.highlight(content, opts.language);
64
+ } catch {
65
+ return content;
66
+ }
67
+ }
68
+
69
+ function parseRows(lines: string[]): Row[] {
70
+ const rows: Row[] = [];
71
+ let oldNo = 0;
72
+ let newNo = 0;
73
+ let inHunk = false;
74
+ let i = 0;
75
+ while (i < lines.length) {
76
+ const line = lines[i]!;
77
+ if (line.startsWith("@@")) {
78
+ const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
79
+ if (m) {
80
+ oldNo = Number(m[1]);
81
+ newNo = Number(m[2]);
82
+ }
83
+ inHunk = true;
84
+ rows.push({ full: line });
85
+ i++;
86
+ continue;
87
+ }
88
+ if (!inHunk || line.startsWith("+++") || line.startsWith("---") || line.startsWith("\\") || line.startsWith("…")) {
89
+ rows.push({ full: line });
90
+ i++;
91
+ continue;
92
+ }
93
+ if (line.startsWith("-")) {
94
+ const removed: string[] = [];
95
+ while (i < lines.length && lines[i]!.startsWith("-") && !lines[i]!.startsWith("---")) removed.push(lines[i++]!);
96
+ const added: string[] = [];
97
+ while (i < lines.length && lines[i]!.startsWith("+") && !lines[i]!.startsWith("+++")) added.push(lines[i++]!);
98
+ const n = Math.max(removed.length, added.length);
99
+ for (let x = 0; x < n; x++) {
100
+ const left = x < removed.length ? { no: oldNo++, marker: "-", content: removed[x]!.slice(1) } : null;
101
+ const right = x < added.length ? { no: newNo++, marker: "+", content: added[x]!.slice(1) } : null;
102
+ rows.push({ left, right });
103
+ }
104
+ continue;
105
+ }
106
+ if (line.startsWith("+")) {
107
+ const added: string[] = [];
108
+ while (i < lines.length && lines[i]!.startsWith("+") && !lines[i]!.startsWith("+++")) added.push(lines[i++]!);
109
+ for (const a of added) rows.push({ left: null, right: { no: newNo++, marker: "+", content: a.slice(1) } });
110
+ continue;
111
+ }
112
+ // context
113
+ rows.push({
114
+ left: { no: oldNo++, marker: " ", content: line.slice(1) },
115
+ right: { no: newNo++, marker: " ", content: line.slice(1) },
116
+ });
117
+ i++;
118
+ }
119
+ return rows;
120
+ }
121
+
122
+ function maxNoWidth(rows: Row[]): number {
123
+ let max = 0;
124
+ for (const r of rows) {
125
+ if (r.left && r.left.no !== null) max = Math.max(max, r.left.no);
126
+ if (r.right && r.right.no !== null) max = Math.max(max, r.right.no);
127
+ }
128
+ return String(max).length;
129
+ }
130
+
131
+ function renderCell(theme: ThemeLike, opts: DiffRenderOptions, cell: Cell | null | undefined, numW: number, cellW: number): string {
132
+ const gutter = numW > 0 ? theme.fg("dim", (cell && cell.no !== null ? String(cell.no).padStart(numW) : " ".repeat(numW)) + " ") : "";
133
+ if (!cell) return gutter + " ".repeat(cellW);
134
+
135
+ const room = cellW - 1; // one column for the marker
136
+ const clipped = cell.content.length > room ? cell.content.slice(0, Math.max(0, room - 1)) + "…" : cell.content;
137
+ const marker = cell.marker === "+" ? fg(theme, "toolDiffAdded", "success", "+") : cell.marker === "-" ? fg(theme, "toolDiffRemoved", "error", "-") : " ";
138
+ let body = marker + highlight(opts, clipped);
139
+ const pad = cellW - visibleLength(body);
140
+ if (pad > 0) body += " ".repeat(pad);
141
+ if (cell.marker === "+") body = withBg(theme, "toolSuccessBg", body);
142
+ else if (cell.marker === "-") body = withBg(theme, "toolErrorBg", body);
143
+ return gutter + body;
144
+ }
145
+
146
+ function renderFull(theme: ThemeLike, line: string): string {
147
+ if (line.startsWith("@@")) return theme.fg("accent", line);
148
+ return theme.fg("dim", line);
149
+ }
150
+
151
+ export function buildSplit(theme: ThemeLike, diff: string, opts: DiffRenderOptions, columns: number): string {
152
+ const rows = parseRows(diff.split("\n"));
153
+ const numW = opts.lineNumbers ? maxNoWidth(rows) : 0;
154
+ const gut = numW > 0 ? numW + 1 : 0;
155
+ const cellW = Math.max(MIN_CELL, Math.floor((columns - SEP.length - 2 * gut) / 2));
156
+ return rows
157
+ .map((row) =>
158
+ row.full !== undefined
159
+ ? renderFull(theme, row.full)
160
+ : renderCell(theme, opts, row.left, numW, cellW) + theme.fg("dim", SEP) + renderCell(theme, opts, row.right, numW, cellW),
161
+ )
162
+ .join("\n");
163
+ }
package/src/types.ts CHANGED
@@ -7,8 +7,22 @@
7
7
  export interface ThemeLike {
8
8
  fg(color: string, text: string): string;
9
9
  bold(text: string): string;
10
+ /**
11
+ * Background paint, `theme.bg(key, text)`. Optional so src/ typechecks
12
+ * standalone and a test theme can omit it; the diff renderer guards its
13
+ * absence (and, like `fg`, a name the theme does not know). Used to give a
14
+ * changed line a subtle +/- background instead of a whole-line foreground
15
+ * tint, which is the only way a syntax-highlighted body can also read as a
16
+ * diff — foreground is already spent on the syntax.
17
+ */
18
+ bg?(color: string, text: string): string;
19
+ /** Reverse video, `theme.inverse(text)` — marks the exact changed words. */
20
+ inverse?(text: string): string;
10
21
  }
11
22
 
23
+ /** Syntax-highlight one line of code to an ANSI string; empty deps in src/. */
24
+ export type HighlightLine = (code: string, language: string) => string;
25
+
12
26
  /** The tools this extension can re-render. */
13
27
  export const PRETTY_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
14
28
  export type PrettyTool = (typeof PRETTY_TOOLS)[number];