@hheei/pi-ext-ui 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hheei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @hheei/pi-ext-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi UI primitives and a small set of specialized tool renderers.
4
+
5
+ `pi-ext-ui` owns the visual result, not tool execution. Its default resolver only handles tools with an explicit specialized renderer (`edit`, `read`, `write`, `apply_patch`, `eval`, and `codemode`). Other tools remain on Pi's native renderer. Extensions can opt into the shared visual API with `createToolView`, `ToolView`, and `DiffView`, or explicitly add a tool through resolver options.
6
+
7
+ The shared view API owns fold levels, compact sections, status glyphs, copy feedback, and width-safe rendering. It does not read files, execute tools, infer outcomes, or import a concrete extension.
8
+
9
+ Background Bash and subagent tools own their display formats through ext-core `ToolPresentation` providers. This resolver consumes those providers automatically, including nested codemode calls, and adds shared folding and copy controls. Without `pi-ext-ui`, the same tools retain native Pi renderers. No direct dependency on `pi-ext-ui` is required by the tool extensions.
@@ -0,0 +1,10 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * Display contract mirror of pi-ext-tools' MUTATION_GLYPH/MUTATION_TONE: how one
4
+ * mutation outcome is drawn. The persisted `ApplyPatchToolDetails` carries the
5
+ * outcome facts; pi-ext-ui never imports pi-ext-tools, so the glyph vocabulary is
6
+ * restated here as part of the rendering contract it consumes.
7
+ */
8
+ export declare const APPLY_PATCH_GLYPH: Record<string, string>;
9
+ export declare const APPLY_PATCH_TONE: Record<string, "success" | "warning" | "dim" | "error">;
10
+ export declare function applyPatchGlyph(status: string, theme: Theme): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Display contract mirror of pi-ext-tools' MUTATION_GLYPH/MUTATION_TONE: how one
3
+ * mutation outcome is drawn. The persisted `ApplyPatchToolDetails` carries the
4
+ * outcome facts; pi-ext-ui never imports pi-ext-tools, so the glyph vocabulary is
5
+ * restated here as part of the rendering contract it consumes.
6
+ */
7
+ export const APPLY_PATCH_GLYPH = {
8
+ pending: "⋯",
9
+ applied: "✓",
10
+ partial: "▲",
11
+ fuzzy: "▲",
12
+ unconfirmed: "▲",
13
+ not_applied: "·",
14
+ rejected: "✗",
15
+ };
16
+ export const APPLY_PATCH_TONE = {
17
+ pending: "dim",
18
+ applied: "success",
19
+ partial: "warning",
20
+ fuzzy: "warning",
21
+ unconfirmed: "warning",
22
+ not_applied: "dim",
23
+ rejected: "error",
24
+ };
25
+ export function applyPatchGlyph(status, theme) {
26
+ const glyph = APPLY_PATCH_GLYPH[status] ?? "✗";
27
+ const tone = APPLY_PATCH_TONE[status] ?? "error";
28
+ return theme.fg(tone, glyph);
29
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Display-only recognition of V4A operation headers in a patch that is still
3
+ * streaming. Mirrors the recognition rules pi-ext-tools' parser applies, but
4
+ * only as far as drawing `󰄰 create|modify|delete path +A -D` preview rows
5
+ * requires: a line is a candidate header, a payload line, or stops the preview.
6
+ */
7
+ export interface V4aPreviewOperationView {
8
+ readonly kind: "add" | "delete" | "update";
9
+ readonly path: string;
10
+ readonly addedLines: number;
11
+ readonly removedLines: number;
12
+ }
13
+ /** Opaque cursor stored in the render state across streamed updates. */
14
+ export interface V4aPreviewCursor {
15
+ consumed: string;
16
+ operations: MutablePreviewOperation[];
17
+ stopped: boolean;
18
+ }
19
+ type MutablePreviewOperation = {
20
+ kind: "add" | "delete" | "update";
21
+ path: string;
22
+ addedLines: number;
23
+ removedLines: number;
24
+ };
25
+ /**
26
+ * Incremental preview of the operation headers completed so far. The cursor
27
+ * lives in the render state so each streamed update only processes new lines.
28
+ */
29
+ export declare function previewV4aOperations(input: string, argsComplete: boolean, cursor: V4aPreviewCursor | undefined): readonly V4aPreviewOperationView[];
30
+ export {};
@@ -0,0 +1,89 @@
1
+ const ADD = "*** Add File: ";
2
+ const DELETE = "*** Delete File: ";
3
+ const UPDATE = "*** Update File: ";
4
+ const MOVE = "*** Move to: ";
5
+ const BEGIN = "*** Begin Patch";
6
+ const END = "*** End Patch";
7
+ const MAX_PREVIEW_OPERATIONS = 128;
8
+ function parseHeader(text) {
9
+ if (text.startsWith(ADD))
10
+ return { kind: "add", path: text.slice(ADD.length) };
11
+ if (text.startsWith(DELETE))
12
+ return { kind: "delete", path: text.slice(DELETE.length) };
13
+ if (text.startsWith(UPDATE))
14
+ return { kind: "update", path: text.slice(UPDATE.length) };
15
+ return undefined;
16
+ }
17
+ function previewablePath(path) {
18
+ return path !== "" && !path.includes("\0") && path !== BEGIN && path !== END;
19
+ }
20
+ function applyPreviewLine(state, text) {
21
+ if (text === BEGIN || text === END)
22
+ return true;
23
+ const header = parseHeader(text);
24
+ if (header !== undefined) {
25
+ if (!previewablePath(header.path) || state.operations.length >= MAX_PREVIEW_OPERATIONS)
26
+ return false;
27
+ state.operations.push({ kind: header.kind, path: header.path, addedLines: 0, removedLines: 0 });
28
+ return true;
29
+ }
30
+ const current = state.operations.at(-1);
31
+ if (current === undefined)
32
+ return true;
33
+ if (current.kind === "update" && text.startsWith(MOVE)) {
34
+ const moveTo = text.slice(MOVE.length);
35
+ if (!previewablePath(moveTo))
36
+ return false;
37
+ current.path = moveTo;
38
+ return true;
39
+ }
40
+ if (current.kind === "add") {
41
+ if (!text.startsWith("+"))
42
+ return false;
43
+ if (text.slice(1).length > 0)
44
+ current.addedLines += 1;
45
+ return true;
46
+ }
47
+ if (current.kind === "delete")
48
+ return false;
49
+ if (text.startsWith("@@") || text.startsWith(" "))
50
+ return true;
51
+ if (text.startsWith("+")) {
52
+ current.addedLines += 1;
53
+ return true;
54
+ }
55
+ if (text.startsWith("-")) {
56
+ current.removedLines += 1;
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * Incremental preview of the operation headers completed so far. The cursor
63
+ * lives in the render state so each streamed update only processes new lines.
64
+ */
65
+ export function previewV4aOperations(input, argsComplete, cursor) {
66
+ const state = cursor ?? { consumed: "", operations: [], stopped: false };
67
+ if (!input.startsWith(state.consumed)) {
68
+ state.consumed = "";
69
+ state.operations = [];
70
+ state.stopped = false;
71
+ }
72
+ if (state.stopped)
73
+ return state.operations.map((operation) => ({ ...operation }));
74
+ const lines = (input.slice(state.consumed.length).match(/[^\n]*\n?/g) ?? []).filter((line) => line !== "");
75
+ for (const [index, raw] of lines.entries()) {
76
+ const newline = raw.endsWith("\n") ? "\n" : "";
77
+ const text = newline === "" ? raw : raw.slice(0, -1).replace(/\r$/, "");
78
+ const complete = newline !== "" || (argsComplete && index === lines.length - 1);
79
+ if (!complete)
80
+ break;
81
+ const applied = applyPreviewLine(state, text);
82
+ if (!applied) {
83
+ state.stopped = true;
84
+ break;
85
+ }
86
+ state.consumed += raw;
87
+ }
88
+ return state.operations.map((operation) => ({ ...operation }));
89
+ }
@@ -0,0 +1,47 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * Display-side view of pi-ext-tools' persisted `ApplyPatchToolDetails`.
4
+ * The details are the contract; every field is read defensively at this
5
+ * untrusted boundary and dropped when it does not match the contract.
6
+ */
7
+ export interface ApplyPatchOperationView {
8
+ readonly operationIndex: number;
9
+ readonly kind: "add" | "delete" | "update";
10
+ readonly path: string;
11
+ readonly addedLines: number;
12
+ readonly removedLines: number;
13
+ readonly status: string;
14
+ readonly appliedHunks: number | undefined;
15
+ readonly totalHunks: number | undefined;
16
+ readonly partialReason: string | undefined;
17
+ }
18
+ export interface ApplyPatchSnapshotView {
19
+ readonly path: string;
20
+ readonly startLine: number;
21
+ readonly before: readonly string[];
22
+ readonly after: readonly string[];
23
+ }
24
+ export interface ApplyPatchRejectionView {
25
+ readonly paths: readonly string[];
26
+ readonly error: string;
27
+ readonly diagnostics: readonly {
28
+ readonly text: string;
29
+ }[];
30
+ }
31
+ export interface ApplyPatchFacts {
32
+ readonly status: "success" | "partial" | "failed";
33
+ readonly operations: readonly ApplyPatchOperationView[];
34
+ readonly snapshots: readonly ApplyPatchSnapshotView[];
35
+ readonly rejected: readonly ApplyPatchRejectionView[];
36
+ readonly unconfirmed: readonly ApplyPatchRejectionView[];
37
+ readonly notApplied: readonly ApplyPatchRejectionView[];
38
+ readonly addedLines: number;
39
+ readonly removedLines: number;
40
+ readonly changedPaths: readonly string[];
41
+ readonly target: string | undefined;
42
+ readonly inProgress: boolean;
43
+ }
44
+ /** Reads only the persisted apply_patch details; other shapes return undefined. */
45
+ export declare function applyPatchFacts(details: unknown): ApplyPatchFacts | undefined;
46
+ /** One operation row: glyph, verb, path, delta, fuzzy marker, hunk summary. */
47
+ export declare function formatApplyPatchOperationRow(operation: ApplyPatchOperationView, theme: Theme, host: string | undefined): string;
@@ -0,0 +1,198 @@
1
+ import { applyPatchGlyph } from "./apply-patch-glyph.js";
2
+ const PATCH_STATUSES = ["success", "partial", "failed"];
3
+ function isPatchStatus(value) {
4
+ return PATCH_STATUSES.includes(value);
5
+ }
6
+ const OPERATION_STATUSES = new Set([
7
+ "pending",
8
+ "applied",
9
+ "partial",
10
+ "fuzzy",
11
+ "unconfirmed",
12
+ "not_applied",
13
+ "rejected",
14
+ ]);
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function operationView(value) {
19
+ const path = typeof value.path === "string" ? value.path : undefined;
20
+ if (path === undefined)
21
+ return undefined;
22
+ const kind = value.kind === "add" || value.kind === "delete" || value.kind === "update"
23
+ ? value.kind
24
+ : undefined;
25
+ if (kind === undefined)
26
+ return undefined;
27
+ const status = typeof value.status === "string" && OPERATION_STATUSES.has(value.status)
28
+ ? value.status
29
+ : undefined;
30
+ if (status === undefined)
31
+ return undefined;
32
+ return {
33
+ operationIndex: typeof value.operationIndex === "number" ? value.operationIndex : 0,
34
+ kind,
35
+ path,
36
+ addedLines: typeof value.addedLines === "number" ? value.addedLines : 0,
37
+ removedLines: typeof value.removedLines === "number" ? value.removedLines : 0,
38
+ status,
39
+ appliedHunks: typeof value.appliedHunks === "number" ? value.appliedHunks : undefined,
40
+ totalHunks: typeof value.totalHunks === "number" ? value.totalHunks : undefined,
41
+ partialReason: typeof value.partialReason === "string" ? value.partialReason : undefined,
42
+ };
43
+ }
44
+ function snapshotView(value) {
45
+ const path = typeof value.path === "string" ? value.path : undefined;
46
+ if (path === undefined)
47
+ return undefined;
48
+ const before = Array.isArray(value.before)
49
+ ? value.before.filter((line) => typeof line === "string")
50
+ : undefined;
51
+ const after = Array.isArray(value.after)
52
+ ? value.after.filter((line) => typeof line === "string")
53
+ : undefined;
54
+ if (before === undefined || after === undefined)
55
+ return undefined;
56
+ return {
57
+ path,
58
+ startLine: typeof value.startLine === "number" && value.startLine > 0 ? value.startLine : 1,
59
+ before,
60
+ after,
61
+ };
62
+ }
63
+ function rejectionView(value) {
64
+ const error = typeof value.error === "string" ? value.error : undefined;
65
+ if (error === undefined)
66
+ return undefined;
67
+ const paths = Array.isArray(value.paths)
68
+ ? value.paths.filter((path) => typeof path === "string")
69
+ : [];
70
+ const diagnostics = Array.isArray(value.diagnostics)
71
+ ? value.diagnostics
72
+ .filter(isRecord)
73
+ .map((diagnostic) => {
74
+ if (diagnostic.kind === "context_not_found") {
75
+ return typeof diagnostic.hint === "string"
76
+ ? `context not found (${diagnostic.hint})`
77
+ : "context not found";
78
+ }
79
+ if (diagnostic.kind === "ambiguous_exact") {
80
+ const candidates = Array.isArray(diagnostic.candidateStartLines)
81
+ ? diagnostic.candidateStartLines.filter((line) => typeof line === "number")
82
+ : [];
83
+ return `exact context is ambiguous at lines ${candidates.join(", ")}`;
84
+ }
85
+ return undefined;
86
+ })
87
+ .filter((text) => text !== undefined)
88
+ .map((text) => ({ text }))
89
+ : [];
90
+ return { paths, error, diagnostics };
91
+ }
92
+ function rejectionList(value) {
93
+ if (!Array.isArray(value))
94
+ return [];
95
+ const views = [];
96
+ for (const entry of value) {
97
+ if (!isRecord(entry))
98
+ continue;
99
+ const view = rejectionView(entry);
100
+ if (view !== undefined)
101
+ views.push(view);
102
+ }
103
+ return views;
104
+ }
105
+ /** Reads only the persisted apply_patch details; other shapes return undefined. */
106
+ export function applyPatchFacts(details) {
107
+ if (!isRecord(details))
108
+ return undefined;
109
+ if (typeof details.status !== "string" || !isPatchStatus(details.status))
110
+ return undefined;
111
+ const status = details.status;
112
+ const operations = Array.isArray(details.operations)
113
+ ? details.operations
114
+ .filter(isRecord)
115
+ .map(operationView)
116
+ .filter((operation) => operation !== undefined)
117
+ : [];
118
+ // Older results carry applied/rejected lists instead of operations; the operation
119
+ // rows are rebuilt from them without hunk counts.
120
+ const applied = Array.isArray(details.applied) ? details.applied.filter(isRecord) : [];
121
+ const rejected = rejectionList(details.rejected);
122
+ if (operations.length === 0) {
123
+ for (const operation of applied) {
124
+ const path = typeof operation.paths === "object" &&
125
+ operation.paths !== null &&
126
+ Array.isArray(operation.paths) &&
127
+ typeof operation.paths[0] === "string"
128
+ ? operation.paths[0]
129
+ : undefined;
130
+ if (path === undefined)
131
+ continue;
132
+ const view = operationView({
133
+ operationIndex: operation.operationIndex,
134
+ kind: operation.kind,
135
+ path,
136
+ addedLines: 0,
137
+ removedLines: 0,
138
+ status: "applied",
139
+ });
140
+ if (view !== undefined)
141
+ operations.push(view);
142
+ }
143
+ for (const [index, rejection] of rejected.entries()) {
144
+ const view = operationView({
145
+ operationIndex: index,
146
+ kind: "update",
147
+ path: rejection.paths[0] ?? "<unknown>",
148
+ addedLines: 0,
149
+ removedLines: 0,
150
+ status: "rejected",
151
+ });
152
+ if (view !== undefined)
153
+ operations.push(view);
154
+ }
155
+ }
156
+ const snapshots = Array.isArray(details.applied)
157
+ ? details.applied.filter(isRecord).flatMap((operation) => Array.isArray(operation.snapshots)
158
+ ? operation.snapshots
159
+ .filter(isRecord)
160
+ .map(snapshotView)
161
+ .filter((snapshot) => snapshot !== undefined)
162
+ : [])
163
+ : [];
164
+ return {
165
+ status,
166
+ operations,
167
+ snapshots,
168
+ rejected,
169
+ unconfirmed: rejectionList(details.unconfirmed),
170
+ notApplied: rejectionList(details.notApplied),
171
+ addedLines: typeof details.addedLines === "number" ? details.addedLines : 0,
172
+ removedLines: typeof details.removedLines === "number" ? details.removedLines : 0,
173
+ changedPaths: Array.isArray(details.changedPaths)
174
+ ? details.changedPaths.filter((path) => typeof path === "string")
175
+ : [],
176
+ target: typeof details.target === "string" && details.target !== "local" ? details.target : undefined,
177
+ inProgress: isRecord(details.progress),
178
+ };
179
+ }
180
+ /** One operation row: glyph, verb, path, delta, fuzzy marker, hunk summary. */
181
+ export function formatApplyPatchOperationRow(operation, theme, host) {
182
+ const glyph = applyPatchGlyph(operation.status, theme);
183
+ const kind = operation.kind === "add" ? "create" : operation.kind === "delete" ? "delete" : "modify";
184
+ const path = host === undefined ? operation.path : `${theme.fg("warning", `${host}:`)}${operation.path}`;
185
+ const deltaParts = [];
186
+ if (operation.addedLines > 0)
187
+ deltaParts.push(theme.fg("success", `+${String(operation.addedLines)}`));
188
+ if (operation.removedLines > 0)
189
+ deltaParts.push(theme.fg("error", `-${String(operation.removedLines)}`));
190
+ const delta = deltaParts.length > 0 ? ` (${deltaParts.join(" ")})` : "";
191
+ const score = operation.status === "fuzzy" ? ` ${theme.fg("dim", "fuzzy")}` : "";
192
+ const hunks = operation.status === "partial" &&
193
+ operation.appliedHunks !== undefined &&
194
+ operation.totalHunks !== undefined
195
+ ? ` ${theme.fg("dim", `(${String(operation.appliedHunks)}/${String(operation.totalHunks)} hunks applied${operation.partialReason === undefined ? "" : `; ${operation.partialReason}`})`)}`
196
+ : "";
197
+ return `${glyph} ${theme.fg("toolTitle", kind)} ${path}${delta}${score}${hunks}`.trimEnd();
198
+ }
@@ -0,0 +1,46 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component } from "@earendil-works/pi-tui";
3
+ /** One displayed diff row. `line` is 1-based: old file for `del`, new file otherwise. */
4
+ export interface DiffLine {
5
+ readonly kind: "add" | "del" | "ctx";
6
+ readonly line: number;
7
+ readonly text: string;
8
+ }
9
+ /** One contiguous change region, in display order. */
10
+ export interface DiffHunk {
11
+ readonly lines: readonly DiffLine[];
12
+ }
13
+ /**
14
+ * Real diff sources, never a partially populated patch/before/after bag:
15
+ * a standard unified patch (what Pi native edit persists as `details.patch`) or
16
+ * hunks a caller already parsed.
17
+ */
18
+ export type DiffSource = {
19
+ readonly kind: "unifiedPatch";
20
+ readonly text: string;
21
+ } | {
22
+ readonly kind: "hunks";
23
+ readonly hunks: readonly DiffHunk[];
24
+ };
25
+ export interface DiffStats {
26
+ readonly added: number;
27
+ readonly removed: number;
28
+ }
29
+ export interface DiffViewOptions {
30
+ readonly theme: Theme;
31
+ readonly source: DiffSource;
32
+ }
33
+ /** Counts from the same interpretation the DiffView body renders. */
34
+ export declare function diffStats(source: DiffSource): DiffStats | undefined;
35
+ /**
36
+ * Unified single-column diff body for the ToolView rail. One space between the
37
+ * sign/line number and the source, right-aligned numbers, no right-side bar and
38
+ * no split columns. Wrapped continuation rows repeat neither sign nor number.
39
+ * A patch that cannot be parsed keeps its raw text behind a short diagnostic.
40
+ */
41
+ export declare class DiffView implements Component {
42
+ #private;
43
+ constructor(options: DiffViewOptions);
44
+ render(width: number): string[];
45
+ invalidate(): void;
46
+ }
@@ -0,0 +1,184 @@
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
3
+ const PATCH_HEADER = /^(diff --git |diff -|index |--- |\+\+\+ |old mode |new mode |new file|deleted file|similarity index|rename |copy |Binary files )/;
4
+ const MIN_NUMBER_WIDTH = 2;
5
+ /** Tab width used for layout only; copy text keeps the original bytes. */
6
+ const TAB_WIDTH = 2;
7
+ function parseUnifiedPatch(text) {
8
+ if (text.trim() === "")
9
+ return { ok: false, diagnostic: "empty patch" };
10
+ const rawLines = text.split(/\r?\n/);
11
+ const hunks = [];
12
+ let added = 0;
13
+ let removed = 0;
14
+ let bodyLines = 0;
15
+ let current;
16
+ let oldNum = 0;
17
+ let newNum = 0;
18
+ for (let index = 0; index < rawLines.length; index += 1) {
19
+ const raw = rawLines[index] ?? "";
20
+ if (raw.startsWith("@@")) {
21
+ const header = HUNK_HEADER.exec(raw);
22
+ if (header === null)
23
+ return { ok: false, diagnostic: `invalid hunk header: ${raw}` };
24
+ if (current !== undefined)
25
+ hunks.push({ lines: current });
26
+ current = [];
27
+ oldNum = Number(header[1]);
28
+ newNum = Number(header[2]);
29
+ continue;
30
+ }
31
+ if (raw.startsWith("\\"))
32
+ continue;
33
+ if (current === undefined) {
34
+ if (raw === "" || PATCH_HEADER.test(raw))
35
+ continue;
36
+ return { ok: false, diagnostic: `unexpected line before the first hunk: ${raw}` };
37
+ }
38
+ if (raw === "") {
39
+ if (index === rawLines.length - 1)
40
+ break;
41
+ return { ok: false, diagnostic: "hunk line without a change sign" };
42
+ }
43
+ const sign = raw.charAt(0);
44
+ const body = raw.slice(1);
45
+ if (sign === "+") {
46
+ current.push({ kind: "add", line: newNum, text: body });
47
+ newNum += 1;
48
+ added += 1;
49
+ }
50
+ else if (sign === "-") {
51
+ current.push({ kind: "del", line: oldNum, text: body });
52
+ oldNum += 1;
53
+ removed += 1;
54
+ }
55
+ else if (sign === " ") {
56
+ current.push({ kind: "ctx", line: newNum, text: body });
57
+ oldNum += 1;
58
+ newNum += 1;
59
+ }
60
+ else {
61
+ return { ok: false, diagnostic: `unsupported hunk line: ${raw}` };
62
+ }
63
+ bodyLines += 1;
64
+ }
65
+ if (current !== undefined)
66
+ hunks.push({ lines: current });
67
+ if (bodyLines === 0)
68
+ return { ok: false, diagnostic: "no hunks found" };
69
+ return { ok: true, hunks, stats: { added, removed } };
70
+ }
71
+ function summarizeHunks(hunks) {
72
+ let added = 0;
73
+ let removed = 0;
74
+ let bodyLines = 0;
75
+ for (const hunk of hunks) {
76
+ for (const line of hunk.lines) {
77
+ bodyLines += 1;
78
+ if (line.kind === "add")
79
+ added += 1;
80
+ else if (line.kind === "del")
81
+ removed += 1;
82
+ }
83
+ }
84
+ if (bodyLines === 0)
85
+ return { ok: false, diagnostic: "no hunks found" };
86
+ return { ok: true, hunks, stats: { added, removed } };
87
+ }
88
+ function parseSource(source) {
89
+ return source.kind === "unifiedPatch"
90
+ ? parseUnifiedPatch(source.text)
91
+ : summarizeHunks(source.hunks);
92
+ }
93
+ /** Counts from the same interpretation the DiffView body renders. */
94
+ export function diffStats(source) {
95
+ const parsed = parseSource(source);
96
+ return parsed.ok ? parsed.stats : undefined;
97
+ }
98
+ function numberWidth(hunks) {
99
+ let max = 0;
100
+ for (const hunk of hunks) {
101
+ for (const line of hunk.lines) {
102
+ if (line.line > max)
103
+ max = line.line;
104
+ }
105
+ }
106
+ return Math.max(MIN_NUMBER_WIDTH, String(max).length);
107
+ }
108
+ function lineToken(kind) {
109
+ if (kind === "add")
110
+ return "success";
111
+ if (kind === "del")
112
+ return "error";
113
+ return "dim";
114
+ }
115
+ function lineSign(kind) {
116
+ if (kind === "add")
117
+ return "+";
118
+ if (kind === "del")
119
+ return "-";
120
+ return " ";
121
+ }
122
+ /**
123
+ * Unified single-column diff body for the ToolView rail. One space between the
124
+ * sign/line number and the source, right-aligned numbers, no right-side bar and
125
+ * no split columns. Wrapped continuation rows repeat neither sign nor number.
126
+ * A patch that cannot be parsed keeps its raw text behind a short diagnostic.
127
+ */
128
+ export class DiffView {
129
+ #theme;
130
+ #parsed;
131
+ #rawText;
132
+ #cache;
133
+ constructor(options) {
134
+ this.#theme = options.theme;
135
+ this.#rawText = options.source.kind === "unifiedPatch" ? options.source.text : undefined;
136
+ this.#parsed = parseSource(options.source);
137
+ }
138
+ render(width) {
139
+ const safeWidth = Math.max(1, width);
140
+ if (this.#cache?.width === safeWidth)
141
+ return this.#cache.rows;
142
+ const rows = this.#parsed.ok
143
+ ? this.#renderDiff(safeWidth, this.#parsed.hunks)
144
+ : this.#renderRaw(safeWidth);
145
+ this.#cache = { width: safeWidth, rows };
146
+ return rows;
147
+ }
148
+ invalidate() {
149
+ this.#cache = undefined;
150
+ }
151
+ #renderRaw(width) {
152
+ const rows = [];
153
+ if (!this.#parsed.ok)
154
+ rows.push(this.#theme.fg("warning", `! ${this.#parsed.diagnostic}`));
155
+ for (const raw of (this.#rawText ?? "").split(/\r?\n/)) {
156
+ rows.push(this.#theme.fg("dim", raw));
157
+ }
158
+ return rows.map((row) => truncateToWidth(row, width, "…"));
159
+ }
160
+ #renderDiff(width, hunks) {
161
+ const numberCol = numberWidth(hunks);
162
+ const gutterWidth = numberCol + 2;
163
+ const contentWidth = Math.max(1, width - gutterWidth);
164
+ const continuation = " ".repeat(gutterWidth);
165
+ const rows = [];
166
+ let isFirstHunk = true;
167
+ for (const hunk of hunks) {
168
+ if (!isFirstHunk) {
169
+ rows.push(this.#theme.fg("dim", ` ${"…".padStart(numberCol)}`));
170
+ }
171
+ isFirstHunk = false;
172
+ for (const line of hunk.lines) {
173
+ const token = lineToken(line.kind);
174
+ const prefix = `${lineSign(line.kind)}${String(line.line).padStart(numberCol)} `;
175
+ const wrapped = wrapTextWithAnsi(line.text.replace(/\t/g, " ".repeat(TAB_WIDTH)), contentWidth);
176
+ for (let row = 0; row < wrapped.length; row += 1) {
177
+ const head = row === 0 ? prefix : continuation;
178
+ rows.push(this.#theme.fg(token, `${head}${wrapped[row] ?? ""}`));
179
+ }
180
+ }
181
+ }
182
+ return rows.map((row) => truncateToWidth(row, width, "…"));
183
+ }
184
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function (pi: ExtensionAPI): void;