decorated-pi 0.2.1 → 0.3.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.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Patch — Exact string replacement for pi
3
+ *
4
+ * Replaces diff-based format with old_str/new_str matching.
5
+ * No fuzzy matching, no similarity — only exact string matching.
6
+ *
7
+ * Per-file operations:
8
+ * { path, edits: [{ old_str, new_str, anchor? }] } — targeted replacements
9
+ * { path, overwrite: true, new_str } — atomic full-file overwrite
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+ // Types
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ export interface Edit {
21
+ /** Optional anchor to narrow search range (exact string, searched from file start) */
22
+ anchor?: string;
23
+ /** Exact text to find in the file */
24
+ old_str: string;
25
+ /** Replacement text */
26
+ new_str: string;
27
+ }
28
+
29
+ export interface FilePatch {
30
+ /** File path (relative to cwd or absolute) */
31
+ path: string;
32
+ /** Targeted edits to apply sequentially */
33
+ edits?: Edit[];
34
+ /** If true, replace the entire file content atomically */
35
+ overwrite?: boolean;
36
+ /** New file content when overwriting */
37
+ new_str?: string;
38
+ }
39
+
40
+ export interface PatchResult {
41
+ modified: string[];
42
+ created: string[];
43
+ warnings: string[];
44
+ /** Per-file replacement info for diff generation */
45
+ replacements: Map<string, ReplacementInfo[]>;
46
+ /** Original file lines per file, for diff context generation */
47
+ originalLines: Map<string, string[]>;
48
+ }
49
+
50
+ /** Records a single old_str→new_str replacement within a file */
51
+ export interface ReplacementInfo {
52
+ /** 1-based line number where old_str starts in the original file */
53
+ oldStartLine: number;
54
+ /** 1-based line number where old_str ends in the original file */
55
+ oldEndLine: number;
56
+ /** 1-based line number where new_str starts in the result file */
57
+ newStartLine: number;
58
+ /** 1-based line number where new_str ends in the result file */
59
+ newEndLine: number;
60
+ /** The original lines that were replaced */
61
+ oldLines: string[];
62
+ /** The new lines that replaced them */
63
+ newLines: string[];
64
+ /** Optional anchor text (first line only, for hunk display) */
65
+ anchor?: string;
66
+ }
67
+
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // Errors
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+
72
+ export class ParseError extends Error {
73
+ constructor(message: string) { super(message); this.name = "ParseError"; }
74
+ }
75
+
76
+ export class ApplyError extends Error {
77
+ constructor(message: string) { super(message); this.name = "ApplyError"; }
78
+ }
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+ // Main API
82
+ // ═══════════════════════════════════════════════════════════════════════════
83
+
84
+ export async function applyPatch(patch: FilePatch, cwd: string): Promise<PatchResult> {
85
+ if (!patch.path?.trim()) throw new ParseError("File path cannot be empty.");
86
+
87
+ const result: PatchResult = {
88
+ modified: [],
89
+ created: [],
90
+ warnings: [],
91
+ replacements: new Map(),
92
+ originalLines: new Map(),
93
+ };
94
+
95
+ const absPath = resolveAbsPath(cwd, patch.path);
96
+
97
+ if (patch.overwrite) {
98
+ applyOverwrite(absPath, patch.path, patch.new_str ?? "", result);
99
+ } else if (patch.edits && patch.edits.length > 0) {
100
+ await applyEdits(absPath, patch.path, patch.edits, result);
101
+ } else {
102
+ throw new ParseError(
103
+ `File ${patch.path}: must provide either edits[] or overwrite:true with new_str.`
104
+ );
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ /** @deprecated Use applyPatch instead. Kept for backward compatibility with tests. */
111
+ export async function applyPatches(patches: FilePatch[], cwd: string): Promise<PatchResult> {
112
+ if (!Array.isArray(patches) || patches.length === 0) {
113
+ throw new ParseError("Patch is empty — no files specified.");
114
+ }
115
+
116
+ const result: PatchResult = {
117
+ modified: [],
118
+ created: [],
119
+ warnings: [],
120
+ replacements: new Map(),
121
+ originalLines: new Map(),
122
+ };
123
+
124
+ for (const p of patches) {
125
+ if (!p.path?.trim()) throw new ParseError("File path cannot be empty.");
126
+
127
+ const absPath = resolveAbsPath(cwd, p.path);
128
+
129
+ if (p.overwrite) {
130
+ applyOverwrite(absPath, p.path, p.new_str ?? "", result);
131
+ } else if (p.edits && p.edits.length > 0) {
132
+ await applyEdits(absPath, p.path, p.edits, result);
133
+ } else {
134
+ throw new ParseError(
135
+ `File ${p.path}: must provide either edits[] or overwrite:true with new_str.`
136
+ );
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // Overwrite (atomic mv)
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ function applyOverwrite(
148
+ absPath: string,
149
+ displayPath: string,
150
+ content: string,
151
+ result: PatchResult,
152
+ ): void {
153
+ const oldContent = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
154
+
155
+ // Write to temp file in the same directory (same filesystem → mv is atomic)
156
+ ensureParentDir(absPath);
157
+ const dir = path.dirname(absPath);
158
+ const tmpName = path.join(dir, `.pi-patch-${randomId()}.tmp`);
159
+ fs.writeFileSync(tmpName, content, "utf8");
160
+ fs.renameSync(tmpName, absPath);
161
+
162
+ if (oldContent) {
163
+ result.modified.push(displayPath);
164
+ } else {
165
+ result.created.push(displayPath);
166
+ }
167
+ }
168
+
169
+ // ═══════════════════════════════════════════════════════════════════════════
170
+ // Edits (exact string replacement)
171
+ // ═══════════════════════════════════════════════════════════════════════════
172
+
173
+ async function applyEdits(
174
+ absPath: string,
175
+ displayPath: string,
176
+ edits: Edit[],
177
+ result: PatchResult,
178
+ ): Promise<void> {
179
+ if (!fs.existsSync(absPath)) {
180
+ throw new ApplyError(`File not found: ${displayPath}`);
181
+ }
182
+ const stat = fs.statSync(absPath);
183
+ if (stat.isDirectory()) {
184
+ throw new ApplyError(`Cannot patch directory: ${displayPath}`);
185
+ }
186
+
187
+ const rawContent = fs.readFileSync(absPath, "utf8");
188
+ // Store original lines for diff context generation later
189
+ const origLines = rawContent.split("\n");
190
+ // Remove trailing empty line from split (trailing newline), but keep lines for content-less files
191
+ if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
192
+ result.originalLines.set(displayPath, origLines);
193
+
194
+ const lineEnding = detectLineEnding(rawContent);
195
+ let content = normalizeLineEndings(rawContent);
196
+
197
+ // Precompute line offsets for O(log n) line number lookups
198
+ const rawLineOffsets = buildLineOffsets(rawContent);
199
+
200
+ // Track cumulative offset for mapping current positions back to original
201
+ let cumulativeOffset = 0;
202
+ const replacements: ReplacementInfo[] = [];
203
+
204
+ for (const edit of edits) {
205
+ if (!edit.old_str) {
206
+ throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
207
+ }
208
+
209
+ const oldNorm = normalizeLineEndings(edit.old_str);
210
+ const newNorm = normalizeLineEndings(edit.new_str);
211
+
212
+ // Determine search range
213
+ let searchFrom = 0;
214
+
215
+ if (edit.anchor) {
216
+ const anchorNorm = normalizeLineEndings(edit.anchor);
217
+
218
+ // Find anchor — must be unique
219
+ const anchorIdx = content.indexOf(anchorNorm);
220
+ if (anchorIdx === -1) {
221
+ throw new ApplyError(
222
+ `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`
223
+ );
224
+ }
225
+ // Check uniqueness
226
+ const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
227
+ if (secondAnchor !== -1) {
228
+ throw new ApplyError(
229
+ `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
230
+ `found at multiple locations. Choose a more specific anchor.`
231
+ );
232
+ }
233
+
234
+ // Search from anchor start position onward (anchor narrows search range)
235
+ // old_str may start at anchor position (anchor is a prefix of old_str)
236
+ searchFrom = anchorIdx;
237
+ }
238
+
239
+ // Find old_str in range — must be unique
240
+ const matchIdx = content.indexOf(oldNorm, searchFrom);
241
+ if (matchIdx === -1) {
242
+ throw new ApplyError(
243
+ `old_str not found in ${displayPath}` +
244
+ (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
245
+ `: "${truncate(edit.old_str)}". ` +
246
+ `The file may have changed — re-read it and try again.`
247
+ );
248
+ }
249
+
250
+ // Check uniqueness
251
+ const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
252
+ if (secondMatch !== -1) {
253
+ throw new ApplyError(
254
+ `old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
255
+ `Add more context to old_str or use an anchor to narrow the search.`
256
+ );
257
+ }
258
+
259
+ // Compute line numbers in the original file for diff generation (O(log n) via binary search)
260
+ // matchIdx is in the modified content; subtract cumulative offset to map back to original
261
+ const origMatchIdx = matchIdx - cumulativeOffset;
262
+ const oldStartLine = lineAtOffset(rawLineOffsets, origMatchIdx);
263
+ const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
264
+
265
+ // Apply replacement
266
+ content =
267
+ content.substring(0, matchIdx) +
268
+ newNorm +
269
+ content.substring(matchIdx + oldNorm.length);
270
+
271
+ // Track the offset shift for subsequent edits
272
+ cumulativeOffset += newNorm.length - oldNorm.length;
273
+
274
+ // Compute new_str line numbers in the result
275
+ const newStartLine = charOffsetToLine(content, matchIdx);
276
+ const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
277
+
278
+ // Record replacement info
279
+ replacements.push({
280
+ oldStartLine,
281
+ oldEndLine,
282
+ newStartLine,
283
+ newEndLine,
284
+ oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
285
+ newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
286
+ anchor: edit.anchor ? edit.anchor.split("\n")[0] : undefined,
287
+ });
288
+ }
289
+
290
+ // Restore line endings
291
+ const finalContent = restoreLineEndings(content, lineEnding);
292
+
293
+ // Warn if line endings were normalized (CRLF → LF)
294
+ if (lineEnding === "\r\n" && rawContent.includes("\r\n")) {
295
+ result.warnings.push(`${displayPath}: CRLF line endings were normalized to LF during editing.`);
296
+ }
297
+
298
+ fs.writeFileSync(absPath, finalContent, "utf8");
299
+ result.modified.push(displayPath);
300
+ result.replacements.set(displayPath, replacements);
301
+ }
302
+
303
+ // ═══════════════════════════════════════════════════════════════════════════
304
+ // Diff generation (for TUI preview and result display)
305
+ // ═══════════════════════════════════════════════════════════════════════════
306
+
307
+ /**
308
+ * Patch preview without writing to disk.
309
+ * Returns unified diff for edits, or truncated content for overwrites.
310
+ */
311
+ export interface PatchPreview {
312
+ diff?: string;
313
+ error?: string;
314
+ /** Truncated new content preview for overwrite mode */
315
+ preview?: string;
316
+ isOverwrite?: boolean;
317
+ }
318
+
319
+ export async function computePatchPreview(
320
+ patch: FilePatch,
321
+ cwd: string,
322
+ ): Promise<PatchPreview> {
323
+ try {
324
+ if (!patch.path?.trim()) {
325
+ return { error: "File path cannot be empty." };
326
+ }
327
+
328
+ const absPath = resolveAbsPath(cwd, patch.path);
329
+
330
+ if (patch.overwrite) {
331
+ return { preview: patch.new_str ?? "", isOverwrite: true };
332
+ } else if (patch.edits && patch.edits.length > 0) {
333
+ if (!fs.existsSync(absPath)) {
334
+ return { error: "File not found" };
335
+ }
336
+
337
+ const rawContent = fs.readFileSync(absPath, "utf8");
338
+ const origLines = rawContent.split("\n");
339
+ if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
340
+ let content = normalizeLineEndings(rawContent);
341
+ const rawLineOffsets = buildLineOffsets(rawContent);
342
+ const allReplacements: ReplacementInfo[] = [];
343
+ let cumulativeOffset = 0;
344
+
345
+ for (const edit of patch.edits) {
346
+ if (!edit.old_str) continue;
347
+ const oldNorm = normalizeLineEndings(edit.old_str);
348
+ const newNorm = normalizeLineEndings(edit.new_str);
349
+
350
+ let searchFrom = 0;
351
+ if (edit.anchor) {
352
+ const anchorNorm = normalizeLineEndings(edit.anchor);
353
+ const idx = content.indexOf(anchorNorm);
354
+ if (idx === -1) {
355
+ return { error: `Anchor not found: "${truncate(edit.anchor)}"` };
356
+ }
357
+ searchFrom = idx;
358
+ }
359
+
360
+ const matchIdx = content.indexOf(oldNorm, searchFrom);
361
+ if (matchIdx === -1) {
362
+ return { error: `old_str not found: "${truncate(edit.old_str)}"` };
363
+ }
364
+
365
+ const origMatchIdx = matchIdx - cumulativeOffset;
366
+ const oldStartLine = lineAtOffset(rawLineOffsets, origMatchIdx);
367
+ const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
368
+ const oldLines = oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
369
+ const newLines = newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
370
+ content = content.substring(0, matchIdx) + newNorm + content.substring(matchIdx + oldNorm.length);
371
+ const newStartLine = charOffsetToLine(content, matchIdx);
372
+ const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
373
+ allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: edit.anchor ? edit.anchor.split("\n")[0] : undefined });
374
+ cumulativeOffset += newNorm.length - oldNorm.length;
375
+ }
376
+
377
+ const diff = generateReplacementDiff(patch.path, allReplacements, origLines);
378
+ return { diff };
379
+ } else {
380
+ return { error: "Must provide edits[] or overwrite:true" };
381
+ }
382
+ } catch (err) {
383
+ return { error: err instanceof Error ? err.message : String(err) };
384
+ }
385
+ }
386
+
387
+ /** @deprecated Use computePatchPreview(single) instead. Kept for backward compatibility. */
388
+ export async function computePatchPreviewMulti(
389
+ patches: FilePatch[],
390
+ cwd: string,
391
+ ): Promise<Map<string, PatchPreview>> {
392
+ const results = new Map<string, PatchPreview>();
393
+ for (const p of patches) {
394
+ const preview = await computePatchPreview(p, cwd);
395
+ results.set(p.path || "_parse", preview);
396
+ }
397
+ return results;
398
+ }
399
+
400
+
401
+
402
+ export function generatePatchDiff(result: PatchResult): string {
403
+ const parts: string[] = [];
404
+ for (const [filePath, reps] of result.replacements) {
405
+ const origLines = result.originalLines.get(filePath) ?? [];
406
+ parts.push(generateReplacementDiff(filePath, reps, origLines));
407
+ }
408
+ return parts.join("\n");
409
+ }
410
+
411
+ interface ReplacementChunk {
412
+ startLine: number;
413
+ endLine: number;
414
+ reps: ReplacementInfo[];
415
+ }
416
+
417
+ function buildReplacementChunks(
418
+ reps: ReplacementInfo[],
419
+ totalLines: number,
420
+ contextLines: number,
421
+ ): ReplacementChunk[] {
422
+ const sorted = [...reps].sort((a, b) => a.oldStartLine - b.oldStartLine);
423
+ const chunks: ReplacementChunk[] = [];
424
+
425
+ for (const rep of sorted) {
426
+ const startLine = Math.max(1, rep.oldStartLine - contextLines);
427
+ const endLine = Math.min(totalLines, rep.oldEndLine + contextLines);
428
+ const current = chunks[chunks.length - 1];
429
+
430
+ if (current && startLine <= current.endLine + 1) {
431
+ current.endLine = Math.max(current.endLine, endLine);
432
+ current.reps.push(rep);
433
+ } else {
434
+ chunks.push({ startLine, endLine, reps: [rep] });
435
+ }
436
+ }
437
+
438
+ return chunks;
439
+ }
440
+
441
+ function getChunkAnchors(chunk: ReplacementChunk): string[] {
442
+ return [
443
+ ...new Set(
444
+ chunk.reps
445
+ .map((rep) => rep.anchor?.trim())
446
+ .filter((anchor): anchor is string => Boolean(anchor)),
447
+ ),
448
+ ];
449
+ }
450
+
451
+ function formatChunkHeader(chunk: ReplacementChunk): string {
452
+ const range = chunk.startLine === chunk.endLine
453
+ ? String(chunk.startLine)
454
+ : `${chunk.startLine}-${chunk.endLine}`;
455
+
456
+ const anchors = getChunkAnchors(chunk);
457
+ if (anchors.length === 0) {
458
+ return `@@ lines ${range} @@`;
459
+ }
460
+
461
+ if (anchors.length === 1) {
462
+ return `@@ lines ${range} @@ anchor: ${anchors[0]}`;
463
+ }
464
+
465
+ return `@@ lines ${range} @@`;
466
+ }
467
+
468
+ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
469
+ const anchors = getChunkAnchors(chunk);
470
+ if (anchors.length <= 1) return [];
471
+
472
+ const shown = anchors.slice(0, 2);
473
+ const remaining = anchors.length - shown.length;
474
+ const lines = ["anchors:", ...shown.map((anchor) => ` - ${anchor}`)];
475
+ if (remaining > 0) {
476
+ lines.push(` - +${remaining} more`);
477
+ }
478
+ return lines;
479
+ }
480
+
481
+ /**
482
+ * Generate diff as visual chunks merged by overlapping/adjacent context windows.
483
+ * This keeps spacing stable when multiple nearby edits would otherwise create
484
+ * repeated context and oversized gaps between chunks.
485
+ */
486
+ function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
487
+ const parts: string[] = [];
488
+ parts.push(`--- ${filePath}`);
489
+ parts.push(`+++ ${filePath}`);
490
+
491
+ if (reps.length === 0) {
492
+ parts.push("");
493
+ return parts.join("\n");
494
+ }
495
+
496
+ const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
497
+ const numWidth = String(maxLineNum).length;
498
+ const CONTEXT = 3;
499
+ const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
500
+
501
+ for (let c = 0; c < chunks.length; c++) {
502
+ const chunk = chunks[c]!;
503
+ if (c > 0) parts.push("");
504
+ parts.push(formatChunkHeader(chunk));
505
+ parts.push(...formatChunkMetadataLines(chunk));
506
+
507
+ let cursor = chunk.startLine;
508
+
509
+ for (const rep of chunk.reps) {
510
+ // Context before this replacement (only once per original line)
511
+ for (let i = cursor; i < rep.oldStartLine; i++) {
512
+ const num = String(i).padStart(numWidth, " ");
513
+ parts.push(` ${num} ${originalLines[i - 1]}`);
514
+ }
515
+
516
+ // Removed lines (from original)
517
+ for (let i = 0; i < rep.oldLines.length; i++) {
518
+ const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
519
+ parts.push(`-${num} ${rep.oldLines[i]}`);
520
+ }
521
+
522
+ // Added lines
523
+ for (let i = 0; i < rep.newLines.length; i++) {
524
+ const num = String(rep.oldStartLine + i).padStart(numWidth, " ");
525
+ parts.push(`+${num} ${rep.newLines[i]}`);
526
+ }
527
+
528
+ cursor = rep.oldEndLine + 1;
529
+ }
530
+
531
+ // Trailing context for the merged chunk
532
+ for (let i = cursor; i <= chunk.endLine; i++) {
533
+ const num = String(i).padStart(numWidth, " ");
534
+ parts.push(` ${num} ${originalLines[i - 1]}`);
535
+ }
536
+ }
537
+
538
+ if (parts[parts.length - 1] !== "") parts.push("");
539
+ return parts.join("\n");
540
+ }
541
+
542
+ // ═══════════════════════════════════════════════════════════════════════════
543
+ // Formatting
544
+ // ═══════════════════════════════════════════════════════════════════════════
545
+
546
+ export function formatPatchResult(result: PatchResult): string {
547
+ const lines: string[] = [];
548
+ for (const p of result.created) lines.push(`A ${p}`);
549
+ for (const p of result.modified) lines.push(`M ${p}`);
550
+ let output = lines.length > 0
551
+ ? "Updated the following files:\n" + lines.join("\n")
552
+ : "No files were modified.";
553
+ if (result.warnings.length > 0) {
554
+ output += "\n\n" + result.warnings.join("\n");
555
+ }
556
+ return output;
557
+ }
558
+
559
+ // ═══════════════════════════════════════════════════════════════════════════
560
+ // Helpers
561
+ // ═══════════════════════════════════════════════════════════════════════════
562
+
563
+ function resolveAbsPath(cwd: string, filePath: string): string {
564
+ return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
565
+ }
566
+
567
+ function ensureParentDir(absPath: string): void {
568
+ const dir = path.dirname(absPath);
569
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
570
+ }
571
+
572
+ function detectLineEnding(content: string): string {
573
+ return content.includes("\r\n") ? "\r\n" : "\n";
574
+ }
575
+
576
+ function normalizeLineEndings(text: string): string {
577
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
578
+ }
579
+
580
+ function restoreLineEndings(text: string, ending: string): string {
581
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
582
+ }
583
+
584
+ /** Precompute character offsets for each line start. offsets[i] = char offset of line i+1 (1-based). */
585
+ function buildLineOffsets(content: string): number[] {
586
+ const offsets = [0];
587
+ for (let i = 0; i < content.length; i++) {
588
+ if (content[i] === "\n") offsets.push(i + 1);
589
+ }
590
+ return offsets;
591
+ }
592
+
593
+ /** Binary search: find 1-based line number containing the given char offset.
594
+ * Uses precomputed line offsets instead of scanning from the beginning. */
595
+ function lineAtOffset(lineOffsets: number[], charOffset: number): number {
596
+ let lo = 0, hi = lineOffsets.length - 1;
597
+ while (lo < hi) {
598
+ const mid = (lo + hi + 1) >> 1;
599
+ if (lineOffsets[mid] <= charOffset) lo = mid;
600
+ else hi = mid - 1;
601
+ }
602
+ return lo + 1; // 1-based
603
+ }
604
+
605
+ /** Convert a character offset to a 1-based line number. O(n) — prefer lineAtOffset for repeated calls on same content. */
606
+ function charOffsetToLine(content: string, offset: number): number {
607
+ let line = 1;
608
+ for (let i = 0; i < offset && i < content.length; i++) {
609
+ if (content[i] === "\n") line++;
610
+ }
611
+ return line;
612
+ }
613
+
614
+ function randomId(): string {
615
+ return Math.random().toString(36).slice(2, 10);
616
+ }
617
+
618
+ function truncate(s: string, maxLen = 60): string {
619
+ if (s.length <= maxLen) return s;
620
+ // Show first line only
621
+ const firstLine = s.split("\n")[0];
622
+ if (firstLine.length <= maxLen) return firstLine;
623
+ return firstLine.slice(0, maxLen - 3) + "...";
624
+ }
@@ -25,7 +25,7 @@ const MODELS: ProviderModelConfig[] = [
25
25
  { id: "glm-4.7", name: "GLM 4.7", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
26
26
  { id: "glm-5", name: "GLM 5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
27
27
  { id: "glm-5.1", name: "GLM 5.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202_752, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
28
- { id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262_144, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
28
+ { id: "kimi-k2.5", name: "Kimi K2.5", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 229_376, maxTokens: 262_144, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
29
29
  { id: "minimax-m2.1", name: "MiniMax M2.1", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
30
30
  { id: "minimax-m2.5", name: "MiniMax M2.5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204_800, maxTokens: 131_072, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },
31
31
  { id: "ernie-4.5-turbo-20260402", name: "ERNIE 4.5 Turbo", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 12_288, compat: { supportsDeveloperRole: false, supportsReasoningEffort: true } as any },