@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 +19 -1
- package/extensions/pretty.ts +53 -16
- package/package.json +1 -1
- package/src/ansi.ts +87 -0
- package/src/diff.ts +207 -33
- package/src/settings.ts +12 -0
- package/src/split.ts +163 -0
- package/src/types.ts +14 -0
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` |
|
|
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.
|
package/extensions/pretty.ts
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* (giladbarnea/pi-pretty-bash), per-surface opt-in (pi-zentui).
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
125
|
-
bash:
|
|
126
|
-
edit:
|
|
127
|
-
write:
|
|
128
|
-
grep:
|
|
129
|
-
find:
|
|
130
|
-
ls:
|
|
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
|
-
|
|
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
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
|
|
63
|
-
*
|
|
64
|
-
* another and never nested, so there is no inner reset to swallow an
|
|
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
|
-
|
|
200
|
+
content: string,
|
|
201
|
+
segments: Segment[] | null,
|
|
71
202
|
color: string,
|
|
72
203
|
fallback: string,
|
|
204
|
+
bgKey: string | null,
|
|
73
205
|
): string {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
segments
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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];
|