pi-pretty-tui 0.3.3 → 0.3.4

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.
@@ -0,0 +1,103 @@
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
2
+ import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ type Range = [number, number];
5
+ type Row = { marker: string; number?: string; text: string; styled: string; changes: Range[] };
6
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
7
+ const graphemes = (text: string) => Array.from(segmenter.segment(text), part => part.segment);
8
+
9
+ // Bounded grapheme LCS: long/unrelated lines fall back to highlighting their
10
+ // changed middle. Neither path splits emoji, combining marks or surrogate pairs.
11
+ function changedRanges(before: string, after: string): [Range[], Range[]] {
12
+ const a = graphemes(before), b = graphemes(after);
13
+ let start = 0;
14
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
15
+ let endA = a.length, endB = b.length;
16
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
17
+ const removed: Range[] = [], added: Range[] = [];
18
+ const range = (parts: string[], from: number, to: number): Range =>
19
+ [visibleWidth(parts.slice(0, from).join("")), visibleWidth(parts.slice(0, to).join(""))];
20
+ const n = endA - start, m = endB - start;
21
+ if (!n || !m || n * m > 65536) {
22
+ if (n) removed.push(range(a, start, endA));
23
+ if (m) added.push(range(b, start, endB));
24
+ return [removed, added];
25
+ }
26
+ const matrix = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
27
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--)
28
+ matrix[i][j] = a[start + i] === b[start + j] ? matrix[i + 1][j + 1] + 1 : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
29
+ let i = 0, j = 0;
30
+ while (i < n || j < m) {
31
+ if (i < n && j < m && a[start + i] === b[start + j]) { i++; j++; }
32
+ else if (i < n && (j === m || matrix[i + 1][j] >= matrix[i][j + 1])) {
33
+ removed.push(range(a, start + i, start + i + 1));
34
+ i++;
35
+ } else {
36
+ added.push(range(b, start + j, start + j + 1));
37
+ j++;
38
+ }
39
+ }
40
+ const merge = (ranges: Range[]) => ranges.reduce<Range[]>((result, next) => {
41
+ const previous = result[result.length - 1];
42
+ if (previous && previous[1] === next[0]) previous[1] = next[1];
43
+ else result.push(next);
44
+ return result;
45
+ }, []);
46
+ return [merge(removed), merge(added)];
47
+ }
48
+
49
+ export function editDiffLayout(theme: any, diff: string, path?: string) {
50
+ const rows: Row[] = diff.split("\n").map(line => {
51
+ const match = /^([ +\-]) *(\d+) (.*)$/.exec(line);
52
+ const marker = match?.[1] ?? (line.startsWith("+") && !line.startsWith("+++") ? "+" : line.startsWith("-") && !line.startsWith("---") ? "-" : " ");
53
+ const text = (match ? match[3] : /^\s+\.\.\.$/.test(line) ? "..." : line).replace(/\t/g, " ");
54
+ return { marker, number: match?.[2], text, styled: text, changes: [] };
55
+ });
56
+ // Highlight old/new snippets separately so removed code cannot corrupt the
57
+ // syntax state of the new side. Keep all highlighting in the host's theme.
58
+ const language = path ? getLanguageFromPath(path) : undefined;
59
+ for (const excluded of ["+", "-"]) {
60
+ const side = rows.filter(row => row.marker !== excluded);
61
+ let styled: string[];
62
+ try { styled = highlightCode(side.map(row => row.text).join("\n"), language); }
63
+ catch { styled = side.map(row => row.text); }
64
+ side.forEach((row, index) => {
65
+ if (row.marker !== " " || excluded === "+") row.styled = styled[index] ?? row.text;
66
+ });
67
+ }
68
+ for (let index = 0; index < rows.length;) {
69
+ if (rows[index].marker !== "-") { index++; continue; }
70
+ const removed: Row[] = [], added: Row[] = [];
71
+ while (rows[index]?.marker === "-") removed.push(rows[index++]);
72
+ while (rows[index]?.marker === "+") added.push(rows[index++]);
73
+ // Do not guess a line pairing when a replacement changes the line count.
74
+ if (removed.length === added.length) removed.forEach((row, i) => {
75
+ [row.changes, added[i].changes] = changedRanges(row.text, added[i].text);
76
+ });
77
+ }
78
+ const numberWidth = rows.reduce((width, row) => Math.max(width, row.number?.length ?? 0), 1);
79
+ let index = 0;
80
+ return (_line: string) => {
81
+ const row = rows[index++];
82
+ const color = row.marker === "+" ? "toolDiffAdded" : row.marker === "-" ? "toolDiffRemoved" : "toolDiffContext";
83
+ const background = row.marker === "+" ? "toolSuccessBg" : row.marker === "-" ? "toolErrorBg" : undefined;
84
+ const paint = (text: string) => background && theme.bg ? theme.bg(background, text) : text;
85
+ const width = visibleWidth(row.text);
86
+ let content = "", cursor = 0;
87
+ for (const [start, end] of row.changes) {
88
+ content += sliceByColumn(row.styled, cursor, start - cursor);
89
+ const changed = sliceByColumn(row.styled, start, end - start);
90
+ // Underline + bold preserve syntax foreground colors, unlike inverse.
91
+ content += theme.underline ? theme.underline(theme.bold(changed)) : theme.bold(changed);
92
+ cursor = end;
93
+ }
94
+ content += sliceByColumn(row.styled, cursor, Math.max(0, width - cursor));
95
+ const gutter = row.number ? `${row.number.padStart(numberWidth)} ${row.marker} ` : " ".repeat(numberWidth + 3);
96
+ return {
97
+ prefix: paint(theme.fg(color, gutter)),
98
+ continuation: paint(" ".repeat(numberWidth + 3)),
99
+ content,
100
+ background: background ? paint : undefined,
101
+ };
102
+ };
103
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type InlineToken,
9
9
  } from "./markdown-emphasis.js";
