@pify/pretty 0.3.1 → 0.5.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 +67 -20
- package/extensions/pretty.ts +17 -6
- package/package.json +5 -1
- package/src/diff.ts +107 -12
- package/src/summary.ts +22 -1
- package/src/words.ts +137 -0
- package/themes/token-ultra-dark.json +89 -0
- package/themes/token-ultra-light.json +89 -0
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,
|
|
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
|
|
12
|
-
| `bash` | `Bash npm test` → `✓ 12 output lines` / `✗ first error line` | Output
|
|
13
|
-
| `edit` | `Edit src/app.ts` → `+12 -3` |
|
|
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
|
|
16
|
-
| `ls` | `List packages` → `23 entries` |
|
|
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
|
-
|
|
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
|
|
45
|
+
Each renderer toggles independently, and the choice is persisted per session:
|
|
23
46
|
|
|
24
47
|
```
|
|
25
|
-
/pretty #
|
|
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
|
|
28
|
-
/pretty off # everything back to pi's rendering
|
|
29
|
-
/pretty on read # explicit on/off instead of toggling
|
|
30
|
-
/pretty reset # all renderers back on
|
|
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
|
|
34
|
-
|
|
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.
|
|
56
|
+
Aliases are accepted where they are obvious: `list`/`dir` → `ls`, `search`/`rg` → `grep`, `cat` → `read`, `sh`/`shell` → `bash`.
|
|
36
57
|
|
|
37
|
-
## Settings
|
|
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,39 @@ 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
|
|
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.
|
|
77
|
+
|
|
78
|
+
## Themes
|
|
79
|
+
|
|
80
|
+
The renderers read the active pi theme and never their own colours, which is what keeps them consistent with the rest of your terminal — but it also means the word-level diff is only as legible as the theme's diff colours, and pi's builtin themes don't really have any. Measured: in both builtin themes `toolDiffAdded` **is** `success` and `toolDiffRemoved` **is** `error`, with context left as a flat grey. The emphasis works, but it is drawing on a palette that was never designed for it.
|
|
81
|
+
|
|
82
|
+
So this package ships two themes with a diff palette of their own, derived from the [Token](https://github.com/ThorstenRhau/token) colourscheme's Ultra appearance:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
pi --theme token-ultra-dark
|
|
86
|
+
pi --theme token-ultra-light
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Added and removed take Token's git-sign colours — the most separated pair in its palette — while context takes a muted foreground that is distinct from `dim`. Changed words and carried-over words are then genuinely different colours rather than two greys:
|
|
90
|
+
|
|
91
|
+
| | added | removed | context |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `token-ultra-dark` | `#7da47a` | `#c67777` | `#8d8983` |
|
|
94
|
+
| `token-ultra-light` | `#24831f` | `#c82a2a` | `#544e44` |
|
|
95
|
+
|
|
96
|
+
The rest follows Token's own semantics — copper for definitions, ochre for control flow, teal for literals — so syntax-highlighted reads match the scheme you may already be running in Neovim, Ghostty, delta and the rest.
|
|
97
|
+
|
|
98
|
+
Themes only apply in the TUI: `--theme` is ignored under `-p`, for pi's builtin themes as much as for these. `test/live/theme-wire.mjs` therefore drives pi's own theme loader instead of a session, and checks that every colour resolves, that the `vars` indirection really happened, and that the three diff colours are three different colours.
|
|
52
99
|
|
|
53
100
|
## How it works
|
|
54
101
|
|
|
55
|
-
|
|
102
|
+
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
103
|
|
|
57
|
-
Only one extension can own a tool's rendering
|
|
104
|
+
Only one extension can own a tool's rendering. Remove any other tool-renderer extension before installing this one.
|
|
58
105
|
|
|
59
106
|
## License
|
|
60
107
|
|
package/extensions/pretty.ts
CHANGED
|
@@ -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 ?? {},
|
|
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,
|
|
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 ?? {},
|
|
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 ?? {},
|
|
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 ?? {},
|
|
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 ?? {},
|
|
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
|
+
"version": "0.5.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",
|
|
@@ -27,12 +27,16 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"extensions",
|
|
29
29
|
"src",
|
|
30
|
+
"themes",
|
|
30
31
|
"README.md",
|
|
31
32
|
"LICENSE"
|
|
32
33
|
],
|
|
33
34
|
"pi": {
|
|
34
35
|
"extensions": [
|
|
35
36
|
"./extensions/pretty.ts"
|
|
37
|
+
],
|
|
38
|
+
"themes": [
|
|
39
|
+
"./themes"
|
|
36
40
|
]
|
|
37
41
|
},
|
|
38
42
|
"scripts": {
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
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
|
|
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;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "token-ultra-dark",
|
|
4
|
+
"vars": {
|
|
5
|
+
"accent": "#ed9574",
|
|
6
|
+
"accent2": "#f7c988",
|
|
7
|
+
"bg2": "#212120",
|
|
8
|
+
"bg3": "#272724",
|
|
9
|
+
"bg5": "#383835",
|
|
10
|
+
"blue": "#8aa5bb",
|
|
11
|
+
"bright_blue": "#90b2cc",
|
|
12
|
+
"cyan": "#7ebcbb",
|
|
13
|
+
"diff_add": "#1e3524",
|
|
14
|
+
"diff_del": "#3c2024",
|
|
15
|
+
"fg0": "#c5c1b8",
|
|
16
|
+
"fg1": "#a7a299",
|
|
17
|
+
"fg2": "#a39d94",
|
|
18
|
+
"fg3": "#8d8983",
|
|
19
|
+
"green": "#9ab58e",
|
|
20
|
+
"gsign_add": "#7da47a",
|
|
21
|
+
"gsign_del": "#c67777",
|
|
22
|
+
"indent": "#333330",
|
|
23
|
+
"line_nr": "#585855",
|
|
24
|
+
"match": "#4d4231",
|
|
25
|
+
"olive": "#aab86d",
|
|
26
|
+
"purple": "#b296cb",
|
|
27
|
+
"red": "#dd8384",
|
|
28
|
+
"sel": "#3c3b36",
|
|
29
|
+
"yellow": "#bf9c53"
|
|
30
|
+
},
|
|
31
|
+
"colors": {
|
|
32
|
+
"accent": "accent",
|
|
33
|
+
"border": "blue",
|
|
34
|
+
"borderAccent": "accent",
|
|
35
|
+
"borderMuted": "line_nr",
|
|
36
|
+
"success": "green",
|
|
37
|
+
"error": "red",
|
|
38
|
+
"warning": "yellow",
|
|
39
|
+
"muted": "fg3",
|
|
40
|
+
"dim": "line_nr",
|
|
41
|
+
"text": "fg0",
|
|
42
|
+
"thinkingText": "fg2",
|
|
43
|
+
"selectedBg": "sel",
|
|
44
|
+
"scrollbarTrack": "indent",
|
|
45
|
+
"scrollbarThumb": "fg3",
|
|
46
|
+
"searchMatchBg": "match",
|
|
47
|
+
"searchMatchText": "fg0",
|
|
48
|
+
"userMessageBg": "bg3",
|
|
49
|
+
"userMessageText": "fg0",
|
|
50
|
+
"customMessageBg": "bg2",
|
|
51
|
+
"customMessageText": "fg1",
|
|
52
|
+
"customMessageLabel": "accent2",
|
|
53
|
+
"toolPendingBg": "bg2",
|
|
54
|
+
"toolSuccessBg": "diff_add",
|
|
55
|
+
"toolErrorBg": "diff_del",
|
|
56
|
+
"toolTitle": "accent2",
|
|
57
|
+
"toolOutput": "fg1",
|
|
58
|
+
"mdHeading": "accent",
|
|
59
|
+
"mdLink": "blue",
|
|
60
|
+
"mdLinkUrl": "bright_blue",
|
|
61
|
+
"mdCode": "olive",
|
|
62
|
+
"mdCodeBlock": "fg1",
|
|
63
|
+
"mdCodeBlockBorder": "bg5",
|
|
64
|
+
"mdQuote": "fg2",
|
|
65
|
+
"mdQuoteBorder": "bg5",
|
|
66
|
+
"mdHr": "bg5",
|
|
67
|
+
"mdListBullet": "accent2",
|
|
68
|
+
"toolDiffAdded": "gsign_add",
|
|
69
|
+
"toolDiffRemoved": "gsign_del",
|
|
70
|
+
"toolDiffContext": "fg3",
|
|
71
|
+
"syntaxComment": "fg3",
|
|
72
|
+
"syntaxKeyword": "accent2",
|
|
73
|
+
"syntaxFunction": "accent",
|
|
74
|
+
"syntaxVariable": "fg0",
|
|
75
|
+
"syntaxString": "cyan",
|
|
76
|
+
"syntaxNumber": "purple",
|
|
77
|
+
"syntaxType": "blue",
|
|
78
|
+
"syntaxOperator": "fg1",
|
|
79
|
+
"syntaxPunctuation": "fg3",
|
|
80
|
+
"thinkingOff": "line_nr",
|
|
81
|
+
"thinkingMinimal": "fg3",
|
|
82
|
+
"thinkingLow": "blue",
|
|
83
|
+
"thinkingMedium": "cyan",
|
|
84
|
+
"thinkingHigh": "accent2",
|
|
85
|
+
"thinkingXhigh": "accent",
|
|
86
|
+
"thinkingMax": "red",
|
|
87
|
+
"bashMode": "olive"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "token-ultra-light",
|
|
4
|
+
"vars": {
|
|
5
|
+
"accent": "#893517",
|
|
6
|
+
"accent2": "#6c4800",
|
|
7
|
+
"bg2": "#f6f5f1",
|
|
8
|
+
"bg3": "#fbf9f4",
|
|
9
|
+
"bg5": "#eae9e5",
|
|
10
|
+
"blue": "#37526a",
|
|
11
|
+
"bright_blue": "#335571",
|
|
12
|
+
"cyan": "#185b5b",
|
|
13
|
+
"diff_add": "#daf6d5",
|
|
14
|
+
"diff_del": "#ffdada",
|
|
15
|
+
"fg0": "#31312b",
|
|
16
|
+
"fg1": "#565141",
|
|
17
|
+
"fg2": "#514b44",
|
|
18
|
+
"fg3": "#544e44",
|
|
19
|
+
"green": "#385a35",
|
|
20
|
+
"gsign_add": "#24831f",
|
|
21
|
+
"gsign_del": "#c82a2a",
|
|
22
|
+
"indent": "#e0ddd8",
|
|
23
|
+
"line_nr": "#b5b2ab",
|
|
24
|
+
"match": "#eadab6",
|
|
25
|
+
"olive": "#46550a",
|
|
26
|
+
"purple": "#573c72",
|
|
27
|
+
"red": "#ae5158",
|
|
28
|
+
"sel": "#dedbd3",
|
|
29
|
+
"yellow": "#926e25"
|
|
30
|
+
},
|
|
31
|
+
"colors": {
|
|
32
|
+
"accent": "accent",
|
|
33
|
+
"border": "blue",
|
|
34
|
+
"borderAccent": "accent",
|
|
35
|
+
"borderMuted": "line_nr",
|
|
36
|
+
"success": "green",
|
|
37
|
+
"error": "red",
|
|
38
|
+
"warning": "yellow",
|
|
39
|
+
"muted": "fg3",
|
|
40
|
+
"dim": "line_nr",
|
|
41
|
+
"text": "fg0",
|
|
42
|
+
"thinkingText": "fg2",
|
|
43
|
+
"selectedBg": "sel",
|
|
44
|
+
"scrollbarTrack": "indent",
|
|
45
|
+
"scrollbarThumb": "fg3",
|
|
46
|
+
"searchMatchBg": "match",
|
|
47
|
+
"searchMatchText": "fg0",
|
|
48
|
+
"userMessageBg": "bg3",
|
|
49
|
+
"userMessageText": "fg0",
|
|
50
|
+
"customMessageBg": "bg2",
|
|
51
|
+
"customMessageText": "fg1",
|
|
52
|
+
"customMessageLabel": "accent2",
|
|
53
|
+
"toolPendingBg": "bg2",
|
|
54
|
+
"toolSuccessBg": "diff_add",
|
|
55
|
+
"toolErrorBg": "diff_del",
|
|
56
|
+
"toolTitle": "accent2",
|
|
57
|
+
"toolOutput": "fg1",
|
|
58
|
+
"mdHeading": "accent",
|
|
59
|
+
"mdLink": "blue",
|
|
60
|
+
"mdLinkUrl": "bright_blue",
|
|
61
|
+
"mdCode": "olive",
|
|
62
|
+
"mdCodeBlock": "fg1",
|
|
63
|
+
"mdCodeBlockBorder": "bg5",
|
|
64
|
+
"mdQuote": "fg2",
|
|
65
|
+
"mdQuoteBorder": "bg5",
|
|
66
|
+
"mdHr": "bg5",
|
|
67
|
+
"mdListBullet": "accent2",
|
|
68
|
+
"toolDiffAdded": "gsign_add",
|
|
69
|
+
"toolDiffRemoved": "gsign_del",
|
|
70
|
+
"toolDiffContext": "fg3",
|
|
71
|
+
"syntaxComment": "fg3",
|
|
72
|
+
"syntaxKeyword": "accent2",
|
|
73
|
+
"syntaxFunction": "accent",
|
|
74
|
+
"syntaxVariable": "fg0",
|
|
75
|
+
"syntaxString": "cyan",
|
|
76
|
+
"syntaxNumber": "purple",
|
|
77
|
+
"syntaxType": "blue",
|
|
78
|
+
"syntaxOperator": "fg1",
|
|
79
|
+
"syntaxPunctuation": "fg3",
|
|
80
|
+
"thinkingOff": "line_nr",
|
|
81
|
+
"thinkingMinimal": "fg3",
|
|
82
|
+
"thinkingLow": "blue",
|
|
83
|
+
"thinkingMedium": "cyan",
|
|
84
|
+
"thinkingHigh": "accent2",
|
|
85
|
+
"thinkingXhigh": "accent",
|
|
86
|
+
"thinkingMax": "red",
|
|
87
|
+
"bashMode": "olive"
|
|
88
|
+
}
|
|
89
|
+
}
|