pi-compact-tools 0.14.2 → 0.15.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 +2 -2
- package/extensions/compact-tools-codex.ts +10 -3
- package/extensions/compact-tools-layout.ts +131 -5
- package/package.json +1 -1
- package/release-notes.json +9 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools o
|
|
|
30
30
|
|
|
31
31
|
- Reads, searches, and listings fold into one line: `Searched for 2 patterns, read 3 files`.
|
|
32
32
|
- `Bash`, `Update`, `Write`, and custom tools keep their own rows with `⎿` results and short previews.
|
|
33
|
-
- Matched against Claude Code 2.1.
|
|
33
|
+
- Matched against Claude Code 2.1.296 in a live terminal.
|
|
34
34
|
|
|
35
35
|
### Codex
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ Silent mode (`Ctrl+'` or `/silent`) works on top of any style. `/compact-tools o
|
|
|
39
39
|
- Reads, searches, and listings fold into `• Explored`: `Read a.ts, b.ts`, `Search TODO in src`.
|
|
40
40
|
- Commands read `Ran` or `Failed (exit 2)` with their last three output lines; opened, `$ cmd`, all its output, and `✓ • 1.23s`.
|
|
41
41
|
- Edits preview their changed lines; custom tools read `• Called name`.
|
|
42
|
-
- Matched against Codex CLI 0.
|
|
42
|
+
- Matched against Codex CLI 0.162.1 in a live terminal.
|
|
43
43
|
|
|
44
44
|
### Compact
|
|
45
45
|
|
|
@@ -20,8 +20,15 @@ export const CODEX_MARGIN = " ";
|
|
|
20
20
|
export const CODEX_BULLET = "•";
|
|
21
21
|
/** Codex draws a tab as four columns. */
|
|
22
22
|
export const CODEX_TAB = " ";
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Codex's key is ctrl+t; in Pi the key that opens a row is ctrl+o. Codex 0.161 spells
|
|
25
|
+
* a modifier by platform (`⌃t` on macOS, `^t` on Linux, `ctrl+t` elsewhere) and so does
|
|
26
|
+
* this. PI_COMPACT_TOOLS_PLATFORM stands in for the platform, so the suite draws the
|
|
27
|
+
* same rows wherever it runs.
|
|
28
|
+
*/
|
|
29
|
+
export function codexExpandKey(platform: string = process.env.PI_COMPACT_TOOLS_PLATFORM ?? process.platform): string {
|
|
30
|
+
return platform === "darwin" ? "⌃o" : platform === "linux" ? "^o" : "ctrl+o";
|
|
31
|
+
}
|
|
25
32
|
|
|
26
33
|
/** How Codex prints how long a call took: `250ms`, `1.50s`, `1m 15s`. */
|
|
27
34
|
export function formatCodexDuration(milliseconds: number): string {
|
|
@@ -41,7 +48,7 @@ export function codexDisclosure(width: number, expanded: boolean, hiddenLines?:
|
|
|
41
48
|
if (expanded) return `${indent}− Show less`;
|
|
42
49
|
if (hiddenLines === undefined) return `${indent}+ Show details`;
|
|
43
50
|
const label = `+ ${hiddenLines} ${hiddenLines === 1 ? "line" : "lines"}`;
|
|
44
|
-
const hint = ` (${
|
|
51
|
+
const hint = ` (${codexExpandKey()} to expand)`;
|
|
45
52
|
return visibleWidth(indent + label + hint) <= width ? indent + label + hint : indent + label;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
visibleWidth,
|
|
9
9
|
wrapTextWithAnsi,
|
|
10
10
|
} from "@earendil-works/pi-tui";
|
|
11
|
-
import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
|
|
11
|
+
import { diffTintRgb, fillRgb, type Rgb } from "./compact-tools-color.ts";
|
|
12
12
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
13
13
|
import { resolveLanguage } from "./compact-tools-language.ts";
|
|
14
14
|
import { highlightMarkdown } from "./compact-tools-markdown.ts";
|
|
@@ -447,10 +447,123 @@ function codeGutter(
|
|
|
447
447
|
theme: Theme,
|
|
448
448
|
): string {
|
|
449
449
|
const number = (lineNumber === undefined ? "" : String(lineNumber)).padStart(numberWidth);
|
|
450
|
-
if (!signs) return theme.fg("toolDiffContext",
|
|
450
|
+
if (!signs) return theme.fg("toolDiffContext", ` ${number} `);
|
|
451
451
|
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
|
452
452
|
const color = kind === "add" ? "toolDiffAdded" : kind === "remove" ? "toolDiffRemoved" : "toolDiffContext";
|
|
453
|
-
return theme.fg(color,
|
|
453
|
+
return theme.fg(color, ` ${number} ${sign}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
type CharRange = [start: number, end: number];
|
|
457
|
+
|
|
458
|
+
/** Most token pairs a word diff compares; a longer pair of lines is left untinted instead of taking quadratic time. */
|
|
459
|
+
const WORD_DIFF_MAX_CELLS = 40_000;
|
|
460
|
+
/** How alike two lines must be, by shared characters, before their changed words are marked. */
|
|
461
|
+
const WORD_DIFF_MIN_SIMILARITY = 0.6;
|
|
462
|
+
/** How much of the theme color a changed word is tinted with, against the row's own tint. */
|
|
463
|
+
const WORD_TINT = 0.32;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The changed words of a removed row and the added row that replaces it, as Claude
|
|
467
|
+
* Code marks them: a brighter tint behind each word the pair does not share. Two
|
|
468
|
+
* rows with little in common are rewrites rather than edits and mark nothing.
|
|
469
|
+
*/
|
|
470
|
+
function wordDiffRanges(removed: string, added: string): { removed: CharRange[]; added: CharRange[] } | undefined {
|
|
471
|
+
const split = (text: string) => text.match(/[\p{L}\p{N}_]+|\s+|[^\s\p{L}\p{N}_]/gu) ?? [];
|
|
472
|
+
const before = split(removed);
|
|
473
|
+
const after = split(added);
|
|
474
|
+
if (before.length * after.length > WORD_DIFF_MAX_CELLS) return undefined;
|
|
475
|
+
const width = after.length + 1;
|
|
476
|
+
const lengths = new Uint16Array((before.length + 1) * width);
|
|
477
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
478
|
+
for (let j = after.length - 1; j >= 0; j--) {
|
|
479
|
+
lengths[i * width + j] = before[i] === after[j]
|
|
480
|
+
? lengths[(i + 1) * width + j + 1]! + 1
|
|
481
|
+
: Math.max(lengths[(i + 1) * width + j]!, lengths[i * width + j + 1]!);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const removedChanged = new Array<boolean>(before.length).fill(true);
|
|
485
|
+
const addedChanged = new Array<boolean>(after.length).fill(true);
|
|
486
|
+
let shared = 0;
|
|
487
|
+
for (let i = 0, j = 0; i < before.length && j < after.length;) {
|
|
488
|
+
if (before[i] === after[j]) {
|
|
489
|
+
removedChanged[i] = addedChanged[j] = false;
|
|
490
|
+
shared += before[i]!.length;
|
|
491
|
+
i++;
|
|
492
|
+
j++;
|
|
493
|
+
} else if (lengths[(i + 1) * width + j]! >= lengths[i * width + j + 1]!) i++;
|
|
494
|
+
else j++;
|
|
495
|
+
}
|
|
496
|
+
if (shared === 0 || (2 * shared) / (removed.length + added.length) < WORD_DIFF_MIN_SIMILARITY) return undefined;
|
|
497
|
+
const ranges = (text: string, tokens: string[], changed: boolean[]): CharRange[] => {
|
|
498
|
+
const output: CharRange[] = [];
|
|
499
|
+
let offset = 0;
|
|
500
|
+
tokens.forEach((token, index) => {
|
|
501
|
+
const last = output.at(-1);
|
|
502
|
+
if (changed[index]) {
|
|
503
|
+
if (last && last[1] === offset) last[1] = offset + token.length;
|
|
504
|
+
else output.push([offset, offset + token.length]);
|
|
505
|
+
}
|
|
506
|
+
offset += token.length;
|
|
507
|
+
});
|
|
508
|
+
// A range starts and ends on a word, not on the blanks beside it.
|
|
509
|
+
return output
|
|
510
|
+
.map(([start, end]): CharRange => [
|
|
511
|
+
end - text.slice(start, end).trimStart().length,
|
|
512
|
+
start + text.slice(start, end).trimEnd().length,
|
|
513
|
+
])
|
|
514
|
+
.filter(([start, end]) => end > start);
|
|
515
|
+
};
|
|
516
|
+
const markedRemoved = ranges(removed, before, removedChanged);
|
|
517
|
+
const markedAdded = ranges(added, after, addedChanged);
|
|
518
|
+
return markedRemoved.length || markedAdded.length ? { removed: markedRemoved, added: markedAdded } : undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Changed-word ranges for every removed row paired, in order, with the added row beside it. */
|
|
522
|
+
function pairWordRanges(lines: readonly CodeDiffLine[]): Map<number, CharRange[]> {
|
|
523
|
+
const marked = new Map<number, CharRange[]>();
|
|
524
|
+
let index = 0;
|
|
525
|
+
while (index < lines.length) {
|
|
526
|
+
if (lines[index]!.kind !== "remove") {
|
|
527
|
+
index++;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const removedStart = index;
|
|
531
|
+
while (index < lines.length && lines[index]!.kind === "remove") index++;
|
|
532
|
+
const addedStart = index;
|
|
533
|
+
while (index < lines.length && lines[index]!.kind === "add") index++;
|
|
534
|
+
for (let offset = 0; offset < Math.min(addedStart - removedStart, index - addedStart); offset++) {
|
|
535
|
+
const pair = wordDiffRanges(lines[removedStart + offset]!.content, lines[addedStart + offset]!.content);
|
|
536
|
+
if (!pair) continue;
|
|
537
|
+
if (pair.removed.length) marked.set(removedStart + offset, pair.removed);
|
|
538
|
+
if (pair.added.length) marked.set(addedStart + offset, pair.added);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return marked;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A row's text with a brighter fill behind the given character ranges and `base` behind the rest. */
|
|
545
|
+
function fillWithWords(
|
|
546
|
+
theme: Theme,
|
|
547
|
+
base: Rgb,
|
|
548
|
+
word: Rgb,
|
|
549
|
+
content: string,
|
|
550
|
+
ranges: readonly CharRange[],
|
|
551
|
+
chunk: string,
|
|
552
|
+
chunkStart: number,
|
|
553
|
+
padding: number,
|
|
554
|
+
): string {
|
|
555
|
+
const chunkWidth = visibleWidth(chunk);
|
|
556
|
+
let output = "";
|
|
557
|
+
let cursor = 0;
|
|
558
|
+
for (const [start, end] of ranges) {
|
|
559
|
+
const from = Math.max(cursor, visibleWidth(content.slice(0, start)) - chunkStart);
|
|
560
|
+
const to = Math.min(chunkWidth, visibleWidth(content.slice(0, end)) - chunkStart);
|
|
561
|
+
if (to <= from) continue;
|
|
562
|
+
if (from > cursor) output += fillRgb(theme, base, sliceByColumn(chunk, cursor, from - cursor, true));
|
|
563
|
+
output += fillRgb(theme, word, sliceByColumn(chunk, from, to - from, true));
|
|
564
|
+
cursor = to;
|
|
565
|
+
}
|
|
566
|
+
return output + fillRgb(theme, base, sliceByColumn(chunk, cursor, chunkWidth - cursor, true) + " ".repeat(padding));
|
|
454
567
|
}
|
|
455
568
|
|
|
456
569
|
type CodeRowsOptions = {
|
|
@@ -478,8 +591,12 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
478
591
|
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
479
592
|
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
480
593
|
const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
|
|
594
|
+
const addedWordTint = options.signs ? diffTintRgb(theme, "toolDiffAdded", WORD_TINT) : undefined;
|
|
595
|
+
const removedWordTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved", WORD_TINT) : undefined;
|
|
596
|
+
let words: Map<number, CharRange[]> | undefined;
|
|
481
597
|
return new CachedComponent((width) => {
|
|
482
598
|
highlighted ??= highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
599
|
+
if (options.signs) words ??= pairWordRanges(lines);
|
|
483
600
|
const bodyWidth = Math.max(1, width - prefixWidth);
|
|
484
601
|
const codeWidth = Math.max(1, bodyWidth - gutterWidth);
|
|
485
602
|
const output: string[] = [];
|
|
@@ -489,6 +606,8 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
489
606
|
return;
|
|
490
607
|
}
|
|
491
608
|
const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
|
|
609
|
+
const wordRanges = words?.get(index);
|
|
610
|
+
const wordTint = line.kind === "add" ? addedWordTint : removedWordTint;
|
|
492
611
|
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted![index] ?? line.content, codeWidth).entries()) {
|
|
493
612
|
const gutter = codeGutter(
|
|
494
613
|
line.kind,
|
|
@@ -502,8 +621,15 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
502
621
|
output.push(prefix + body);
|
|
503
622
|
continue;
|
|
504
623
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
624
|
+
const padding = Math.max(0, bodyWidth - visibleWidth(body));
|
|
625
|
+
if (wordRanges && wordTint) {
|
|
626
|
+
output.push(
|
|
627
|
+
prefix + fillRgb(theme, tint, gutter)
|
|
628
|
+
+ fillWithWords(theme, tint, wordTint, line.content, wordRanges, chunk, chunkIndex * codeWidth, padding),
|
|
629
|
+
);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
output.push(prefix + fillRgb(theme, tint, body + " ".repeat(padding)));
|
|
507
633
|
}
|
|
508
634
|
});
|
|
509
635
|
if (options.footer) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Tool rows for Pi in three styles: Claude, Codex, and Compact. Switch with /compact-tools; includes silent mode and a GitHub Dark theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/release-notes.json
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
"0.15.0": {
|
|
3
|
+
"New": [
|
|
4
|
+
"Claude style now marks the changed words in an edit diff, like Claude Code 2.1.296: a removed line and the added line that replaces it get a brighter tint on the words that differ. Lines that are mostly rewritten are left alone",
|
|
5
|
+
"Codex style spells the expand key the way Codex 0.162 does: `⌃o` on macOS, `^o` on Linux, `ctrl+o` elsewhere"
|
|
6
|
+
],
|
|
7
|
+
"Fix": [
|
|
8
|
+
"Line numbers in edit diffs and Write previews moved one column right and lost one trailing space, matching Claude Code 2.1.296. Code stays in the same column"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
2
11
|
"0.14.2": {
|
|
3
12
|
"New": [
|
|
4
13
|
"Supports Pi 1.1 recorded execution durations and output padding when those fields are available"
|