@ladbabynpm/picc-edit 0.1.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/src/file.ts ADDED
@@ -0,0 +1,162 @@
1
+ // =============================================================================
2
+ // picc-edit — src/file.ts
3
+ //
4
+ // Adaptors vs upstream:
5
+ // - `getFsImplementation()` → plain `node:fs`.
6
+ // - `safeResolvePath` is dropped (the caller passes an absolute path).
7
+ // - `writeFileSyncAndFlush_DEPRECATED` → `fs.writeFileSync`.
8
+ // =============================================================================
9
+
10
+ import {
11
+ readdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import {
17
+ basename,
18
+ dirname,
19
+ extname,
20
+ join,
21
+ } from "node:path";
22
+
23
+ export type LineEndingType = "CRLF" | "LF";
24
+
25
+ function isEnoent(e: unknown): boolean {
26
+ return (
27
+ typeof e === "object" &&
28
+ e !== null &&
29
+ (e as NodeJS.ErrnoException).code === "ENOENT"
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Get the normalized modification time of a file in milliseconds.
35
+ */
36
+ export function getFileModificationTime(filePath: string): number {
37
+ return Math.floor(statSync(filePath).mtimeMs);
38
+ }
39
+
40
+ /**
41
+ * Detect the file encoding from its leading bytes. Empty files default to
42
+ * utf8 so that writing emoji/CJK to empty files is not corrupted.
43
+ */
44
+ export function detectEncodingForResolvedPath(
45
+ resolvedPath: string,
46
+ ): BufferEncoding {
47
+ const buffer = readFileSync(resolvedPath);
48
+ const bytesRead = buffer.length;
49
+
50
+ if (bytesRead === 0) {
51
+ return "utf8";
52
+ }
53
+
54
+ if (bytesRead >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
55
+ return "utf16le";
56
+ }
57
+
58
+ if (
59
+ bytesRead >= 3 &&
60
+ buffer[0] === 0xef &&
61
+ buffer[1] === 0xbb &&
62
+ buffer[2] === 0xbf
63
+ ) {
64
+ return "utf8";
65
+ }
66
+
67
+ return "utf8";
68
+ }
69
+
70
+ /** Detect the dominant line-ending style in a string. */
71
+ export function detectLineEndingsForString(content: string): LineEndingType {
72
+ let crlfCount = 0;
73
+ let lfCount = 0;
74
+
75
+ for (let i = 0; i < content.length; i++) {
76
+ if (content[i] === "\n") {
77
+ if (i > 0 && content[i - 1] === "\r") {
78
+ crlfCount++;
79
+ } else {
80
+ lfCount++;
81
+ }
82
+ }
83
+ }
84
+
85
+ return crlfCount > lfCount ? "CRLF" : "LF";
86
+ }
87
+
88
+ /**
89
+ * Read a file, returning its CRLF-normalized content plus detected encoding
90
+ * and line-ending style in one pass.
91
+ */
92
+ export function readFileSyncWithMetadata(filePath: string): {
93
+ content: string;
94
+ encoding: BufferEncoding;
95
+ lineEndings: LineEndingType;
96
+ } {
97
+ const encoding = detectEncodingForResolvedPath(filePath);
98
+ const raw = readFileSync(filePath, { encoding });
99
+ const lineEndings = detectLineEndingsForString(raw.slice(0, 4096));
100
+ return {
101
+ content: raw.replaceAll("\r\n", "\n"),
102
+ encoding,
103
+ lineEndings,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Write `content` to `filePath` with `endings` normalization applied.
109
+ *
110
+ * - `'LF'`: writes `content` verbatim (the model's sent line endings are
111
+ * respected as-is; no resampling of the repo).
112
+ * - `'CRLF'`: normalizes any existing CRLF to LF first, then re-joins with
113
+ * CRLF so a `content` that already contains `\r\n` does not become `\r\r\n`.
114
+ */
115
+ export function writeTextContent(
116
+ filePath: string,
117
+ content: string,
118
+ encoding: BufferEncoding,
119
+ endings: LineEndingType,
120
+ ): void {
121
+ let toWrite = content;
122
+ if (endings === "CRLF") {
123
+ toWrite = content.replaceAll("\r\n", "\n").split("\n").join("\r\n");
124
+ }
125
+ writeFileSync(filePath, toWrite, { encoding });
126
+ }
127
+
128
+ /**
129
+ * Convert leading tabs on each line to two spaces. Used only for the
130
+ * *display* patch — the written content is left untouched.
131
+ */
132
+ export function convertLeadingTabsToSpaces(content: string): string {
133
+ if (!content.includes("\t")) return content;
134
+ return content.replace(/^\t+/gm, (m) => " ".repeat(m.length));
135
+ }
136
+
137
+ /**
138
+ * Find files with the same base name but a different extension in the same
139
+ * directory, to suggest a fix for a file-not-found edit. Returns the bare
140
+ * filename of the first match, or undefined.
141
+ */
142
+ export function findSimilarFile(filePath: string): string | undefined {
143
+ try {
144
+ const dir = dirname(filePath);
145
+ const fileBaseName = basename(filePath, extname(filePath));
146
+
147
+ const files = readdirSync(dir);
148
+ const similarFiles = files.filter(
149
+ (file) =>
150
+ basename(file, extname(file)) === fileBaseName &&
151
+ join(dir, file) !== filePath,
152
+ );
153
+
154
+ const firstMatch = similarFiles[0];
155
+ return firstMatch ? firstMatch : undefined;
156
+ } catch (error) {
157
+ if (!isEnoent(error)) {
158
+ throw error;
159
+ }
160
+ return undefined;
161
+ }
162
+ }
package/src/path.ts ADDED
@@ -0,0 +1,62 @@
1
+ // =============================================================================
2
+ // picc-write — src/path.ts
3
+ //
4
+ // Adaptors vs upstream:
5
+ // - `getCwd()` / `getFsImplementation().cwd()` → `process.cwd()`
6
+ // - `getPlatform()` → `process.platform`
7
+ // =============================================================================
8
+
9
+ import { homedir } from "node:os";
10
+ import { isAbsolute, join, normalize, resolve } from "node:path";
11
+ import { posixPathToWindowsPath } from "./windowsPaths.js";
12
+
13
+ /**
14
+ * Expand `~` to the home directory, convert Windows POSIX-style paths, and
15
+ * resolve relative paths against `baseDir`.
16
+ *
17
+ * Ported from `utils/path.ts:expandPath`.
18
+ */
19
+ export function expandPath(path: string, baseDir?: string): string {
20
+ const actualBaseDir = baseDir ?? process.cwd();
21
+
22
+ if (typeof path !== "string") {
23
+ throw new TypeError(`Path must be a string, received ${typeof path}`);
24
+ }
25
+ if (typeof actualBaseDir !== "string") {
26
+ throw new TypeError(
27
+ `Base directory must be a string, received ${typeof actualBaseDir}`,
28
+ );
29
+ }
30
+
31
+ if (path.includes("\0") || actualBaseDir.includes("\0")) {
32
+ throw new Error("Path contains null bytes");
33
+ }
34
+
35
+ const trimmedPath = path.trim();
36
+ if (!trimmedPath) {
37
+ return normalize(actualBaseDir).normalize("NFC");
38
+ }
39
+
40
+ if (trimmedPath === "~") {
41
+ return homedir().normalize("NFC");
42
+ }
43
+
44
+ if (trimmedPath.startsWith("~/")) {
45
+ return join(homedir(), trimmedPath.slice(2)).normalize("NFC");
46
+ }
47
+
48
+ let processedPath = trimmedPath;
49
+ if (process.platform === "win32" && trimmedPath.match(/^\/[a-z]\//i)) {
50
+ try {
51
+ processedPath = posixPathToWindowsPath(trimmedPath);
52
+ } catch {
53
+ processedPath = trimmedPath;
54
+ }
55
+ }
56
+
57
+ if (isAbsolute(processedPath)) {
58
+ return normalize(processedPath).normalize("NFC");
59
+ }
60
+
61
+ return resolve(actualBaseDir, processedPath).normalize("NFC");
62
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,39 @@
1
+ // =============================================================================
2
+ // picc-edit — src/prompt.ts
3
+ //
4
+ // Adaptors vs upstream:
5
+ // - `isCompactLinePrefixEnabled()` is hardcoded to `true` (picc-read always
6
+ // uses the compact `line-number + tab` prefix).
7
+ // - The `USER_TYPE === 'ant'` minimal-uniqueness hint is dropped.
8
+ // - `FILE_READ_TOOL_NAME` is fixed to `Read` (the read tool is configured
9
+ // independently of this extension).
10
+ // =============================================================================
11
+
12
+ function getPreReadInstruction(): string {
13
+ return `\n- You must use your \`read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. `;
14
+ }
15
+
16
+ export function getEditToolDescription(): string {
17
+ return getDefaultEditDescription();
18
+ }
19
+
20
+ function getDefaultEditDescription(): string {
21
+ const prefixFormat = "line number + tab";
22
+ return `Performs exact string replacements in files.
23
+
24
+ Usage:${getPreReadInstruction()}
25
+ - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
26
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
27
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
28
+ - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
29
+ - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`;
30
+ }
31
+
32
+ /** Faithful success messages (from Claude Code's `mapToolResultToToolResultBlockParam`). */
33
+ export function replaceAllMessage(filePath: string): string {
34
+ return `The file ${filePath} has been updated. All occurrences were successfully replaced.`;
35
+ }
36
+
37
+ export function singleEditMessage(filePath: string): string {
38
+ return `The file ${filePath} has been updated successfully.`;
39
+ }
@@ -0,0 +1,52 @@
1
+ // =============================================================================
2
+ // picc-edit — src/readState.ts
3
+ //
4
+ // Session-scoped "has this file been read (and when)" map, mirroring Claude
5
+ // Code's `readFileState`. pi has no shared read-state, so each of picc-write
6
+ // and picc-edit owns its own: the entry point populates it from `tool_result`
7
+ // events for any file tool that establishes known contents (read/write/edit)
8
+ // and clears it on session start.
9
+ // =============================================================================
10
+
11
+ /** A recorded read of a file. `offset`/`limit` present ⇒ partial view. */
12
+ export type ReadEntry = {
13
+ content: string;
14
+ timestamp: number;
15
+ offset?: number;
16
+ limit?: number;
17
+ };
18
+
19
+ /**
20
+ * Tool names whose successful `tool_result` means the file's contents are
21
+ * known and can seed the read-state. Mirrors Claude Code, where Read, Write,
22
+ * and Edit each refresh the shared `readFileState` — so a file the agent just
23
+ * wrote (or edited) is immediately editable/writable without a redundant
24
+ * re-read. Both pi's lowercase built-ins and the capitalized picc ports are
25
+ * accepted, since the active name depends on each extension's config.
26
+ */
27
+ const KNOWN_FILE_TOOL_NAMES = new Set([
28
+ "read",
29
+ "Read",
30
+ "write",
31
+ "Write",
32
+ "edit",
33
+ "Edit",
34
+ ]);
35
+
36
+ export function fileStateToolName(name: string): boolean {
37
+ return KNOWN_FILE_TOOL_NAMES.has(name);
38
+ }
39
+
40
+ const state = new Map<string, ReadEntry>();
41
+
42
+ export function readStateGet(filePath: string): ReadEntry | undefined {
43
+ return state.get(filePath);
44
+ }
45
+
46
+ export function readStateSet(filePath: string, entry: ReadEntry): void {
47
+ state.set(filePath, entry);
48
+ }
49
+
50
+ export function readStateClear(): void {
51
+ state.clear();
52
+ }
@@ -0,0 +1,185 @@
1
+ // =============================================================================
2
+ // picc-edit — src/renderDiff.ts
3
+ //
4
+ // Local port of pi's built-in `renderDiff`
5
+ // (`modes/interactive/components/diff.js`) with one difference: added lines
6
+ // are drawn in a fixed truecolor green (#207c36) instead of the theme's
7
+ // `toolDiffAdded` color (see `renderAddedLine`). Everything else — line
8
+ // parsing, tab replacement, intra-line word diff with inverse highlighting —
9
+ // mirrors the built-in so the TUI output matches pi's standard diff viewer.
10
+ // =============================================================================
11
+
12
+ import { type Change, diffWords } from "diff";
13
+
14
+ /** Fixed truecolor for newly added lines (overrides the theme color). */
15
+ const ADDED_LINE_FG = "\x1b[38;2;32;124;54m";
16
+ /** Reset only the foreground color — same convention as `Theme.fg`. */
17
+ const FG_RESET = "\x1b[39m";
18
+
19
+ type RenderDiffTheme = {
20
+ fg: (color: "toolDiffRemoved" | "toolDiffContext", text: string) => string;
21
+ inverse: (text: string) => string;
22
+ };
23
+
24
+ /** Render an added line in the fixed #207c36 color. */
25
+ function renderAddedLine(line: string): string {
26
+ return `${ADDED_LINE_FG}${line}${FG_RESET}`;
27
+ }
28
+
29
+ /** Parse diff line to extract prefix, line number, and content.
30
+ * Format: "+123 content" or "-123 content" or " 123 content" or " ..." */
31
+ function parseDiffLine(line: string): {
32
+ prefix: string;
33
+ lineNum: string;
34
+ content: string;
35
+ } | null {
36
+ const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
37
+ if (!match) return null;
38
+ return { prefix: match[1], lineNum: match[2], content: match[3] };
39
+ }
40
+
41
+ /** Replace tabs with spaces for consistent rendering. */
42
+ function replaceTabs(text: string): string {
43
+ return text.replace(/\t/g, " ");
44
+ }
45
+
46
+ /**
47
+ * Compute word-level diff and render with inverse on changed parts.
48
+ * Removed parts use the theme's inverse; added parts use the theme's inverse
49
+ * wrapped in the fixed added-line color (the inverse flips fg/bg, so the
50
+ * inverse spans still read as the #207c36 swap).
51
+ */
52
+ function renderIntraLineDiff(
53
+ oldContent: string,
54
+ newContent: string,
55
+ theme: RenderDiffTheme,
56
+ ): { removedLine: string; addedLine: string } {
57
+ const wordDiff: Change[] = diffWords(oldContent, newContent);
58
+ let removedLine = "";
59
+ let addedLine = "";
60
+ let isFirstRemoved = true;
61
+ let isFirstAdded = true;
62
+ for (const part of wordDiff) {
63
+ if (part.removed) {
64
+ let value = part.value;
65
+ // Strip leading whitespace from the first removed part
66
+ if (isFirstRemoved) {
67
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
68
+ value = value.slice(leadingWs.length);
69
+ removedLine += leadingWs;
70
+ isFirstRemoved = false;
71
+ }
72
+ if (value) {
73
+ removedLine += theme.inverse(value);
74
+ }
75
+ } else if (part.added) {
76
+ let value = part.value;
77
+ // Strip leading whitespace from the first added part
78
+ if (isFirstAdded) {
79
+ const leadingWs = value.match(/^(\s*)/)?.[1] || "";
80
+ value = value.slice(leadingWs.length);
81
+ addedLine += leadingWs;
82
+ isFirstAdded = false;
83
+ }
84
+ if (value) {
85
+ addedLine += theme.inverse(value);
86
+ }
87
+ } else {
88
+ removedLine += part.value;
89
+ addedLine += part.value;
90
+ }
91
+ }
92
+ return { removedLine, addedLine };
93
+ }
94
+
95
+ /**
96
+ * Render a diff string with colored lines and intra-line change highlighting.
97
+ * - Context lines: dim/gray
98
+ * - Removed lines: red, with inverse on changed tokens
99
+ * - Added lines: fixed #207c36, with inverse on changed tokens
100
+ */
101
+ export function renderDiff(
102
+ diffText: string,
103
+ theme: RenderDiffTheme,
104
+ ): string {
105
+ const lines = diffText.split("\n");
106
+ const result: string[] = [];
107
+ let i = 0;
108
+ while (i < lines.length) {
109
+ const line = lines[i];
110
+ const parsed = parseDiffLine(line);
111
+ if (!parsed) {
112
+ result.push(theme.fg("toolDiffContext", line));
113
+ i++;
114
+ continue;
115
+ }
116
+ if (parsed.prefix === "-") {
117
+ // Collect consecutive removed lines
118
+ const removedLines: { lineNum: string; content: string }[] = [];
119
+ while (i < lines.length) {
120
+ const p = parseDiffLine(lines[i]);
121
+ if (p?.prefix !== "-") break;
122
+ removedLines.push({ lineNum: p.lineNum, content: p.content });
123
+ i++;
124
+ }
125
+ // Collect consecutive added lines
126
+ const addedLines: { lineNum: string; content: string }[] = [];
127
+ while (i < lines.length) {
128
+ const p = parseDiffLine(lines[i]);
129
+ if (p?.prefix !== "+") break;
130
+ addedLines.push({ lineNum: p.lineNum, content: p.content });
131
+ i++;
132
+ }
133
+ // Only do intra-line diffing when there's exactly one removed and
134
+ // one added line (indicating a single line modification).
135
+ // Otherwise, show lines as-is.
136
+ if (removedLines.length === 1 && addedLines.length === 1) {
137
+ const removed = removedLines[0];
138
+ const added = addedLines[0];
139
+ const { removedLine, addedLine } = renderIntraLineDiff(
140
+ replaceTabs(removed.content),
141
+ replaceTabs(added.content),
142
+ theme,
143
+ );
144
+ result.push(
145
+ theme.fg(
146
+ "toolDiffRemoved",
147
+ `-${removed.lineNum} ${removedLine}`,
148
+ ),
149
+ );
150
+ result.push(renderAddedLine(`+${added.lineNum} ${addedLine}`));
151
+ } else {
152
+ // Show all removed lines first, then all added lines
153
+ for (const removed of removedLines) {
154
+ result.push(
155
+ theme.fg(
156
+ "toolDiffRemoved",
157
+ `-${removed.lineNum} ${replaceTabs(removed.content)}`,
158
+ ),
159
+ );
160
+ }
161
+ for (const added of addedLines) {
162
+ result.push(
163
+ renderAddedLine(`+${added.lineNum} ${replaceTabs(added.content)}`),
164
+ );
165
+ }
166
+ }
167
+ } else if (parsed.prefix === "+") {
168
+ // Standalone added line
169
+ result.push(
170
+ renderAddedLine(`+${parsed.lineNum} ${replaceTabs(parsed.content)}`),
171
+ );
172
+ i++;
173
+ } else {
174
+ // Context line
175
+ result.push(
176
+ theme.fg(
177
+ "toolDiffContext",
178
+ ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`,
179
+ ),
180
+ );
181
+ i++;
182
+ }
183
+ }
184
+ return result.join("\n");
185
+ }
@@ -0,0 +1,31 @@
1
+ // =============================================================================
2
+ // picc-write — src/windowsPaths.ts
3
+ //
4
+ // On Windows, Git Bash (MSYS2) passes POSIX-style paths like `/c/Users/...`.
5
+ // This module converts that to a real Windows path so file APIs work.
6
+ // =============================================================================
7
+
8
+ /** Convert a POSIX path to a Windows path using pure JS. */
9
+ export function posixPathToWindowsPath(posixPath: string): string {
10
+ // Handle UNC paths: //server/share -> \\server\share
11
+ if (posixPath.startsWith("//")) {
12
+ return posixPath.replace(/\//g, "\\");
13
+ }
14
+ // Handle /cygdrive/c/... format
15
+ const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/);
16
+ if (cygdriveMatch) {
17
+ const driveLetter = cygdriveMatch[1]!.toUpperCase();
18
+ const rest = posixPath
19
+ .slice(("/cygdrive/" + cygdriveMatch[1]).length);
20
+ return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
21
+ }
22
+ // Handle /c/... format (MSYS2/Git Bash)
23
+ const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/);
24
+ if (driveMatch) {
25
+ const driveLetter = driveMatch[1]!.toUpperCase();
26
+ const rest = posixPath.slice(2);
27
+ return driveLetter + ":" + (rest || "\\").replace(/\//g, "\\");
28
+ }
29
+ // Already Windows or relative — just flip slashes
30
+ return posixPath.replace(/\//g, "\\");
31
+ }