@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.3] - 2026-04-27
6
+
7
+ ### Added
8
+
9
+ - Added bracketed `loc` forms `(anchor)`, `[anchor]`, `[anchor`, `(anchor`, `anchor]`, and `anchor)` to `atom` `splice` editing so a single anchor can target a block body, whole node, or partial node region
10
+ - Added automatic block-delimiter inference for block splices using file extension, defaulting to `{` and using `(` for Lisp-family files
11
+ - Added optional `pre`/`post` arguments to the `href` prompt helper so hashline references can be wrapped as bracketed or parenthesized anchors
12
+ - Added destination-aware indent handling for block replacements by detecting file indent style and reapplying tabs/spaces to spliced body text
13
+
14
+ ### Changed
15
+
16
+ - Changed bracketed atom locators to be `splice`-only and reject `pre`, `post`, or `sed` on region locators
17
+ - Changed `applyAtomEdits` to forbid mixing `splice_block` with other anchor-scoped edit verbs in one call
18
+ - Changed `splice_block` resolution behavior to include selected block range and enclosing-count context in warning output
19
+ - Changed balanced-block parsing to support `kind` selection (`{`, `(`, `[`), nesting depth, and safer same-line enclosing selection
20
+
21
+ ### Removed
22
+
23
+ - Removed the `sed` `F` option for literal matching; `sed` now accepts only `pat`, `rep`, and optional `g`, with `F`-style literal matching no longer supported
24
+
25
+ ### Fixed
26
+
27
+ - Fixed `splice_block` multi-line replacements to replace the exact target region and avoid duplicate braces or duplicated signature lines from bare-anchor `splice` attempts
28
+ - Fixed false-positive “unbalanced” replacement-body warnings caused by braces in regex/string/comment text by skipping those constructs during block scanning
29
+ - Fixed `splice_block` for same-line `(` bodies so inline call sites like `int(port)` can be replaced correctly
30
+
31
+ ## [14.5.2] - 2026-04-26
32
+ ### Breaking Changes
33
+
34
+ - Removed support for sed-style string expressions and required `sed` to be specified as an object with `pat` and `rep` (and optional `g`, `F`, `i` flags)
35
+
36
+ ### Changed
37
+
38
+ - Changed atom `sed` replacements to be global by default and require `g:false` for first-match-only replacements
39
+ - Changed anchor validation so multiple `sed` operations can target the same line and run sequentially
40
+ - Changed cross-entry conflict resolution so `del` edits on an anchor are ignored when that line is also replaced by `sed` or `splice` in another edit entry
41
+
42
+ ### Fixed
43
+
44
+ - Fixed zero-length regex `sed` patterns (for example `()`, `^`, `$`) to fall back to literal substring matching instead of producing insertion-like replacements
45
+ - Fixed `sed` chaining so each edit on the same anchor applies to the latest line state from prior replacements
46
+
5
47
  ## [14.5.1] - 2026-04-26
6
48
 
7
49
  ### Removed
@@ -762,6 +804,7 @@
762
804
  - Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata
763
805
  - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits
764
806
  - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
807
+ - Fixed status line dropping git branch name when path is long by shrinking the path segment before dropping other segments
765
808
 
766
809
  ## [13.18.0] - 2026-04-02
767
810
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.5.1",
4
+ "version": "14.5.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.1",
50
- "@oh-my-pi/pi-agent-core": "14.5.1",
51
- "@oh-my-pi/pi-ai": "14.5.1",
52
- "@oh-my-pi/pi-natives": "14.5.1",
53
- "@oh-my-pi/pi-tui": "14.5.1",
54
- "@oh-my-pi/pi-utils": "14.5.1",
49
+ "@oh-my-pi/omp-stats": "14.5.3",
50
+ "@oh-my-pi/pi-agent-core": "14.5.3",
51
+ "@oh-my-pi/pi-ai": "14.5.3",
52
+ "@oh-my-pi/pi-natives": "14.5.3",
53
+ "@oh-my-pi/pi-tui": "14.5.3",
54
+ "@oh-my-pi/pi-utils": "14.5.3",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -69,7 +69,7 @@
69
69
  "zod": "4.3.6"
70
70
  },
