pi-diff-widget 0.0.0-stage → 0.1.2

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/src/view.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { DiffFile } from './git.ts';
2
+ import type { Turn } from './turns.ts';
3
+
4
+ export interface DiffSection {
5
+ title: string;
6
+ detail?: string;
7
+ /** Raw display diff in pi's line-numbered format. Rendered with `renderDiff`. */
8
+ diff?: string;
9
+ note?: string;
10
+ }
11
+ // TEST LINE FOR DIFF
12
+ export interface TurnView {
13
+ label: string;
14
+ sections: DiffSection[];
15
+ }
16
+
17
+ export type ViewMode = 'uncommitted' | 'turns';
18
+
19
+ /** First non-empty line of a prompt, trimmed and capped for a header label. */
20
+ export function summarizePrompt(prompt: string): string {
21
+ const line =
22
+ prompt.split('\n').find((part) => part.trim().length > 0) ?? '';
23
+ const trimmed = line.trim();
24
+ return trimmed.length > 64 ? `${trimmed.slice(0, 61)}...` : trimmed;
25
+ }
26
+
27
+ const NOTE_BY_STATUS: Partial<Record<DiffFile['status'], string>> = {
28
+ binary: 'binary file, no text diff',
29
+ 'too-large': 'file too large to diff',
30
+ unreadable: 'file could not be read',
31
+ };
32
+
33
+ function fileSection(file: DiffFile): DiffSection {
34
+ return {
35
+ title: file.path,
36
+ detail: file.status,
37
+ diff: file.diff || undefined,
38
+ note: NOTE_BY_STATUS[file.status],
39
+ };
40
+ }
41
+
42
+ export function toUncommittedSections(files: DiffFile[]): DiffSection[] {
43
+ return files.map((file) => fileSection(file));
44
+ }
45
+
46
+ export interface FileEditSummary {
47
+ path: string;
48
+ added: number;
49
+ removed: number;
50
+ }
51
+
52
+ export interface EditSummary {
53
+ fileCount: number;
54
+ added: number;
55
+ removed: number;
56
+ files: FileEditSummary[];
57
+ }
58
+
59
+ /** Count `+` and `-` lines in a raw display diff. `...` separators are ignored. */
60
+ export function countDiffLines(diff: string): {
61
+ added: number;
62
+ removed: number;
63
+ } {
64
+ let added = 0;
65
+ let removed = 0;
66
+ for (const line of diff.split('\n')) {
67
+ if (line.startsWith('+')) added += 1;
68
+ else if (line.startsWith('-')) removed += 1;
69
+ }
70
+ return { added, removed };
71
+ }
72
+
73
+ /**
74
+ * Aggregate raw diffs per file. Counts are net when each file appears once,
75
+ * which is how the baseline widget feeds it.
76
+ */
77
+ export function summarizeDiffs(
78
+ diffs: Array<{ path: string; diff: string }>,
79
+ ): EditSummary {
80
+ const byPath = new Map<string, FileEditSummary>();
81
+
82
+ for (const item of diffs) {
83
+ const counts = countDiffLines(item.diff);
84
+ const existing = byPath.get(item.path);
85
+ if (existing) {
86
+ existing.added += counts.added;
87
+ existing.removed += counts.removed;
88
+ } else {
89
+ byPath.set(item.path, {
90
+ path: item.path,
91
+ added: counts.added,
92
+ removed: counts.removed,
93
+ });
94
+ }
95
+ }
96
+
97
+ const files = [...byPath.values()].sort((a, b) =>
98
+ a.path.localeCompare(b.path),
99
+ );
100
+ return {
101
+ fileCount: files.length,
102
+ added: files.reduce((sum, file) => sum + file.added, 0),
103
+ removed: files.reduce((sum, file) => sum + file.removed, 0),
104
+ files,
105
+ };
106
+ }
107
+
108
+ export function toTurnViews(turns: Turn[]): TurnView[] {
109
+ return turns
110
+ .filter((turn) => turn.edits.length > 0)
111
+ .map((turn, position) => ({
112
+ label: summarizePrompt(turn.prompt) || `prompt ${position + 1}`,
113
+ sections: turn.edits.map((edit) => ({
114
+ title: edit.path,
115
+ diff: edit.diff,
116
+ })),
117
+ }));
118
+ }
package/src/widget.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { truncateToWidth } from '@earendil-works/pi-tui';
2
+ import type { Component, TUI } from '@earendil-works/pi-tui';
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ } from '@earendil-works/pi-coding-agent';
7
+ import type { Theme } from '@earendil-works/pi-coding-agent';
8
+ import type { BaselineTracker } from './baseline.ts';
9
+ import type { DocumentTheme } from './document.ts';
10
+ import type { ExecFn } from './exec.ts';
11
+ import {
12
+ collectRevertTargets,
13
+ computeNetSummary,
14
+ pruneCleanBaselines,
15
+ revertTargets,
16
+ } from './summary.ts';
17
+ import type { EditSummary } from './view.ts';
18
+
19
+ export const WIDGET_KEY = 'pi-diff';
20
+ const MAX_FILES = 8;
21
+ const MAX_PATH_WIDTH = 48;
22
+
23
+ function formatCounts(
24
+ counts: { added: number; removed: number },
25
+ theme: DocumentTheme,
26
+ ): string {
27
+ const added = theme.fg('toolDiffAdded', `+${counts.added}`);
28
+ const removed = theme.fg('toolDiffRemoved', `-${counts.removed}`);
29
+ return `${added} ${removed}`;
30
+ }
31
+
32
+ /** Zed-style summary: a header with totals, then one line per changed file. */
33
+ export function buildEditSummaryLines(
34
+ summary: EditSummary,
35
+ theme: DocumentTheme,
36
+ maxFiles = MAX_FILES,
37
+ ): string[] {
38
+ const label =
39
+ summary.fileCount === 1 ? '1 file' : `${summary.fileCount} files`;
40
+ const lines = [
41
+ `${theme.fg('accent', theme.bold('edits'))} ${theme.fg('dim', `· ${label} · `)}${formatCounts(summary, theme)}`,
42
+ ];
43
+
44
+ const shown = summary.files.slice(0, maxFiles);
45
+ for (const file of shown) {
46
+ const path = truncateToWidth(file.path, MAX_PATH_WIDTH, '…');
47
+ lines.push(`${path} ${formatCounts(file, theme)}`);
48
+ }
49
+ if (summary.files.length > shown.length) {
50
+ const rest = summary.files.length - shown.length;
51
+ lines.push(theme.fg('dim', `… ${rest} more`));
52
+ }
53
+
54
+ return lines;
55
+ }
56
+
57
+ class EditSummaryWidget implements Component {
58
+ private readonly lines: string[];
59
+
60
+ constructor(lines: string[]) {
61
+ this.lines = lines;
62
+ }
63
+
64
+ render(width: number): string[] {
65
+ return this.lines.map((line) => truncateToWidth(line, width));
66
+ }
67
+
68
+ invalidate(): void {}
69
+ }
70
+
71
+ /**
72
+ * Show net agent edits above the editor, refreshed at turn boundaries. Hidden
73
+ * when nothing differs from the session baselines, or everything is committed.
74
+ */
75
+ export function registerDiffWidget(
76
+ pi: ExtensionAPI,
77
+ baselines: BaselineTracker,
78
+ exec: ExecFn,
79
+ ): void {
80
+ let visible = true;
81
+
82
+ const update = async (ctx: ExtensionContext) => {
83
+ if (!ctx.hasUI) return;
84
+ await pruneCleanBaselines(baselines, exec, ctx.cwd);
85
+ if (!visible) {
86
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
87
+ return;
88
+ }
89
+
90
+ const summary = await computeNetSummary(baselines, ctx.cwd);
91
+ if (summary.fileCount === 0) {
92
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
93
+ return;
94
+ }
95
+
96
+ ctx.ui.setWidget(
97
+ WIDGET_KEY,
98
+ (_tui: TUI, theme: Theme) =>
99
+ new EditSummaryWidget(buildEditSummaryLines(summary, theme)),
100
+ );
101
+ };
102
+
103
+ pi.on('session_start', (_event, ctx) => void update(ctx));
104
+ pi.on('turn_end', (_event, ctx) => void update(ctx));
105
+ pi.on('agent_settled', (_event, ctx) => void update(ctx));
106
+ pi.on('session_shutdown', (_event, ctx) => {
107
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
108
+ });
109
+
110
+ pi.registerCommand('diff-widget', {
111
+ description:
112
+ 'Live edits widget: on, off, accept (keep), or reject (undo)',
113
+ handler: async (args, ctx) => {
114
+ const action = args.trim().toLowerCase();
115
+
116
+ if (action === 'reject') {
117
+ await ctx.waitForIdle();
118
+ const targets = await collectRevertTargets(baselines, ctx.cwd);
119
+ if (targets.length === 0) {
120
+ ctx.ui.notify('Nothing to reject', 'info');
121
+ return;
122
+ }
123
+ const names = targets
124
+ .slice(0, 5)
125
+ .map((target) => target.relativePath)
126
+ .join(', ');
127
+ const rest =
128
+ targets.length > 5 ? ` and ${targets.length - 5} more` : '';
129
+ const confirmed = await ctx.ui.confirm(
130
+ 'Reject agent edits?',
131
+ `Restore ${targets.length} file(s) to their pre-agent content: ${names}${rest}. This cannot be undone.`,
132
+ );
133
+ if (!confirmed) return;
134
+
135
+ await revertTargets(targets);
136
+ await update(ctx);
137
+ ctx.ui.notify(
138
+ `Rejected edits to ${targets.length} file(s)`,
139
+ 'info',
140
+ );
141
+ return;
142
+ }
143
+
144
+ if (action === 'accept' || action === 'keep') {
145
+ await baselines.rebaseline(pi);
146
+ await update(ctx);
147
+ ctx.ui.notify('Edits accepted', 'info');
148
+ return;
149
+ }
150
+
151
+ if (action === 'on') visible = true;
152
+ else if (action === 'off') visible = false;
153
+ else if (action === '') visible = !visible;
154
+ else {
155
+ ctx.ui.notify(
156
+ `Unknown action "${action}". Use on, off, accept, or reject.`,
157
+ 'warning',
158
+ );
159
+ return;
160
+ }
161
+
162
+ await update(ctx);
163
+ ctx.ui.notify(`Edits widget ${visible ? 'on' : 'off'}`, 'info');
164
+ },
165
+ });
166
+ }