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 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.288 in a live terminal.
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.160.0 in a live terminal.
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
- /** Codex's key is ctrl+t; in Pi the key that opens a row is ctrl+o. */
24
- export const CODEX_EXPAND_KEY = "ctrl+o";
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 = ` (${CODEX_EXPAND_KEY} to expand)`;
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", `${number} `);
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, `${number} ${sign} `);
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 padded = body + " ".repeat(Math.max(0, bodyWidth - visibleWidth(body)));
506
- output.push(prefix + fillRgb(theme, tint, padded));
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.14.2",
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",
@@ -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"