10
10
  import { gapRowsAfterBlock, renderSpacedList } from "./markdown-spacing.js";
11
+ import { editDiffLayout } from "./edit-diff.js";
11
12
  import { ThoughtPresentation } from "./thought-presentation.js";
12
13
  import { isThirdPartyTool, renderToolProjection, THIRD_PARTY_TOOL, toolLabel, type ToolProjection } from "./tool-presentation.js";
13
14
  import { NestedTools, NESTED_SNAPSHOT_ENTRY } from "./nested-tools.js";
@@ -196,14 +197,14 @@ export default function prettyTui(pi: ExtensionAPI) {
196
197
  let latestActivityStatusGeneration = 0;
197
198
  const renderLatestActivityStatus = (status: LatestActivityStatus, width: number, theme: any): string => {
198
199
  const severity = status.severity ?? "status";
199
- const icon = severity === "error" ? "✕" : severity === "success" ? "✓" : "◇";
200
+ const icon = severity === "error" ? "✕ " : severity === "success" ? "✓ " : "";
200
201
  const color = severity === "error"
201
202
  ? "error"
202
203
  : severity === "success"
203
204
  ? "success"
204
205
  : "dim";
205
206
  const detail = status.detail?.trim() ? ` — ${status.detail.trim()}` : "";
206
- return truncateToWidth(` ${theme.fg(color, `${icon} ${status.title}`)}${theme.fg("dim", detail)}`, Math.max(1, width), "…");
207
+ return truncateToWidth(` ${theme.fg(color, `${icon}${status.title}`)}${theme.fg("dim", detail)}`, Math.max(1, width), "…");
207
208
  };
208
209
  const refreshLatestActivityWidget = () => {
209
210
  if (!currentExtensionUi?.setWidget) return;
@@ -642,7 +643,7 @@ export default function prettyTui(pi: ExtensionAPI) {
642
643
  );
643
644
  };
644
645
 
645
- const renderStandaloneError = (member: ActivityMember, width: number, component?: any): string[] => {
646
+ const renderStandaloneAlert = (member: ActivityMember, width: number, component?: any): string[] => {
646
647
  const run = component && renderMode !== "full" ? errorRun(component) : { hidden: false, count: 1, compact: false };
647
648
  if (component) errorDisclosureRows.delete(component);
648
649
  if (run.hidden) return [];
@@ -656,7 +657,9 @@ export default function prettyTui(pi: ExtensionAPI) {
656
657
  const prefix = heading + (content ? theme.fg("dim", " · ") : "");
657
658
  const text = prefix + theme.fg("muted", content) + (count > 1 ? theme.fg("dim", component && expandedErrorRuns.has(component) ? " [Collapse]" : ` [×${count}]`) : "");
658
659
  // Wrap rather than truncate; all original detail lines remain accessible.
659
- const continuation = " ".repeat(Math.min(visibleWidth(prefix), Math.max(0, Math.floor(width / 3))));
660
+ // A single-line notification can be entirely its title. Indenting by
661
+ // that title's length wastes most of the viewport on every continuation.
662
+ const continuation = " ".repeat(Math.min(2, Math.max(0, width - 1)));
660
663
  const rows: string[] = [];
661
664
  for (const line of text.split("\n")) {
662
665
  const wrapped = wrapTextWithAnsi(line, Math.max(1, width - visibleWidth(continuation)));
@@ -686,8 +689,8 @@ export default function prettyTui(pi: ExtensionAPI) {
686
689
  };
687
690
 
688
691
  const renderPendingActivityUpdate = (member: ActivityMember, width: number, component?: any): string[] => {
689
- if (member.updateSeverity === "error" && member.updateTitle !== "Response truncated") {
690
- return renderStandaloneError(member, width, component);
692
+ if (member.updateSeverity === "error" || member.updateSeverity === "warning") {
693
+ return renderStandaloneAlert(member, width, component);
691
694
  }
692
695
  const theme = defaultActivityTheme();
693
696
  const title = truncateToWidth(member.updateTitle ?? "Update", Math.max(1, width - 2), "…");
@@ -2797,6 +2800,8 @@ export default function prettyTui(pi: ExtensionAPI) {
2797
2800
  prefix: DisplayValue;
2798
2801
  continuation?: DisplayValue;
2799
2802
  content: DisplayValue;
2803
+ singleLine?: boolean;
2804
+ background?: (text: string) => string;
2800
2805
  };
2801
2806
 
2802
2807
  /**
@@ -2810,14 +2815,21 @@ export default function prettyTui(pi: ExtensionAPI) {
2810
2815
  for (const row of rows) {
2811
2816
  const prefix = typeof row.prefix === "function" ? row.prefix() : row.prefix;
2812
2817
  const content = typeof row.content === "function" ? row.content() : row.content;
2818
+ if (row.singleLine) {
2819
+ const inline = content.replace(/\r\n|\r|\n/g, " ");
2820
+ rendered.push(truncateToWidth(prefix + inline, Math.max(1, width), "…"));
2821
+ continue;
2822
+ }
2813
2823
  const rawContinuation = row.continuation ?? " ".repeat(visibleWidth(prefix));
2814
2824
  const continuation = typeof rawContinuation === "function" ? rawContinuation() : rawContinuation;
2815
2825
  const prefixWidth = Math.max(visibleWidth(prefix), visibleWidth(continuation));
2816
2826
  const contentWidth = Math.max(1, width - prefixWidth);
2817
2827
  const wrapped = wrapTextWithAnsi(content || " ", contentWidth);
2818
2828
 
2819
- rendered.push(prefix + (wrapped[0] ?? ""));
2820
- for (const line of wrapped.slice(1)) rendered.push(continuation + line);
2829
+ const paint = (line: string) => row.background
2830
+ ? row.background(line + " ".repeat(Math.max(0, contentWidth - visibleWidth(line)))) : line;
2831
+ rendered.push(prefix + paint(wrapped[0] ?? ""));
2832
+ for (const line of wrapped.slice(1)) rendered.push(continuation + paint(line));
2821
2833
  }
2822
2834
 
2823
2835
  return rendered;
@@ -2830,7 +2842,8 @@ export default function prettyTui(pi: ExtensionAPI) {
2830
2842
  context.state.compactToolStatus = status;
2831
2843
  };
2832
2844
 
2833
- const callRow = (theme: any, name: string, detail: string, state: any): DisplayRow => ({
2845
+ const callRow = (theme: any, name: string, detail: string, state: any, expanded = false): DisplayRow => ({
2846
+ singleLine: renderMode === "clean" && !expanded,
2834
2847
  prefix: () => {
2835
2848
  const status = (state.compactToolStatus ?? "running") as ToolStatus;
2836
2849
  const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
@@ -2841,12 +2854,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2841
2854
  content: renderMode === "compact" ? theme.fg("muted", detail) :
2842
2855
  theme.fg("text", theme.bold(name)) +
2843
2856
  theme.fg("dim", "(") +
2844
- theme.fg("muted", detail) +
2857
+ theme.fg(renderMode === "clean" && expanded ? "text" : "muted", detail) +
2845
2858
  theme.fg("dim", ")"),
2846
2859
  });
2847
2860
 
2848
- const call = (theme: any, name: string, detail: string, state: any) =>
2849
- block([callRow(theme, name, detail, state)]);
2861
+ const call = (theme: any, name: string, detail: string, state: any, expanded = false) =>
2862
+ block([callRow(theme, name, detail, state, expanded)]);
2850
2863
 
2851
2864
  const writeCall = (theme: any, name: string, path: string, content: string, expanded: boolean, state: any) => {
2852
2865
  const lines = content.replace(/\t/g, " ").split("\n");
@@ -2856,7 +2869,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2856
2869
  const shown = lines.slice(0, expanded ? total : 10);
2857
2870
  const remaining = total - shown.length;
2858
2871
  const rows: DisplayRow[] = [
2859
- callRow(theme, name, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, state),
2872
+ callRow(theme, name, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, state, expanded),
2860
2873
  ];
2861
2874
 
2862
2875
  for (let index = 0; index < shown.length; index++) {
@@ -2879,28 +2892,6 @@ export default function prettyTui(pi: ExtensionAPI) {
2879
2892
  return block(rows);
2880
2893
  };
2881
2894
 
2882
- // Pi diffs encode the marker before the padded line number. Move it into
2883
- // its own column and keep the whole gutter out of content word wrapping.
2884
- const editDiffLayout = (theme: any, diff: string): ((line: string) => DisplayRow) => {
2885
- const parse = (line: string) => /^([ +\-]) *(\d+) (.*)$/.exec(line);
2886
- const numberWidth = diff.split("\n").reduce((width, line) => Math.max(width, parse(line)?.[2].length ?? 0), 1);
2887
- const gutterWidth = numberWidth + 3;
2888
- return (line) => {
2889
- const match = parse(line);
2890
- const marker = match?.[1] ?? (
2891
- line.startsWith("+") && !line.startsWith("+++") ? "+" :
2892
- line.startsWith("-") && !line.startsWith("---") ? "-" : " "
2893
- );
2894
- const color = marker === "+" ? "toolDiffAdded" : marker === "-" ? "toolDiffRemoved" : "toolDiffContext";
2895
- const gutter = match ? `${match[2].padStart(numberWidth)} ${marker} ` : " ".repeat(gutterWidth);
2896
- return {
2897
- prefix: theme.fg(color, gutter),
2898
- continuation: " ".repeat(gutterWidth),
2899
- content: theme.fg(color, match ? match[3] : /^\s+\.\.\.$/.test(line) ? "..." : line),
2900
- };
2901
- };
2902
- };
2903
-
2904
2895
  const result = (
2905
2896
  theme: any,
2906
2897
  summary: string,
@@ -2929,6 +2920,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2929
2920
  prefix: theme.fg("dim", isLast ? " └ " : " │ ") + (layout?.prefix ?? ""),
2930
2921
  continuation: (isLast ? " " : theme.fg("dim", " │ ")) + (layout?.continuation ?? ""),
2931
2922
  content: layout?.content ?? outputStyle(shown[index] || " "),
2923
+ background: layout?.background,
2932
2924
  });
2933
2925
  }
2934
2926
  if (omitted > 0) {
@@ -4536,7 +4528,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4536
4528
  renderCall(args: any, theme: any, context: any) {
4537
4529
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, readLabel, context.toolCallId);
4538
4530
  const range = readLineRange(args.offset, args.limit);
4539
- return call(theme, readLabel, `${args.path}${range}`, context.state);
4531
+ return call(theme, readLabel, `${args.path}${range}`, context.state, context.expanded);
4540
4532
  },
4541
4533
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4542
4534
  const output = textContent(toolResult);
@@ -4572,9 +4564,9 @@ export default function prettyTui(pi: ExtensionAPI) {
4572
4564
  const detail = omitted > 0
4573
4565
  ? `${firstLine} … (${omitted} more ${omitted === 1 ? "line" : "lines"})`
4574
4566
  : firstLine;
4575
- return call(theme, bashLabel, detail, context.state);
4567
+ return call(theme, bashLabel, detail, context.state, context.expanded);
4576
4568
  }
4577
- return call(theme, bashLabel, command, context.state);
4569
+ return call(theme, bashLabel, command, context.state, context.expanded);
4578
4570
  },
4579
4571
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4580
4572
  const output = textContent(toolResult);
@@ -4616,7 +4608,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4616
4608
  renderCall(args: any, theme: any, context: any) {
4617
4609
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, editLabel, context.toolCallId);
4618
4610
  const count = Array.isArray(args.edits) ? ` · ${args.edits.length} changes` : "";
4619
- return call(theme, editLabel, `${args.path}${count}`, context.state);
4611
+ return call(theme, editLabel, `${args.path}${count}`, context.state, context.expanded);
4620
4612
  },
4621
4613
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4622
4614
  const output = textContent(toolResult);
@@ -4646,7 +4638,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4646
4638
  if (line.startsWith("-") && !line.startsWith("---")) return theme.fg("toolDiffRemoved", line);
4647
4639
  return theme.fg("toolDiffContext", line);
4648
4640
  },
4649
- !failed && diff ? editDiffLayout(theme, diff) : undefined,
4641
+ !failed && diff ? editDiffLayout(theme, diff, context.args?.path) : undefined,
4650
4642
  );
4651
4643
  },
4652
4644
  } as any);
@@ -4664,7 +4656,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4664
4656
  const lines = content.replace(/\t/g, " ").split("\n");
4665
4657
  while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
4666
4658
  const total = lines.length;
4667
- return call(theme, writeLabel, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, context.state);
4659
+ return call(theme, writeLabel, `${path} · ${total} ${total === 1 ? "line" : "lines"}`, context.state, context.expanded);
4668
4660
  }
4669
4661
  return writeCall(theme, writeLabel, path, content, context.expanded, context.state);
4670
4662
  },
@@ -4692,7 +4684,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4692
4684
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, grepLabel, context.toolCallId);
4693
4685
  const where = args.path ? ` · ${args.path}` : "";
4694
4686
  const glob = args.glob ? ` · ${args.glob}` : "";
4695
- return call(theme, grepLabel, `${args.pattern}${where}${glob}`, context.state);
4687
+ return call(theme, grepLabel, `${args.pattern}${where}${glob}`, context.state, context.expanded);
4696
4688
  },
4697
4689
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4698
4690
  const output = textContent(toolResult);
@@ -4711,7 +4703,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4711
4703
  renderShell: "self",
4712
4704
  renderCall(args: any, theme: any, context: any) {
4713
4705
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, findLabel, context.toolCallId);
4714
- return call(theme, findLabel, `${args.pattern}${args.path ? ` · ${args.path}` : ""}`, context.state);
4706
+ return call(theme, findLabel, `${args.pattern}${args.path ? ` · ${args.path}` : ""}`, context.state, context.expanded);
4715
4707
  },
4716
4708
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4717
4709
  const output = textContent(toolResult);
@@ -4730,7 +4722,7 @@ export default function prettyTui(pi: ExtensionAPI) {
4730
4722
  renderShell: "self",
4731
4723
  renderCall(args: any, theme: any, context: any) {
4732
4724
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, lsLabel, context.toolCallId);
4733
- return call(theme, lsLabel, args.path ?? ".", context.state);
4725
+ return call(theme, lsLabel, args.path ?? ".", context.state, context.expanded);
4734
4726
  },
4735
4727
  renderResult(toolResult: any, options: any, theme: any, context: any) {
4736
4728
  const output = textContent(toolResult);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "A beautiful, polished TUI for the Pi coding agent",
5
5
  "license": "MIT",
6
6
  "author": "Kainan Yang <ykn0309@whu.edu.cn>",