agent-sh 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,829 @@
1
+ /**
2
+ * Diff renderer with width-adaptive presentation modes and inline highlighting.
3
+ *
4
+ * Returns string[] (one per terminal line) — never writes to stdout.
5
+ * Supports unified, split (side-by-side), and summary modes.
6
+ * Uses token-level LCS for word-level inline diff highlighting.
7
+ */
8
+ import { highlight } from "cli-highlight";
9
+ import type { DiffResult, DiffHunk, DiffLine } from "./diff.js";
10
+ import { visibleLen, charWidth } from "./ansi.js";
11
+ import { palette as p } from "./palette.js";
12
+ import { wrapLine } from "./markdown.js";
13
+
14
+ // ── Types ────────────────────────────────────────────────────────
15
+
16
+ export type DiffDisplayMode = "split" | "unified" | "summary";
17
+
18
+ export interface DiffRenderOptions {
19
+ /** Available terminal width (columns). */
20
+ width: number;
21
+ /** Force a specific display mode instead of auto-detecting from width. */
22
+ mode?: DiffDisplayMode;
23
+ /** Maximum number of output lines before truncation. Default 50. */
24
+ maxLines?: number;
25
+ /** File path to show in the header (also used to detect language for syntax highlighting). */
26
+ filePath?: string;
27
+ /** Use true-color (24-bit) backgrounds. Default true. */
28
+ trueColor?: boolean;
29
+ /** Enable syntax highlighting on diff lines. Default true. */
30
+ syntaxHighlight?: boolean;
31
+ /** Draw the `│` rule between the line number and the code (default true). Set false
32
+ * for a flush gutter: `<n> <sigil><code>`, the row background spans the line, and
33
+ * context code is left un-dimmed. */
34
+ gutterLine?: boolean;
35
+ }
36
+
37
+ // ── Constants ────────────────────────────────────────────────────
38
+
39
+ const SPLIT_MIN_WIDTH = 120;
40
+ const UNIFIED_MIN_WIDTH = 40;
41
+
42
+ // ── Syntax highlighting ──────────────────────────────────────────
43
+
44
+ const EXT_TO_LANG: Record<string, string> = {
45
+ ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript",
46
+ ".py": "python", ".rb": "ruby", ".rs": "rust", ".go": "go", ".java": "java",
47
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp",
48
+ ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
49
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash", ".fish": "bash",
50
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "ini",
51
+ ".xml": "xml", ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss",
52
+ ".sql": "sql", ".md": "markdown", ".lua": "lua", ".php": "php",
53
+ ".ex": "elixir", ".exs": "elixir", ".erl": "erlang",
54
+ ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure",
55
+ ".vim": "vim", ".dockerfile": "dockerfile",
56
+ };
57
+
58
+ export function detectLanguage(filePath?: string): string | undefined {
59
+ if (!filePath) return undefined;
60
+ const dot = filePath.lastIndexOf(".");
61
+ if (dot === -1) {
62
+ // Handle extensionless files like Dockerfile, Makefile
63
+ const base = filePath.split("/").pop()?.toLowerCase();
64
+ if (base === "dockerfile") return "dockerfile";
65
+ if (base === "makefile") return "makefile";
66
+ return undefined;
67
+ }
68
+ return EXT_TO_LANG[filePath.slice(dot).toLowerCase()];
69
+ }
70
+
71
+ /**
72
+ * Syntax-highlight a single line of code.
73
+ * Returns the original text if highlighting fails or no language detected.
74
+ */
75
+ export function highlightLine(text: string, language?: string): string {
76
+ if (!language || text.trim() === "") return text;
77
+ try {
78
+ // cli-highlight adds a trailing newline; strip it
79
+ return highlight(text, { language }).replace(/\n$/, "");
80
+ } catch {
81
+ return text;
82
+ }
83
+ }
84
+
85
+ // ── Token-level LCS for inline highlighting ──────────────────────
86
+
87
+ interface Token {
88
+ text: string;
89
+ kind: "word" | "space" | "punct";
90
+ }
91
+
92
+ function tokenize(line: string): Token[] {
93
+ const tokens: Token[] = [];
94
+ const re = /(\s+)|([A-Za-z0-9_]+)|([^\s\w])/g;
95
+ let m: RegExpExecArray | null;
96
+ while ((m = re.exec(line)) !== null) {
97
+ if (m[1]) tokens.push({ text: m[1], kind: "space" });
98
+ else if (m[2]) tokens.push({ text: m[2], kind: "word" });
99
+ else if (m[3]) tokens.push({ text: m[3], kind: "punct" });
100
+ }
101
+ return tokens;
102
+ }
103
+
104
+ function tokenLcs(
105
+ a: Token[],
106
+ b: Token[],
107
+ ): { oldMatch: boolean[]; newMatch: boolean[] } {
108
+ const m = a.length;
109
+ const n = b.length;
110
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
111
+ new Array<number>(n + 1).fill(0),
112
+ );
113
+ for (let i = 1; i <= m; i++) {
114
+ for (let j = 1; j <= n; j++) {
115
+ dp[i][j] =
116
+ a[i - 1].text === b[j - 1].text
117
+ ? dp[i - 1][j - 1] + 1
118
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
119
+ }
120
+ }
121
+
122
+ // Backtrack to mark matched tokens
123
+ const oldMatch = new Array<boolean>(m).fill(false);
124
+ const newMatch = new Array<boolean>(n).fill(false);
125
+ let i = m;
126
+ let j = n;
127
+ while (i > 0 && j > 0) {
128
+ if (a[i - 1].text === b[j - 1].text) {
129
+ oldMatch[i - 1] = true;
130
+ newMatch[j - 1] = true;
131
+ i--;
132
+ j--;
133
+ } else if (dp[i - 1][j] >= dp[i][j - 1]) {
134
+ i--;
135
+ } else {
136
+ j--;
137
+ }
138
+ }
139
+
140
+ return { oldMatch, newMatch };
141
+ }
142
+
143
+ /**
144
+ * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
145
+ * preserving the given background color across the line.
146
+ */
147
+ function preserveBg(text: string, bg: string): string {
148
+ return text.replace(/\x1b\[0m/g, `\x1b[39m${bg}`);
149
+ }
150
+
151
+ /**
152
+ * Pad a rendered line with spaces to fill the given visible width,
153
+ * ensuring background color spans the full column.
154
+ */
155
+ function padToWidth(text: string, targetWidth: number): string {
156
+ const vis = visibleLen(text);
157
+ if (vis >= targetWidth) return text;
158
+ return text + " ".repeat(targetWidth - vis);
159
+ }
160
+
161
+ interface InlinePalette {
162
+ rowBg: string;
163
+ emphBg: string;
164
+ }
165
+
166
+ function highlightInlineChanges(
167
+ oldLine: string,
168
+ newLine: string,
169
+ oldPalette: InlinePalette,
170
+ newPalette: InlinePalette,
171
+ useTrueColor: boolean,
172
+ language?: string,
173
+ ): { old: string; new: string } {
174
+ if (!useTrueColor) {
175
+ // Still apply syntax highlighting even without true-color backgrounds
176
+ if (language) {
177
+ return {
178
+ old: highlightLine(oldLine, language),
179
+ new: highlightLine(newLine, language),
180
+ };
181
+ }
182
+ return { old: oldLine, new: newLine };
183
+ }
184
+
185
+ const oldTokens = tokenize(oldLine);
186
+ const newTokens = tokenize(newLine);
187
+
188
+ // Skip if either side is trivially small
189
+ if (oldTokens.length === 0 || newTokens.length === 0) {
190
+ return {
191
+ old: language ? highlightLine(oldLine, language) : oldLine,
192
+ new: language ? highlightLine(newLine, language) : newLine,
193
+ };
194
+ }
195
+
196
+ // Safety guard: skip if LCS matrix would be too large
197
+ if (oldTokens.length * newTokens.length > 50000) {
198
+ return {
199
+ old: language ? highlightLine(oldLine, language) : oldLine,
200
+ new: language ? highlightLine(newLine, language) : newLine,
201
+ };
202
+ }
203
+
204
+ const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
205
+
206
+ const buildHighlighted = (
207
+ tokens: Token[],
208
+ matched: boolean[],
209
+ palette: InlinePalette,
210
+ ): string => {
211
+ let result = "";
212
+ for (let i = 0; i < tokens.length; i++) {
213
+ if (matched[i]) {
214
+ // Matched (unchanged) tokens: syntax highlight + row background
215
+ const text = language ? highlightLine(tokens[i].text, language) : tokens[i].text;
216
+ result += palette.rowBg + preserveBg(text, palette.rowBg);
217
+ } else {
218
+ // Changed tokens: emphasis background, no syntax highlighting (emphasis stands out)
219
+ result += palette.emphBg + p.bold + tokens[i].text + p.reset;
220
+ }
221
+ }
222
+ return result;
223
+ };
224
+
225
+ return {
226
+ old: buildHighlighted(oldTokens, oldMatch, oldPalette),
227
+ new: buildHighlighted(newTokens, newMatch, newPalette),
228
+ };
229
+ }
230
+
231
+ // ── Change pair detection ────────────────────────────────────────
232
+
233
+ interface ChangePair {
234
+ removed: DiffLine;
235
+ added: DiffLine;
236
+ removedIdx: number;
237
+ addedIdx: number;
238
+ }
239
+
240
+ /**
241
+ * Scan a hunk for adjacent removed/added runs and pair them 1:1.
242
+ * Returns a set of line indices that are part of a change pair.
243
+ */
244
+ function findChangePairs(hunk: DiffHunk): Map<number, ChangePair> {
245
+ const pairs = new Map<number, ChangePair>();
246
+ const lines = hunk.lines;
247
+ let i = 0;
248
+
249
+ while (i < lines.length) {
250
+ const removedStart = i;
251
+ while (i < lines.length && lines[i].type === "removed") i++;
252
+ const removedEnd = i;
253
+
254
+ const addedStart = i;
255
+ while (i < lines.length && lines[i].type === "added") i++;
256
+ const addedEnd = i;
257
+
258
+ const removedCount = removedEnd - removedStart;
259
+ const addedCount = addedEnd - addedStart;
260
+ const pairCount = Math.min(removedCount, addedCount);
261
+
262
+ for (let k = 0; k < pairCount; k++) {
263
+ const pair: ChangePair = {
264
+ removed: lines[removedStart + k],
265
+ added: lines[addedStart + k],
266
+ removedIdx: removedStart + k,
267
+ addedIdx: addedStart + k,
268
+ };
269
+ pairs.set(removedStart + k, pair);
270
+ pairs.set(addedStart + k, pair);
271
+ }
272
+
273
+ // If no removed/added run was found, advance past context lines
274
+ if (removedCount === 0 && addedCount === 0) {
275
+ i++;
276
+ }
277
+ }
278
+
279
+ return pairs;
280
+ }
281
+
282
+ // ── Header ───────────────────────────────────────────────────────
283
+
284
+ function buildHeader(diff: DiffResult, filePath?: string): string {
285
+ const path = filePath ?? "";
286
+ if (diff.isNewFile) {
287
+ return `${p.bold}new: ${path}${p.reset} ${p.dim}(+${diff.added} lines)${p.reset}`;
288
+ }
289
+ return `${p.bold}${path}${p.reset} ${p.dim}(+${diff.added} / -${diff.removed})${p.reset}`;
290
+ }
291
+
292
+ // ── Summary mode ─────────────────────────────────────────────────
293
+
294
+ function renderSummary(diff: DiffResult): string[] {
295
+ if (diff.isIdentical) return [`${p.dim}(no changes)${p.reset}`];
296
+ if (diff.isNewFile) return [`${p.success}+${diff.added} lines${p.reset} ${p.dim}(new file)${p.reset}`];
297
+ return [`${p.success}+${diff.added}${p.reset} ${p.error}-${diff.removed}${p.reset}`];
298
+ }
299
+
300
+ // ── Unified mode ─────────────────────────────────────────────────
301
+
302
+ interface UnifiedLayout {
303
+ noW: number;
304
+ lineTextW: number;
305
+ textWidth: number;
306
+ useTrueColor: boolean;
307
+ gutterLine: boolean;
308
+ lang: string | undefined;
309
+ removedPalette: InlinePalette;
310
+ addedPalette: InlinePalette;
311
+ }
312
+
313
+ function unifiedLayout(diff: DiffResult, opts: DiffRenderOptions): UnifiedLayout {
314
+ const textWidth = opts.width;
315
+ let maxNo = 0;
316
+ for (const hunk of diff.hunks) {
317
+ for (const line of hunk.lines) {
318
+ const n = line.oldNo ?? line.newNo ?? 0;
319
+ if (n > maxNo) maxNo = n;
320
+ }
321
+ }
322
+ const noW = Math.max(String(maxNo).length, 1);
323
+ return {
324
+ noW,
325
+ lineTextW: Math.max(1, textWidth - noW - 5),
326
+ textWidth,
327
+ useTrueColor: opts.trueColor !== false,
328
+ gutterLine: opts.gutterLine !== false,
329
+ lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
330
+ removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
331
+ addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
332
+ };
333
+ }
334
+
335
+ function renderUnifiedHunk(hunk: DiffHunk, layout: UnifiedLayout): string[] {
336
+ const { noW, lineTextW, textWidth, useTrueColor, gutterLine, lang, removedPalette, addedPalette } = layout;
337
+ const out: string[] = [];
338
+
339
+ const pairs = findChangePairs(hunk);
340
+ const renderedAsPartOfPair = new Set<number>();
341
+ const bgWidth = Math.max(1, textWidth - noW - 3);
342
+ const gutter = (n: string): string => `${p.dim}${n} │${p.reset} `;
343
+
344
+ const change = (no: string, sigil: string, bg: string, fg: string, text: string): string => {
345
+ if (!gutterLine) {
346
+ return `${bg}${fg}${padToWidth(`${no} ${sigil} ${preserveBg(text, bg)}`, textWidth)}${p.reset}`;
347
+ }
348
+ if (useTrueColor) return gutter(no) + padToWidth(`${bg}${fg}${sigil} ${preserveBg(text, bg)}`, bgWidth) + p.reset;
349
+ return `${gutter(no)}${fg}${sigil} ${text}${p.reset}`;
350
+ };
351
+
352
+ for (let i = 0; i < hunk.lines.length; i++) {
353
+ const line = hunk.lines[i];
354
+ const no = String(
355
+ line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? ""),
356
+ ).padStart(noW);
357
+
358
+ if (line.type === "context") {
359
+ const raw = truncateText(line.text, lineTextW);
360
+ const text = lang ? highlightLine(raw, lang) : raw;
361
+ // The flush gutter dims only the line number; the code stays normal/highlighted.
362
+ out.push(!gutterLine ? `${p.dim}${no}${p.reset} ${text}` : `${gutter(no)} ${p.dim}${text}${p.reset}`);
363
+ continue;
364
+ }
365
+
366
+ if (line.type === "removed") {
367
+ const pair = pairs.get(i);
368
+ let removedText: string;
369
+ let addedText: string | null = null;
370
+ let addedNo: string | null = null;
371
+
372
+ if (pair && pair.removedIdx === i) {
373
+ const highlighted = highlightInlineChanges(
374
+ line.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang,
375
+ );
376
+ removedText = truncateText(highlighted.old, lineTextW);
377
+ addedText = truncateText(highlighted.new, lineTextW);
378
+ addedNo = String(pair.added.newNo ?? "").padStart(noW);
379
+ renderedAsPartOfPair.add(pair.addedIdx);
380
+ } else {
381
+ const raw = truncateText(line.text, lineTextW);
382
+ removedText = lang ? highlightLine(raw, lang) : raw;
383
+ }
384
+
385
+ out.push(change(no, "-", p.errorBg, p.error, removedText));
386
+ if (addedText !== null && addedNo !== null) {
387
+ out.push(change(addedNo, "+", p.successBg, p.success, addedText));
388
+ }
389
+ continue;
390
+ }
391
+
392
+ if (line.type === "added") {
393
+ if (renderedAsPartOfPair.has(i)) continue;
394
+ const raw = truncateText(line.text, lineTextW);
395
+ const text = lang ? highlightLine(raw, lang) : raw;
396
+ out.push(change(no, "+", p.successBg, p.success, text));
397
+ }
398
+ }
399
+ return out;
400
+ }
401
+
402
+ function renderUnified(diff: DiffResult, opts: DiffRenderOptions): string[] {
403
+ const layout = unifiedLayout(diff, opts);
404
+ const output: string[] = [];
405
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
406
+ if (hunkIdx > 0) output.push(` ${p.dim}⋯${p.reset}`);
407
+ output.push(...renderUnifiedHunk(diff.hunks[hunkIdx], layout));
408
+ }
409
+ return output;
410
+ }
411
+
412
+ // ── Split (side-by-side) mode ────────────────────────────────────
413
+
414
+ interface SplitLayout {
415
+ colWidth: number;
416
+ noW: number;
417
+ textW: number;
418
+ useTrueColor: boolean;
419
+ lang: string | undefined;
420
+ removedPalette: InlinePalette;
421
+ addedPalette: InlinePalette;
422
+ }
423
+
424
+ function splitLayout(diff: DiffResult, opts: DiffRenderOptions): SplitLayout {
425
+ const totalWidth = opts.width;
426
+ const colWidth = Math.max(1, Math.floor((totalWidth - 3) / 2));
427
+ let maxNo = 0;
428
+ for (const hunk of diff.hunks) {
429
+ for (const line of hunk.lines) {
430
+ const n = line.oldNo ?? line.newNo ?? 0;
431
+ if (n > maxNo) maxNo = n;
432
+ }
433
+ }
434
+ const noW = Math.max(String(maxNo).length, 1);
435
+ return {
436
+ colWidth,
437
+ noW,
438
+ textW: Math.max(1, colWidth - noW - 3),
439
+ useTrueColor: opts.trueColor !== false,
440
+ lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
441
+ removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
442
+ addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
443
+ };
444
+ }
445
+
446
+ function renderSplitHunk(hunk: DiffHunk, layout: SplitLayout): string[] {
447
+ const { colWidth, noW, textW, useTrueColor, lang, removedPalette, addedPalette } = layout;
448
+ const out: string[] = [];
449
+ const rows = buildSplitRows(hunk);
450
+
451
+ for (const row of rows) {
452
+ const leftNo = row.left
453
+ ? String(row.left.oldNo ?? row.left.newNo ?? "").padStart(noW)
454
+ : " ".repeat(noW);
455
+ const rightNo = row.right
456
+ ? String(row.right.newNo ?? row.right.oldNo ?? "").padStart(noW)
457
+ : " ".repeat(noW);
458
+
459
+ let leftText = row.left ? truncateText(row.left.text, textW) : "";
460
+ let rightText = row.right ? truncateText(row.right.text, textW) : "";
461
+
462
+ if (row.left && row.right && row.left.type === "removed" && row.right.type === "added") {
463
+ const highlighted = highlightInlineChanges(
464
+ row.left.text, row.right.text, removedPalette, addedPalette, useTrueColor, lang,
465
+ );
466
+ leftText = truncateText(highlighted.old, textW);
467
+ rightText = truncateText(highlighted.new, textW);
468
+ } else if (lang) {
469
+ if (leftText) leftText = highlightLine(leftText, lang);
470
+ if (rightText) rightText = highlightLine(rightText, lang);
471
+ }
472
+
473
+ let leftCol: string;
474
+ let rightCol: string;
475
+
476
+ if (!row.left || row.left.type === "context") {
477
+ leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${p.dim}${leftText}${p.reset}`, colWidth);
478
+ } else if (row.left.type === "removed") {
479
+ if (useTrueColor) {
480
+ leftCol = padToWidth(
481
+ `${p.errorBg}${p.error}${leftNo} │ ${preserveBg(leftText, p.errorBg)}`, colWidth,
482
+ ) + p.reset;
483
+ } else {
484
+ leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
485
+ }
486
+ } else {
487
+ leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${leftText}`, colWidth);
488
+ }
489
+
490
+ if (!row.right || row.right.type === "context") {
491
+ rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${p.dim}${rightText}${p.reset}`, colWidth);
492
+ } else if (row.right.type === "added") {
493
+ if (useTrueColor) {
494
+ rightCol = padToWidth(
495
+ `${p.successBg}${p.success}${rightNo} │ ${preserveBg(rightText, p.successBg)}`, colWidth,
496
+ ) + p.reset;
497
+ } else {
498
+ rightCol = padToWidth(`${p.success}${rightNo} │ ${rightText}${p.reset}`, colWidth);
499
+ }
500
+ } else {
501
+ rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${rightText}`, colWidth);
502
+ }
503
+
504
+ out.push(`${leftCol} ${p.dim}│${p.reset} ${rightCol}`);
505
+ }
506
+ return out;
507
+ }
508
+
509
+ function renderSplit(diff: DiffResult, opts: DiffRenderOptions): string[] {
510
+ const layout = splitLayout(diff, opts);
511
+ const output: string[] = [];
512
+
513
+ // Column header
514
+ const leftHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
515
+ const rightHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
516
+ output.push(`${leftHeader} ${p.dim}│${p.reset} ${rightHeader}`);
517
+
518
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
519
+ if (hunkIdx > 0) {
520
+ output.push(`${p.dim}${" ".repeat(layout.colWidth)} │ ${" ".repeat(layout.colWidth)}${p.reset}`);
521
+ output.push(`${p.dim}${"·".repeat(layout.colWidth)} │ ${"·".repeat(layout.colWidth)}${p.reset}`);
522
+ }
523
+ output.push(...renderSplitHunk(diff.hunks[hunkIdx], layout));
524
+ }
525
+ return output;
526
+ }
527
+
528
+ interface SplitRow {
529
+ left: DiffLine | null;
530
+ right: DiffLine | null;
531
+ }
532
+
533
+ function buildSplitRows(hunk: DiffHunk): SplitRow[] {
534
+ const rows: SplitRow[] = [];
535
+ const lines = hunk.lines;
536
+ let i = 0;
537
+
538
+ while (i < lines.length) {
539
+ if (lines[i].type === "context") {
540
+ rows.push({ left: lines[i], right: lines[i] });
541
+ i++;
542
+ continue;
543
+ }
544
+
545
+ const removed: DiffLine[] = [];
546
+ while (i < lines.length && lines[i].type === "removed") {
547
+ removed.push(lines[i]);
548
+ i++;
549
+ }
550
+
551
+ const added: DiffLine[] = [];
552
+ while (i < lines.length && lines[i].type === "added") {
553
+ added.push(lines[i]);
554
+ i++;
555
+ }
556
+
557
+ const maxLen = Math.max(removed.length, added.length);
558
+ for (let k = 0; k < maxLen; k++) {
559
+ rows.push({
560
+ left: k < removed.length ? removed[k] : null,
561
+ right: k < added.length ? added[k] : null,
562
+ });
563
+ }
564
+ }
565
+
566
+ return rows;
567
+ }
568
+
569
+ // ── Async variants (yield between hunks) ──────────────────────────
570
+
571
+ /** Optional hook called between hunks to yield to the event loop. */
572
+ type YieldFn = () => Promise<void>;
573
+
574
+ async function renderUnifiedAsync(
575
+ diff: DiffResult,
576
+ opts: DiffRenderOptions,
577
+ yieldFn: YieldFn,
578
+ ): Promise<string[]> {
579
+ const layout = unifiedLayout(diff, opts);
580
+ const output: string[] = [];
581
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
582
+ if (hunkIdx > 0) await yieldFn();
583
+ if (hunkIdx > 0) output.push(` ${p.dim}⋯${p.reset}`);
584
+ output.push(...renderUnifiedHunk(diff.hunks[hunkIdx], layout));
585
+ }
586
+ return output;
587
+ }
588
+
589
+ async function renderSplitAsync(
590
+ diff: DiffResult,
591
+ opts: DiffRenderOptions,
592
+ yieldFn: YieldFn,
593
+ ): Promise<string[]> {
594
+ const layout = splitLayout(diff, opts);
595
+ const output: string[] = [];
596
+
597
+ const leftHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
598
+ const rightHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
599
+ output.push(`${leftHeader} ${p.dim}│${p.reset} ${rightHeader}`);
600
+
601
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
602
+ if (hunkIdx > 0) await yieldFn();
603
+ if (hunkIdx > 0) {
604
+ output.push(`${p.dim}${" ".repeat(layout.colWidth)} │ ${" ".repeat(layout.colWidth)}${p.reset}`);
605
+ output.push(`${p.dim}${"·".repeat(layout.colWidth)} │ ${"·".repeat(layout.colWidth)}${p.reset}`);
606
+ }
607
+ output.push(...renderSplitHunk(diff.hunks[hunkIdx], layout));
608
+ }
609
+ return output;
610
+ }
611
+
612
+ // ── Utilities ────────────────────────────────────────────────────
613
+
614
+ /**
615
+ * Truncate text to fit within maxWidth visible characters.
616
+ * ANSI-aware: measures visible length and preserves escape codes.
617
+ */
618
+ function truncateText(text: string, maxWidth: number): string {
619
+ if (maxWidth <= 0) return "";
620
+ if (visibleLen(text) <= maxWidth) return text;
621
+ if (maxWidth <= 1) return "…";
622
+
623
+ // Advance by visible width (CJK = 2), skipping ANSI sequences.
624
+ // Reserve one column for the trailing ellipsis.
625
+ let visible = 0;
626
+ let i = 0;
627
+ while (i < text.length && visible < maxWidth - 1) {
628
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
629
+ const end = text.indexOf("m", i);
630
+ if (end !== -1) {
631
+ i = end + 1;
632
+ continue;
633
+ }
634
+ }
635
+ const cp = text.codePointAt(i) ?? 0;
636
+ const cw = charWidth(cp);
637
+ if (visible + cw > maxWidth - 1) break;
638
+ visible += cw;
639
+ i += cp > 0xffff ? 2 : 1;
640
+ }
641
+
642
+ return text.slice(0, i) + p.reset + "…";
643
+ }
644
+
645
+ // ── Truncation ──────────────────────────────────────────────────
646
+
647
+ /**
648
+ * Trim context lines from hunks so the rendered output fits within a budget.
649
+ * Change lines are never removed — only the surrounding context shrinks.
650
+ */
651
+ function trimHunksToFit(hunks: DiffHunk[], maxLines: number): DiffHunk[] {
652
+ // Count change lines across all hunks
653
+ let changeCount = 0;
654
+ for (const hunk of hunks) {
655
+ for (const line of hunk.lines) {
656
+ if (line.type !== "context") changeCount++;
657
+ }
658
+ }
659
+
660
+ const separators = Math.max(0, hunks.length - 1);
661
+
662
+ // How many context lines can we afford?
663
+ const contextBudget = Math.max(0, maxLines - changeCount - separators);
664
+
665
+ // Count total context to see if trimming is needed
666
+ let totalContext = 0;
667
+ for (const hunk of hunks) {
668
+ for (const line of hunk.lines) {
669
+ if (line.type === "context") totalContext++;
670
+ }
671
+ }
672
+
673
+ if (totalContext <= contextBudget) return hunks;
674
+
675
+ // Determine how many context lines to keep per side of each change.
676
+ // Binary-search for the largest per-side context that fits.
677
+ let lo = 0;
678
+ let hi = 3; // original context size from groupHunks
679
+ while (lo < hi) {
680
+ const mid = Math.ceil((lo + hi) / 2);
681
+ if (countContextWithLimit(hunks, mid) <= contextBudget) lo = mid;
682
+ else hi = mid - 1;
683
+ }
684
+
685
+ return rebuildHunks(hunks, lo);
686
+ }
687
+
688
+ /** Count how many context lines remain if we keep at most `ctx` per side of each change. */
689
+ function countContextWithLimit(hunks: DiffHunk[], ctx: number): number {
690
+ let count = 0;
691
+ for (const hunk of hunks) {
692
+ const lines = hunk.lines;
693
+ for (let i = 0; i < lines.length; i++) {
694
+ if (lines[i]!.type !== "context") continue;
695
+ // Keep this context line if it's within `ctx` of any change
696
+ let nearChange = false;
697
+ for (let d = 1; d <= ctx; d++) {
698
+ if ((i - d >= 0 && lines[i - d]!.type !== "context") ||
699
+ (i + d < lines.length && lines[i + d]!.type !== "context")) {
700
+ nearChange = true;
701
+ break;
702
+ }
703
+ }
704
+ if (nearChange) count++;
705
+ }
706
+ }
707
+ return count;
708
+ }
709
+
710
+ /** Rebuild hunks keeping only context lines within `ctx` distance of a change. */
711
+ function rebuildHunks(hunks: DiffHunk[], ctx: number): DiffHunk[] {
712
+ return hunks.map((hunk) => {
713
+ const lines = hunk.lines;
714
+ const kept: DiffLine[] = [];
715
+ for (let i = 0; i < lines.length; i++) {
716
+ if (lines[i]!.type !== "context") {
717
+ kept.push(lines[i]!);
718
+ continue;
719
+ }
720
+ for (let d = 1; d <= ctx; d++) {
721
+ if ((i - d >= 0 && lines[i - d]!.type !== "context") ||
722
+ (i + d < lines.length && lines[i + d]!.type !== "context")) {
723
+ kept.push(lines[i]!);
724
+ break;
725
+ }
726
+ }
727
+ }
728
+ return { lines: kept };
729
+ });
730
+ }
731
+
732
+ // ── Public API ───────────────────────────────────────────────────
733
+
734
+ /** Select display mode based on available terminal width. */
735
+ export function selectMode(width: number): DiffDisplayMode {
736
+ if (width >= SPLIT_MIN_WIDTH) return "split";
737
+ if (width >= UNIFIED_MIN_WIDTH) return "unified";
738
+ return "summary";
739
+ }
740
+
741
+ /** Render a diff result as an array of ANSI-formatted terminal lines. */
742
+ export function renderDiff(diff: DiffResult, opts: DiffRenderOptions): string[] {
743
+ if (diff.isIdentical) return [`${p.dim}(no changes)${p.reset}`];
744
+
745
+ const mode = opts.mode ?? selectMode(opts.width);
746
+ const maxLines = opts.maxLines ?? 50;
747
+
748
+ const header = buildHeader(diff, opts.filePath);
749
+
750
+ if (mode === "summary") {
751
+ return [header, ...renderSummary(diff)];
752
+ }
753
+
754
+ // Trim context lines from hunks if the diff would exceed the budget,
755
+ // so that actual changes are always visible.
756
+ const trimmed: DiffResult = { ...diff, hunks: trimHunksToFit(diff.hunks, maxLines) };
757
+
758
+ let bodyLines: string[];
759
+ switch (mode) {
760
+ case "split":
761
+ bodyLines = renderSplit(trimmed, opts);
762
+ break;
763
+ case "unified":
764
+ bodyLines = renderUnified(trimmed, opts);
765
+ break;
766
+ }
767
+
768
+ // Final safety net — if still over budget, simple tail truncation.
769
+ if (bodyLines.length > maxLines) {
770
+ const overflow = bodyLines.length - maxLines;
771
+ bodyLines = bodyLines.slice(0, maxLines);
772
+ bodyLines.push(`${p.dim}… ${overflow} more lines${p.reset}`);
773
+ }
774
+
775
+ return [header, ...bodyLines];
776
+ }
777
+
778
+ /**
779
+ * Async variant of renderDiff that yields to the event loop between hunks.
780
+ * Use when rendering in a context where a spinner or other UI needs to stay
781
+ * responsive (e.g. showing a large diff during a permission prompt).
782
+ *
783
+ * @param onLines - Callback invoked with each batch of rendered lines as they
784
+ * are produced. Allows progressive/streaming display.
785
+ */
786
+ export async function renderDiffAsync(
787
+ diff: DiffResult,
788
+ opts: DiffRenderOptions,
789
+ onLines: (lines: string[]) => void,
790
+ ): Promise<void> {
791
+ if (diff.isIdentical) {
792
+ onLines([`${p.dim}(no changes)${p.reset}`]);
793
+ return;
794
+ }
795
+
796
+ const mode = opts.mode ?? selectMode(opts.width);
797
+ const maxLines = opts.maxLines ?? 50;
798
+
799
+ const header = buildHeader(diff, opts.filePath);
800
+
801
+ if (mode === "summary") {
802
+ onLines([header, ...renderSummary(diff)]);
803
+ return;
804
+ }
805
+
806
+ // Trim context lines from hunks if the diff would exceed the budget
807
+ const trimmed: DiffResult = { ...diff, hunks: trimHunksToFit(diff.hunks, maxLines) };
808
+
809
+ const yieldFn: YieldFn = () => new Promise<void>(r => setImmediate(r));
810
+
811
+ let bodyLines: string[];
812
+ switch (mode) {
813
+ case "split":
814
+ bodyLines = await renderSplitAsync(trimmed, opts, yieldFn);
815
+ break;
816
+ case "unified":
817
+ bodyLines = await renderUnifiedAsync(trimmed, opts, yieldFn);
818
+ break;
819
+ }
820
+
821
+ // Final safety net — if still over budget, simple tail truncation.
822
+ if (bodyLines.length > maxLines) {
823
+ const overflow = bodyLines.length - maxLines;
824
+ bodyLines = bodyLines.slice(0, maxLines);
825
+ bodyLines.push(`${p.dim}… ${overflow} more lines${p.reset}`);
826
+ }
827
+
828
+ onLines([header, ...bodyLines]);
829
+ }