@pify/pretty 0.3.1 → 0.4.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,40 +1,61 @@
1
1
  # @pify/pretty
2
2
 
3
- Compact, theme-aware rendering for [pi](https://github.com/earendil-works/pi)'s built-in tools — one-line summaries that expand on demand, syntax-highlighted reads, colorized diffs. **Behavior is untouched**: every tool delegates to pi's original implementation; only the rendering changes.
3
+ Compact, theme-aware rendering for [pi](https://github.com/earendil-works/pi)'s built-in tools — one-line summaries that expand on demand, syntax-highlighted reads, colourised diffs. **Behaviour is untouched**: every tool delegates to pi's original implementation, and only the rendering changes.
4
4
 
5
5
  Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install pretty`](https://github.com/pifydev/cli) or `pi install npm:@pify/pretty`.
6
6
 
7
+ ## Why
8
+
9
+ A long session scrolls. Twenty file reads printed in full push the thing you actually wanted to see off the top of the terminal, and the conversation becomes an archaeology exercise. Collapsing each tool call to a line you can expand keeps the shape of the session visible.
10
+
11
+ Nothing here changes what a tool does. If this extension is removed, every command still behaves identically — you just get pi's default output back.
12
+
7
13
  ## What you see
8
14
 
9
15
  | Tool | Collapsed | Expanded |
10
16
  |---|---|---|
11
- | `read` | `Read src/app.ts · lines 1–50` → `50 lines` | Syntax-highlighted content (pi's own highlighter, theme-matched) |
12
- | `bash` | `Bash npm test` → `✓ 12 output lines` / `✗ first error line` | Output (12-line preview while running) |
13
- | `edit` | `Edit src/app.ts` → `+12 -3` | Colorized diff |
17
+ | `read` | `Read src/app.ts · lines 1–50` → `50 lines` | Syntax-highlighted content, theme-matched |
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 |
14
20
  | `write` | `Write y.md · 34 lines` → `✓ written` | — |
15
- | `grep`/`find` | `Grep TODO in src` → `7 matches` | Match list |
16
- | `ls` | `List packages` → `23 entries` | Listing |
21
+ | `grep` / `find` | `Grep TODO in src` → `7 matches` | The match list |
22
+ | `ls` | `List packages` → `23 entries` | The listing |
23
+
24
+ Expand with pi's standard toggle (Ctrl+O on a tool block). Failures always show the first error line in the error colour, collapsed or not — a failure you have to expand to notice is a failure you will miss.
17
25
 
18
- Expand with pi's standard toggle (Ctrl+O on a tool block). Failures always show the first error line in the error color.
26
+ ## Seeing what actually changed
27
+
28
+ Line colour tells you *that* a line changed. When the change is one argument in a forty-column call, finding it is still your job — you read both lines and spot the difference yourself, which is the work the diff was supposed to have done.
29
+
30
+ So a removed line and the added line that replaced it are compared word by word, and the rendering shows the answer: what carried over goes quiet, and what actually changed keeps the diff colour.
31
+
32
+ ```
33
+ - const total = sum(items, 0); ← "0" in the removed colour, the rest muted
34
+ + const total = sum(items, 1); ← "1" in the added colour, the rest muted
35
+ ```
36
+
37
+ The pairing is deliberately conservative. Only a removed run and an added run of the *same length* are lined up, because two lines replaced by one is a rewrite rather than two edits — pairing those would invent a correspondence that is not there. And a pair whose lines have too little in common is left alone entirely: when the whole line is the change, dimming a few incidental brackets would only mislead. Pure insertions and pure deletions have no counterpart and are coloured whole, as before.
38
+
39
+ Colours come from the theme's own `toolDiffAdded` / `toolDiffRemoved` / `toolDiffContext`, so a theme that styles diffs deliberately gets what it asked for. A theme without them falls back rather than throwing — `Theme.fg` raises on a name it does not know, and a throw inside a renderer takes the row down with it.
40
+
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.
19
42
 
20
43
  ## Per-tool opt-out
21
44
 
22
- Each renderer toggles independently (persisted per session):
45
+ Each renderer toggles independently, and the choice is persisted per session:
23
46
 
24
47
  ```
25
- /pretty # show which renderers are on
48
+ /pretty # which renderers are on, and the settings in force
26
49
  /pretty bash # toggle one back to pi's default rendering
27
- /pretty bash grep # toggle several at once (v0.2)
28
- /pretty off # everything back to pi's rendering (v0.2)
29
- /pretty on read # explicit on/off instead of toggling (v0.2)
30
- /pretty reset # all renderers back on (v0.2)
50
+ /pretty bash grep # toggle several at once
51
+ /pretty off # everything back to pi's rendering
52
+ /pretty on read # explicit on/off instead of toggling
53
+ /pretty reset # all renderers back on
31
54
  ```
32
55
 
33
- Aliases are accepted where they're obvious: `list`/`dir` → `ls`, `search`/`rg` → `grep`, `cat` → `read`, `sh`/`shell` → `bash`.
56
+ Aliases are accepted where they are obvious: `list`/`dir` → `ls`, `search`/`rg` → `grep`, `cat` → `read`, `sh`/`shell` → `bash`.
34
57
 
35
- Expanded bodies are capped at 200 lines (v0.2) — a 5,000-line diff or grep result used to render in full and scroll the conversation away.
36
-
37
- ## Settings (v0.3)
58
+ ## Settings
38
59
 
39
60
  Every cap in a renderer is somebody's taste, and the right number depends on your terminal. Put them in `.pi/pretty.json` (project) or `<agentDir>/pretty.json` (global — the project file wins):
40
61
 
@@ -48,13 +69,17 @@ Every cap in a renderer is somebody's taste, and the right number depends on you
48
69
  }
49
70
  ```
50
71
 
51
- Unknown keys, wrong types, and absurd numbers are reported at session start and fall back to the shipped defaults rather than taking the renderers down — a typo tells you it was a typo instead of quietly doing nothing. `/pretty` shows the settings in force and where they came from. (The case for making these configurable is [`pi-cc-extensions`](https://github.com/minuque/pi-cc-extensions)' `/ccstyle`, which does it with a whole settings panel.)
72
+ 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
+
74
+ 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.
75
+
76
+ `summaryClip` is a ceiling, not a target: the effective clip is also bounded by the width of your terminal, re-read on every render. A one-line summary wider than the terminal wraps onto two, and a collapsed row that takes two lines is not collapsed. Resizing mid-session is handled for the same reason.
52
77
 
53
78
  ## How it works
54
79
 
55
- The official `built-in-tool-renderer` pattern: each tool is re-registered with `createReadTool()`/`createBashTool()`/… delegating `execute` untouched, overriding only `renderCall`/`renderResult`. Highlighting uses pi's exported `highlightCode` + `getLanguageFromPath` zero extra dependencies, colors always match your theme.
80
+ Each tool is re-registered through pi's own `createReadTool()` / `createBashTool()` / … factories with `execute` delegated untouched, overriding only `renderCall` and `renderResult`. Highlighting uses pi's exported `highlightCode` and `getLanguageFromPath`, so colours always match your theme and there are no extra dependencies.
56
81
 
57
- Only one extension can own a tool's rendering: remove other pretty/TUI tool-renderer extensions (e.g. `pi-pretty`, `pi-pretty-tui`) before installing.
82
+ Only one extension can own a tool's rendering. Remove any other tool-renderer extension before installing this one.
58
83
 
59
84
  ## License
60
85
 
@@ -46,11 +46,13 @@ import {
46
46
  bashCall,
47
47
  bashSummary,
48
48
  editCall,
49
+ effectiveClip,
49
50
  listCall,
50
51
  matchSummary,
51
52
  readCall,
52
53
  readSummary,
53
54
  searchCall,
55
+ terminalColumns,
54
56
  writeCall,
55
57
  } from "../src/summary.ts";
56
58
  import {
@@ -74,6 +76,15 @@ export default function pretty(pi: ExtensionAPI) {
74
76
  let config: PrettyConfig = DEFAULT_CONFIG;
75
77
  let settings: PrettySettings = DEFAULT_SETTINGS;
76
78
  let settingsSource: string | null = null;
79
+
80
+ /**
81
+ * The clip a summary actually gets. Read per render rather than cached:
82
+ * terminals get resized mid-session, and a summary sized for the old width
83
+ * is exactly the wrapped two-line row this package exists to avoid.
84
+ */
85
+ function clipWidth(): number {
86
+ return effectiveClip(settings.summaryClip, terminalColumns());
87
+ }
77
88
  let settingsWarnings: string[] = [];
78
89
 
79
90
  /** Project settings win over global ones; neither is required. */
@@ -126,7 +137,7 @@ export default function pretty(pi: ExtensionAPI) {
126
137
  case "read":
127
138
  return {
128
139
  renderCall: (args: { path?: string; offset?: number; limit?: number }, theme: ThemeLike) =>
129
- new Text(readCall(theme, args ?? {}, settings.summaryClip), 0, 0),
140
+ new Text(readCall(theme, args ?? {}, clipWidth()), 0, 0),
130
141
  renderResult: (
131
142
  result: unknown,
132
143
  options: { expanded?: boolean; isPartial?: boolean },
@@ -158,7 +169,7 @@ export default function pretty(pi: ExtensionAPI) {
158
169
  case "bash":
159
170
  return {
160
171
  renderCall: (args: { command?: string }, theme: ThemeLike, context: { expanded?: boolean }) =>
161
- new Text(bashCall(theme, args?.command ?? "", context?.expanded === true, settings.summaryClip), 0, 0),
172
+ new Text(bashCall(theme, args?.command ?? "", context?.expanded === true, clipWidth()), 0, 0),
162
173
  renderResult: (
163
174
  result: unknown,
164
175
  options: { expanded?: boolean; isPartial?: boolean },
@@ -176,7 +187,7 @@ export default function pretty(pi: ExtensionAPI) {
176
187
  };
177
188
  case "edit":
178
189
  return {
179
- renderCall: (args: { path?: string }, theme: ThemeLike) => new Text(editCall(theme, args ?? {}, settings.summaryClip), 0, 0),
190
+ renderCall: (args: { path?: string }, theme: ThemeLike) => new Text(editCall(theme, args ?? {}, clipWidth()), 0, 0),
180
191
  renderResult: (
181
192
  result: unknown,
182
193
  options: { expanded?: boolean; isPartial?: boolean },
@@ -198,7 +209,7 @@ export default function pretty(pi: ExtensionAPI) {
198
209
  case "write":
199
210
  return {
200
211
  renderCall: (args: { path?: string; content?: string }, theme: ThemeLike) =>
201
- new Text(writeCall(theme, args ?? {}, settings.summaryClip), 0, 0),
212
+ new Text(writeCall(theme, args ?? {}, clipWidth()), 0, 0),
202
213
  renderResult: (
203
214
  result: unknown,
204
215
  options: { expanded?: boolean; isPartial?: boolean },
@@ -217,7 +228,7 @@ export default function pretty(pi: ExtensionAPI) {
217
228
  renderCall: (
218
229
  args: { pattern?: string; path?: string; glob?: string },
219
230
  theme: ThemeLike,
220
- ) => new Text(searchCall(theme, tool === "grep" ? "Grep" : "Find", args ?? {}, settings.summaryClip), 0, 0),
231
+ ) => new Text(searchCall(theme, tool === "grep" ? "Grep" : "Find", args ?? {}, clipWidth()), 0, 0),
221
232
  renderResult: (
222
233
  result: unknown,
223
234
  options: { expanded?: boolean; isPartial?: boolean },
@@ -238,7 +249,7 @@ export default function pretty(pi: ExtensionAPI) {
238
249
  };
239
250
  case "ls":
240
251
  return {
241
- renderCall: (args: { path?: string }, theme: ThemeLike) => new Text(listCall(theme, args ?? {}, settings.summaryClip), 0, 0),
252
+ renderCall: (args: { path?: string }, theme: ThemeLike) => new Text(listCall(theme, args ?? {}, clipWidth()), 0, 0),
242
253
  renderResult: (
243
254
  result: unknown,
244
255
  options: { expanded?: boolean; isPartial?: boolean },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/pretty",
3
- "version": "0.3.1",
3
+ "version": "0.4.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/diff.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { MIN_SIMILARITY, wordDiff, type Segment } from "./words.ts";
1
2
  import type { ThemeLike } from "./types.ts";
2
3
 
3
4
  export interface DiffStats {
@@ -30,16 +31,110 @@ export function statsLabel(theme: ThemeLike, stats: DiffStats): string {
30
31
  return `${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}`;
31
32
  }
32
33
 
33
- /** Colorize a display diff line-by-line with theme colors. */
34
- export function colorizeDiff(theme: ThemeLike, diff: string): string {
35
- return diff
36
- .split("\n")
37
- .map((line) => {
38
- if (line.startsWith("+++") || line.startsWith("---")) return theme.fg("dim", line);
39
- if (line.startsWith("@@")) return theme.fg("accent", line);
40
- if (line.startsWith("+")) return theme.fg("success", line);
41
- if (line.startsWith("-")) return theme.fg("error", line);
42
- return line;
43
- })
44
- .join("\n");
34
+ /**
35
+ * pi's themes carry colours meant for exactly this — `toolDiffAdded`,
36
+ * `toolDiffRemoved`, `toolDiffContext` — so a theme that styles diffs
37
+ * deliberately gets what it asked for instead of the generic success/error
38
+ * pair. `Theme.fg` throws on a name it does not know, and a throw inside a
39
+ * renderer takes the row down with it, so unknown names fall back.
40
+ */
41
+ function fg(theme: ThemeLike, color: string, fallback: string, text: string): string {
42
+ try {
43
+ return theme.fg(color, text);
44
+ } catch {
45
+ return theme.fg(fallback, text);
46
+ }
47
+ }
48
+
49
+ const ADDED = "toolDiffAdded";
50
+ const REMOVED = "toolDiffRemoved";
51
+ const CONTEXT = "toolDiffContext";
52
+
53
+ function plain(theme: ThemeLike, line: string): string {
54
+ if (line.startsWith("+++") || line.startsWith("---")) return theme.fg("dim", line);
55
+ if (line.startsWith("@@")) return theme.fg("accent", line);
56
+ if (line.startsWith("+")) return fg(theme, ADDED, "success", line);
57
+ if (line.startsWith("-")) return fg(theme, REMOVED, "error", line);
58
+ return line;
59
+ }
60
+
61
+ /**
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.
66
+ */
67
+ function emphasize(
68
+ theme: ThemeLike,
69
+ marker: string,
70
+ segments: Segment[],
71
+ color: string,
72
+ fallback: string,
73
+ ): 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
+ );
80
+ }
81
+
82
+ /**
83
+ * Colourise a display diff.
84
+ *
85
+ * Line colour alone says *that* a line changed; when the change is one
86
+ * 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.
91
+ */
92
+ export function colorizeDiff(theme: ThemeLike, diff: string, emphasis = true): string {
93
+ const lines = diff.split("\n");
94
+ if (!emphasis) return lines.map((line) => plain(theme, line)).join("\n");
95
+
96
+ const out: string[] = [];
97
+ let inHunk = false;
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i]!;
100
+ if (line.startsWith("@@")) inHunk = true;
101
+
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
+ if (inHunk && line.startsWith("-") && !line.startsWith("---")) {
106
+ const removed: string[] = [];
107
+ let j = i;
108
+ while (j < lines.length && lines[j]!.startsWith("-") && !lines[j]!.startsWith("---")) removed.push(lines[j++]!);
109
+ const added: string[] = [];
110
+ let k = j;
111
+ while (k < lines.length && lines[k]!.startsWith("+") && !lines[k]!.startsWith("+++")) added.push(lines[k++]!);
112
+
113
+ const pairs =
114
+ removed.length > 0 && removed.length === added.length
115
+ ? removed.map((minus, index) => wordDiff(minus.slice(1), added[index]!.slice(1)))
116
+ : null;
117
+
118
+ if (pairs && pairs.every((pair) => pair.similarity >= MIN_SIMILARITY)) {
119
+ for (const [index, pair] of pairs.entries()) {
120
+ out.push(emphasize(theme, removed[index]![0]!, pair.removed, REMOVED, "error"));
121
+ }
122
+ for (const [index, pair] of pairs.entries()) {
123
+ out.push(emphasize(theme, added[index]![0]!, pair.added, ADDED, "success"));
124
+ }
125
+ i = k - 1;
126
+ continue;
127
+ }
128
+
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));
134
+ i = j - 1;
135
+ continue;
136
+ }
137
+ out.push(plain(theme, line));
138
+ }
139
+ return out.join("\n");
45
140
  }
package/src/summary.ts CHANGED
@@ -10,9 +10,30 @@ function title(theme: ThemeLike, name: string): string {
10
10
  return theme.fg("toolTitle", theme.bold(name)) + " ";
11
11
  }
12
12
 
13
- /** Default summary clip; overridable through settings (v0.3). */
13
+ /** Default summary clip; overridable through settings. */
14
14
  export const DEFAULT_CLIP = 100;
15
15
 
16
+ /**
17
+ * Room the summary line actually has, which is not the same as the number in
18
+ * the settings. A one-line summary that is wider than the terminal wraps to
19
+ * two, and the whole point of collapsing a tool call was that it took one.
20
+ * The reserve covers the tool name, the separators, and the indent pi puts in
21
+ * front of a tool block.
22
+ */
23
+ const WIDTH_RESERVE = 24;
24
+ const MIN_CLIP = 24;
25
+
26
+ export function effectiveClip(configured: number, columns: number | undefined): number {
27
+ if (typeof columns !== "number" || !Number.isFinite(columns) || columns <= 0) return configured;
28
+ return Math.max(MIN_CLIP, Math.min(configured, columns - WIDTH_RESERVE));
29
+ }
30
+
31
+ /** The terminal's width, when there is a terminal to ask. */
32
+ export function terminalColumns(): number | undefined {
33
+ const columns = process.stdout?.columns;
34
+ return typeof columns === "number" && Number.isFinite(columns) && columns > 0 ? columns : undefined;
35
+ }
36
+
16
37
  /**
17
38
  * Keep a one-line summary one line. A deeply nested path or a long command
18
39
  * is clipped in the middle: the start says what it is, the end says which
package/src/words.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Which part of the line actually changed.
3
+ *
4
+ * A diff coloured line-by-line tells you *that* a line changed. When the
5
+ * change is one argument in a forty-column function call, finding it is still
6
+ * your job — you read both lines and spot the difference yourself, which is
7
+ * the work the diff was supposed to have done.
8
+ *
9
+ * So a removed line and the added line that replaced it get compared word by
10
+ * word, and the rendering makes the difference visible: the words that
11
+ * carried over go quiet, and what actually changed keeps the diff colour.
12
+ * Dimming the unchanged part rather than brightening the changed one is
13
+ * deliberate — it needs no nested colour codes, so it cannot produce the
14
+ * broken escape sequences that come from painting a span inside a span.
15
+ *
16
+ * Zero dependencies, like the rest of this package.
17
+ */
18
+
19
+ /** A run of characters that either changed or did not. */
20
+ export interface Segment {
21
+ text: string;
22
+ changed: boolean;
23
+ }
24
+
25
+ export interface WordDiff {
26
+ /** 0…1 — how much of the two lines is shared. */
27
+ similarity: number;
28
+ removed: Segment[];
29
+ added: Segment[];
30
+ }
31
+
32
+ /**
33
+ * Split into words and the runs of separators between them, keeping both, so
34
+ * the segments can be concatenated back into the original line exactly.
35
+ */
36
+ export function tokenize(line: string): string[] {
37
+ return line.match(/[A-Za-z0-9_$]+|[^A-Za-z0-9_$]+/g) ?? [];
38
+ }
39
+
40
+ /**
41
+ * Longest common subsequence over tokens. Quadratic, which is fine for one
42
+ * pair of source lines and is why `maxLineLength` exists below: a minified
43
+ * bundle on one line is not something anyone reads a word diff of.
44
+ */
45
+ function lcsTable(a: readonly string[], b: readonly string[]): number[][] {
46
+ const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
47
+ for (let i = a.length - 1; i >= 0; i--) {
48
+ for (let j = b.length - 1; j >= 0; j--) {
49
+ table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
50
+ }
51
+ }
52
+ return table;
53
+ }
54
+
55
+ function push(segments: Segment[], text: string, changed: boolean): void {
56
+ if (text === "") return;
57
+ const last = segments[segments.length - 1];
58
+ if (last && last.changed === changed) last.text += text;
59
+ else segments.push({ text, changed });
60
+ }
61
+
62
+ /** Longest line this will compare; past it the answer is not worth the time. */
63
+ export const MAX_WORD_DIFF_LINE = 2000;
64
+
65
+ export function wordDiff(removed: string, added: string): WordDiff {
66
+ if (removed === added) {
67
+ return {
68
+ similarity: 1,
69
+ removed: removed ? [{ text: removed, changed: false }] : [],
70
+ added: added ? [{ text: added, changed: false }] : [],
71
+ };
72
+ }
73
+ if (removed.length > MAX_WORD_DIFF_LINE || added.length > MAX_WORD_DIFF_LINE) {
74
+ return { similarity: 0, removed: [{ text: removed, changed: true }], added: [{ text: added, changed: true }] };
75
+ }
76
+
77
+ const a = tokenize(removed);
78
+ const b = tokenize(added);
79
+ const table = lcsTable(a, b);
80
+
81
+ const removedSegments: Segment[] = [];
82
+ const addedSegments: Segment[] = [];
83
+ let shared = 0;
84
+ let i = 0;
85
+ let j = 0;
86
+ while (i < a.length && j < b.length) {
87
+ if (a[i] === b[j]) {
88
+ push(removedSegments, a[i]!, false);
89
+ push(addedSegments, b[j]!, false);
90
+ shared += a[i]!.length;
91
+ i++;
92
+ j++;
93
+ } else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
94
+ push(removedSegments, a[i]!, true);
95
+ i++;
96
+ } else {
97
+ push(addedSegments, b[j]!, true);
98
+ j++;
99
+ }
100
+ }
101
+ while (i < a.length) push(removedSegments, a[i++]!, true);
102
+ while (j < b.length) push(addedSegments, b[j++]!, true);
103
+
104
+ const longest = Math.max(removed.length, added.length);
105
+ return {
106
+ similarity: longest === 0 ? 1 : (2 * shared) / (removed.length + added.length),
107
+ removed: coalesce(removedSegments),
108
+ added: coalesce(addedSegments),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * `a b c` → `a x y` leaves the space between the two changed words matched,
114
+ * which is true and unhelpful: it breaks one change into two highlighted runs
115
+ * with an unhighlighted gap. Whitespace surrounded by change is change.
116
+ */
117
+ function coalesce(segments: Segment[]): Segment[] {
118
+ const out: Segment[] = [];
119
+ for (let i = 0; i < segments.length; i++) {
120
+ const segment = segments[i]!;
121
+ const bridged =
122
+ !segment.changed &&
123
+ segment.text.trim() === "" &&
124
+ out.length > 0 &&
125
+ out[out.length - 1]!.changed &&
126
+ segments[i + 1]?.changed === true;
127
+ push(out, segment.text, bridged ? true : segment.changed);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Below this, the two lines have too little in common for "what changed" to
134
+ * mean anything — the whole line is the change, and dimming a few incidental
135
+ * shared brackets would only mislead.
136
+ */
137
+ export const MIN_SIMILARITY = 0.34;