71
71
  "devDependencies": {
72
- "@types/bun": "^1.3.13",
72
+ "@types/bun": "^1.3",
73
73
  "@types/turndown": "5.0.6"
74
74
  },
75
75
  "engines": {
@@ -45,11 +45,14 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
45
45
 
46
46
  /**
47
47
  * {{href lineNum "content"}} — compute a real hashline ref for prompt examples.
48
- * Returns `"lineNumBIGRAM"` (e.g., `"42nd"`) using the actual hash algorithm.
48
+ * {{href lineNum "content" "[" "]"}} wrap the ref with pre/post chars (still quoted).
49
+ * Returns `"lineNumBIGRAM"` (e.g., `"42nd"`), or `"[42nd]"` when pre/post are supplied.
49
50
  */
50
- prompt.registerHelper("href", (lineNum: unknown, content: unknown): string => {
51
+ prompt.registerHelper("href", (lineNum: unknown, content: unknown, pre?: unknown, post?: unknown): string => {
51
52
  const { ref } = formatHashlineRef(lineNum, content);
52
- return JSON.stringify(ref);
53
+ const preStr = typeof pre === "string" ? pre : "";
54
+ const postStr = typeof post === "string" ? post : "";
55
+ return JSON.stringify(`${preStr}${ref}${postStr}`);
53
56
  });
54
57
 
55
58
  /**
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Block-balanced delimiter finder used by the `splice_block` verb.
3
+ *
4
+ * Tokenizes source text to skip strings and comments, then walks a stack of
5
+ * open delimiters to identify the enclosing balanced block for a target line.
6
+ *
7
+ * This is intentionally language-agnostic over the C-family (C, C++, Rust,
8
+ * Go, Java, JS/TS, C#, Swift, Kotlin, Scala, …): it understands `// line`,
9
+ * `/* block * /` comments, double-quoted, single-quoted, and backtick strings
10
+ * with backslash escapes. It does NOT attempt to parse raw string literals,
11
+ * Python triple-quoted strings, or YAML/Python indent-significant blocks —
12
+ * those are out of scope for v1.
13
+ */
14
+
15
+ export type DelimiterKind = "{" | "(" | "[";
16
+
17
+ export interface BlockRange {
18
+ /** Byte/character offset of the opening delimiter. */
19
+ openOffset: number;
20
+ /** Byte/character offset just after the closing delimiter. */
21
+ closeOffsetExclusive: number;
22
+ /** Offset of first character after the opener (start of body). */
23
+ bodyStart: number;
24
+ /** Offset of the closing delimiter character. */
25
+ bodyEnd: number;
26
+ /** 1-indexed line number of the opener. */
27
+ openLine: number;
28
+ /** Byte/character offset of the opener line start. */
29
+ openLineStart: number;
30
+ /** 1-indexed line number of the closer. */
31
+ closeLine: number;
32
+ /** True when opener and closer are on the same line. */
33
+ sameLine: boolean;
34
+ /** Whitespace prefix of the opener's line. */
35
+ openerLineIndent: string;
36
+ /**
37
+ * Whitespace prefix of the first non-blank body line, or `null` when the
38
+ * body has no non-blank line.
39
+ */
40
+ bodyLineIndent: string | null;
41
+ /** Body text exactly as it appears between the delimiters. */
42
+ bodyText: string;
43
+ /** Total enclosing blocks of the requested kind before depth selection. */
44
+ enclosingCount: number;
45
+ }
46
+
47
+ interface BraceEvent {
48
+ kind: DelimiterKind | ")" | "]" | "}";
49
+ offset: number;
50
+ }
51
+
52
+ const OPENERS: Record<DelimiterKind, string> = { "{": "{", "(": "(", "[": "[" };
53
+ const CLOSERS: Record<DelimiterKind, string> = { "{": "}", "(": ")", "[": "]" };
54
+
55
+ /**
56
+ * Walk `text` and emit positions of opening and closing delimiters that lie
57
+ * outside strings and comments.
58
+ */
59
+ export function scanDelimiters(text: string): BraceEvent[] {
60
+ const out: BraceEvent[] = [];
61
+ const len = text.length;
62
+ let i = 0;
63
+ while (i < len) {
64
+ const ch = text[i]!;
65
+ // Line comment `// …` to end of line.
66
+ if (ch === "/" && text[i + 1] === "/") {
67
+ i += 2;
68
+ while (i < len && text[i] !== "\n") i++;
69
+ continue;
70
+ }
71
+ // Hash line comment `# …` for shell/Python-like — but only when at start
72
+ // of a token, to avoid mangling C preprocessor lines (`#include`). We
73
+ // treat any `#` at column 0 or after whitespace as a line comment, which
74
+ // is a heuristic that's also fine for `#include` (no braces follow on
75
+ // the same line in practice for our use case).
76
+ if (ch === "#" && (i === 0 || text[i - 1] === "\n" || text[i - 1] === " " || text[i - 1] === "\t")) {
77
+ // Not enabled: too aggressive for C/C++/Rust files. Skip.
78
+ }
79
+ // Block comment `/* … */`.
80
+ if (ch === "/" && text[i + 1] === "*") {
81
+ i += 2;
82
+ while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
83
+ if (i < len) i += 2;
84
+ continue;
85
+ }
86
+ // String literals: ", ', `. Backslash-escape aware.
87
+ if (ch === '"' || ch === "'" || ch === "`") {
88
+ const quote = ch;
89
+ i++;
90
+ while (i < len) {
91
+ const c = text[i]!;
92
+ if (c === "\\") {
93
+ i += 2;
94
+ continue;
95
+ }
96
+ if (c === quote) {
97
+ i++;
98
+ break;
99
+ }
100
+ if (c === "\n" && (quote === '"' || quote === "'")) {
101
+ // Unterminated string; stop scanning this literal so we
102
+ // don't swallow the rest of the file.
103
+ break;
104
+ }
105
+ i++;
106
+ }
107
+ continue;
108
+ }
109
+ if (ch === "{" || ch === "(" || ch === "[") {
110
+ out.push({ kind: ch, offset: i });
111
+ } else if (ch === "}" || ch === ")" || ch === "]") {
112
+ out.push({ kind: ch, offset: i });
113
+ }
114
+ i++;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ interface OpenFrame {
120
+ kind: DelimiterKind;
121
+ offset: number;
122
+ }
123
+
124
+ /**
125
+ * Build a list of balanced (open, close) ranges by walking the events from
126
+ * `scanDelimiters`. Mismatched closers are skipped (the file may be partially
127
+ * malformed), and unclosed openers at EOF are dropped.
128
+ */
129
+ function pairBlocks(events: BraceEvent[]): { open: OpenFrame; closeOffset: number }[] {
130
+ const stack: OpenFrame[] = [];
131
+ const pairs: { open: OpenFrame; closeOffset: number }[] = [];
132
+ for (const ev of events) {
133
+ if (ev.kind === "{" || ev.kind === "(" || ev.kind === "[") {
134
+ stack.push({ kind: ev.kind, offset: ev.offset });
135
+ continue;
136
+ }
137
+ // Closer.
138
+ const expected: DelimiterKind | null =
139
+ ev.kind === "}" ? "{" : ev.kind === ")" ? "(" : ev.kind === "]" ? "[" : null;
140
+ if (!expected) continue;
141
+ // Pop until we find the matching opener, but only commit pairs when
142
+ // kinds match. This tolerates small skews from raw strings or other
143
+ // unsupported constructs without exploding the search.
144
+ const top = stack[stack.length - 1];
145
+ if (top?.kind === expected) {
146
+ stack.pop();
147
+ pairs.push({ open: top, closeOffset: ev.offset });
148
+ }
149
+ }
150
+ return pairs;
151
+ }
152
+
153
+ function lineToOffset(text: string, line: number): number {
154
+ let n = 1;
155
+ let i = 0;
156
+ while (i < text.length && n < line) {
157
+ if (text[i] === "\n") n++;
158
+ i++;
159
+ }
160
+ return i;
161
+ }
162
+
163
+ function offsetToLine(text: string, offset: number): number {
164
+ let n = 1;
165
+ for (let i = 0; i < offset && i < text.length; i++) {
166
+ if (text[i] === "\n") n++;
167
+ }
168
+ return n;
169
+ }
170
+
171
+ function lineIndentAt(text: string, lineNumber: number): string {
172
+ const start = lineToOffset(text, lineNumber);
173
+ let i = start;
174
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
175
+ return text.slice(start, i);
176
+ }
177
+
178
+ function extractRange(text: string, start: number, end: number): string {
179
+ return text.slice(start, end);
180
+ }
181
+
182
+ export interface FindBlockOptions {
183
+ kind?: DelimiterKind;
184
+ depth?: number;
185
+ }
186
+
187
+ export interface FindBlockError {
188
+ message: string;
189
+ }
190
+
191
+ /**
192
+ * Find the enclosing balanced block of `kind` containing `targetLine`
193
+ * (1-indexed), at the requested ancestor `depth` (0 = innermost).
194
+ *
195
+ * Returns an error object when no such block exists.
196
+ */
197
+ export function findEnclosingBlock(
198
+ text: string,
199
+ targetLine: number,
200
+ options: FindBlockOptions = {},
201
+ ): BlockRange | FindBlockError {
202
+ const kind: DelimiterKind = options.kind ?? "{";
203
+ const depth = Math.max(0, Math.floor(options.depth ?? 0));
204
+
205
+ const events = scanDelimiters(text);
206
+ const pairs = pairBlocks(events);
207
+
208
+ // Lines (1-indexed) that bracket the target are considered to contain it.
209
+ // This handles same-line `{ x }` blocks too (openLine == closeLine ==
210
+ // targetLine).
211
+ const enclosing = pairs
212
+ .filter(p => p.open.kind === kind)
213
+ .map(p => ({
214
+ open: p.open,
215
+ closeOffset: p.closeOffset,
216
+ openLine: offsetToLine(text, p.open.offset),
217
+ closeLine: offsetToLine(text, p.closeOffset),
218
+ }))
219
+ .filter(p => p.openLine <= targetLine && targetLine <= p.closeLine);
220
+ if (enclosing.length === 0) {
221
+ return {
222
+ message: `No enclosing \`${kind}\` block contains line ${targetLine}.`,
223
+ };
224
+ }
225
+ // Default ordering is innermost first (largest open offset among containers).
226
+ // When both candidates are entirely on the target line, prefer the outermost
227
+ // same-line block so anchoring a call line targets the containing call before
228
+ // nested argument calls such as `int(port)`. Multi-line nesting keeps the
229
+ // existing innermost-first behavior.
230
+ enclosing.sort((a, b) => {
231
+ const aSingle = a.openLine === targetLine && a.closeLine === targetLine;
232
+ const bSingle = b.openLine === targetLine && b.closeLine === targetLine;
233
+ if (aSingle && bSingle) return a.open.offset - b.open.offset;
234
+ return b.open.offset - a.open.offset;
235
+ });
236
+ if (depth >= enclosing.length) {
237
+ return {
238
+ message: `Requested depth ${depth} exceeds available enclosing \`${kind}\` blocks (${enclosing.length}).`,
239
+ };
240
+ }
241
+ const chosen = enclosing[depth]!;
242
+ const openOffset = chosen.open.offset;
243
+ const closeOffset = chosen.closeOffset;
244
+ const bodyStart = openOffset + 1;
245
+ const bodyEnd = closeOffset;
246
+ const openLine = chosen.openLine;
247
+ const closeLine = chosen.closeLine;
248
+ const openLineStart = lineToOffset(text, openLine);
249
+ const openerLineIndent = lineIndentAt(text, openLine);
250
+ const bodyText = extractRange(text, bodyStart, bodyEnd);
251
+ const bodyLineIndent = computeBodyLineIndent(text, bodyStart, bodyEnd);
252
+ return {
253
+ openOffset,
254
+ closeOffsetExclusive: closeOffset + 1,
255
+ bodyStart,
256
+ bodyEnd,
257
+ openLine,
258
+ openLineStart,
259
+ closeLine,
260
+ sameLine: openLine === closeLine,
261
+ openerLineIndent,
262
+ bodyLineIndent,
263
+ bodyText,
264
+ enclosingCount: enclosing.length,
265
+ };
266
+ }
267
+
268
+ function computeBodyLineIndent(text: string, bodyStart: number, bodyEnd: number): string | null {
269
+ // Scan body for the first line whose non-whitespace character lives within
270
+ // [bodyStart, bodyEnd). Return that line's leading whitespace prefix.
271
+ let i = bodyStart;
272
+ // Step over the rest of the opener's line (it may contain trailing
273
+ // whitespace but not body content we want to use as the indent reference).
274
+ while (i < bodyEnd && text[i] !== "\n") i++;
275
+ while (i < bodyEnd) {
276
+ // At line boundary; skip the newline.
277
+ if (text[i] === "\n") i++;
278
+ const lineStart = i;
279
+ while (i < bodyEnd && (text[i] === " " || text[i] === "\t")) i++;
280
+ // Skip blank lines.
281
+ if (i < bodyEnd && text[i] !== "\n") {
282
+ return text.slice(lineStart, i);
283
+ }
284
+ // Skip to end of line.
285
+ while (i < bodyEnd && text[i] !== "\n") i++;
286
+ }
287
+ return null;
288
+ }
289
+
290
+ /**
291
+ * Verify that the agent's body has balanced delimiters of `kind`. Returns an
292
+ * error message when unbalanced, or `null` when fine.
293
+ */
294
+ export function checkBodyBraceBalance(body: string, kind: DelimiterKind): string | null {
295
+ const events = scanDelimiters(body);
296
+ let opens = 0;
297
+ let closes = 0;
298
+ const opener = OPENERS[kind];
299
+ const closer = CLOSERS[kind];
300
+ for (const e of events) {
301
+ if (e.kind === opener) opens++;
302
+ else if (e.kind === closer) closes++;
303
+ }
304
+ if (opens !== closes) {
305
+ return `Replacement body has unbalanced \`${opener}\`/\`${closer}\` (open=${opens}, close=${closes}).`;
306
+ }
307
+ return null;
308
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Indent helpers used by `splice_block`.
3
+ *
4
+ * Pure functions: take strings/lines, return strings/lines. No I/O. Designed
5
+ * to be easy to unit-test in isolation.
6
+ */
7
+
8
+ export interface IndentStyle {
9
+ kind: "tab" | "space";
10
+ width: number;
11
+ }
12
+
13
+ const SAMPLE_LINES_FOR_DETECTION = 256;
14
+
15
+ /**
16
+ * Detect whether the file uses tab or space indentation, and the indent width
17
+ * for spaces. Sampling-based; defaults to {kind: "tab", width: 1} when nothing
18
+ * is conclusive.
19
+ */
20
+ export function detectIndentStyle(text: string): IndentStyle {
21
+ const lines = text.split("\n", SAMPLE_LINES_FOR_DETECTION + 1).slice(0, SAMPLE_LINES_FOR_DETECTION);
22
+ let tabIndented = 0;
23
+ let spaceIndented = 0;
24
+ const spaceWidthCounts = new Map<number, number>();
25
+ for (const line of lines) {
26
+ if (line.length === 0) continue;
27
+ const ch0 = line[0];
28
+ if (ch0 === "\t") {
29
+ tabIndented++;
30
+ continue;
31
+ }
32
+ if (ch0 !== " ") continue;
33
+ let count = 0;
34
+ while (count < line.length && line[count] === " ") count++;
35
+ // Skip lines whose entire content is whitespace (no signal).
36
+ if (count === line.length) continue;
37
+ spaceIndented++;
38
+ // Record indent step. Try common values 2, 4, 8 by GCD-ish.
39
+ spaceWidthCounts.set(count, (spaceWidthCounts.get(count) ?? 0) + 1);
40
+ }
41
+ if (tabIndented > spaceIndented) return { kind: "tab", width: 1 };
42
+ if (spaceIndented === 0) return { kind: "tab", width: 1 };
43
+
44
+ // Pick the most common nonzero indent width that divides the others well.
45
+ const candidates = [2, 4, 8];
46
+ let bestCandidate = 4;
47
+ let bestScore = -1;
48
+ for (const cand of candidates) {
49
+ let score = 0;
50
+ for (const [width, count] of spaceWidthCounts) {
51
+ if (width % cand === 0) score += count;
52
+ }
53
+ if (score > bestScore) {
54
+ bestScore = score;
55
+ bestCandidate = cand;
56
+ }
57
+ }
58
+ return { kind: "space", width: bestCandidate };
59
+ }
60
+
61
+ /**
62
+ * Strip the common leading whitespace prefix from every non-empty line.
63
+ * Blank/whitespace-only lines pass through unchanged.
64
+ *
65
+ * Tab and space whitespace count as a single character each here; we look at
66
+ * raw prefix bytes. The block executor re-applies destination indent later,
67
+ * so tab/space mismatches in the agent's own input are normalized by
68
+ * `applyIndent` rather than here.
69
+ */
70
+ export function stripCommonIndent(lines: readonly string[]): string[] {
71
+ let common: string | null = null;
72
+ for (const line of lines) {
73
+ if (line.trim().length === 0) continue;
74
+ const m = /^[\t ]*/.exec(line);
75
+ const prefix = m ? m[0] : "";
76
+ if (common === null) {
77
+ common = prefix;
78
+ continue;
79
+ }
80
+ // Reduce to the longest shared prefix.
81
+ let i = 0;
82
+ const limit = Math.min(common.length, prefix.length);
83
+ while (i < limit && common[i] === prefix[i]) i++;
84
+ common = common.slice(0, i);
85
+ if (common.length === 0) break;
86
+ }
87
+ if (!common) return [...lines];
88
+ return lines.map(line => (line.startsWith(common!) ? line.slice(common!.length) : line));
89
+ }
90
+
91
+ /**
92
+ * Re-index leading whitespace of `lines` from the source style to the
93
+ * destination style, then prepend `prefix` to every non-empty line.
94
+ *
95
+ * - Source style is detected from the lines themselves.
96
+ * - Tab→space and space→tab conversion is applied to the *relative*
97
+ * indentation (everything past the common prefix has already been stripped
98
+ * by `stripCommonIndent`, so all leading whitespace here is "extra" indent).
99
+ * - Blank lines stay empty (no trailing whitespace).
100
+ */
101
+ export function applyIndent(lines: readonly string[], prefix: string, destStyle: IndentStyle): string[] {
102
+ const sourceStyle = detectIndentStyle(lines.join("\n"));
103
+ return lines.map(line => {
104
+ if (line.trim().length === 0) return "";
105
+ const m = /^[\t ]*/.exec(line);
106
+ const leading = m ? m[0] : "";
107
+ const rest = line.slice(leading.length);
108
+ const normalized = normalizeIndent(leading, sourceStyle, destStyle);
109
+ return prefix + normalized + rest;
110
+ });
111
+ }
112
+
113
+ function normalizeIndent(leading: string, source: IndentStyle, dest: IndentStyle): string {
114
+ if (leading.length === 0) return leading;
115
+ // Compute total visual columns of the leading run, treating tabs in the
116
+ // source as `source.width` columns (or 1 column for tab-indented files,
117
+ // where the destination decides the visible width).
118
+ let columns = 0;
119
+ for (const ch of leading) {
120
+ if (ch === "\t") {
121
+ // Treat a source tab as one indent unit. Width = source.width or
122
+ // fallback 4 when source is tab style.
123
+ columns += source.kind === "tab" ? 1 : Math.max(1, Math.floor(source.width));
124
+ } else {
125
+ columns += 1;
126
+ }
127
+ }
128
+ if (dest.kind === "tab") {
129
+ // Convert visual columns to whole tabs. When source was space-indented,
130
+ // columns is in spaces; divide by source.width to get logical levels.
131
+ let levels: number;
132
+ if (source.kind === "tab") {
133
+ levels = columns;
134
+ } else {
135
+ const w = Math.max(1, source.width);
136
+ levels = Math.round(columns / w);
137
+ }
138
+ return "\t".repeat(Math.max(0, levels));
139
+ }
140
+ // Destination is spaces. Convert to dest.width columns per level.
141
+ let levels: number;
142
+ if (source.kind === "tab") {
143
+ levels = columns; // tabs were 1 column each here
144
+ } else {
145
+ const w = Math.max(1, source.width);
146
+ levels = Math.round(columns / w);
147
+ }
148
+ const out = " ".repeat(Math.max(0, levels) * Math.max(1, dest.width));
149
+ return out;
150
+ }