/** * Diff renderer — Shiki-powered terminal diff for pi write/edit tools. * * Based on pi-diff by @heyhuynhgiabuu (https://github.com/buddingnewinsights/pi-diff) * * Architecture: * 1. Syntax-highlight full code blocks via Shiki → ANSI (fg-only codes) * 2. Layer diff background colors underneath (composites at cell level) * 3. For word-level changes, inject brighter bg at changed char positions * 4. Result: syntax fg + diff bg + word emphasis — all three visible together */ import { existsSync, readFileSync } from 'node:fs' import { extname, join, relative } from 'node:path' import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, ToolDefinition, } from '@earendil-works/pi-coding-agent' import { CONFIG_DIR_NAME, getAgentDir } from '@earendil-works/pi-coding-agent' import type { Text } from '@earendil-works/pi-tui' import { codeToANSI } from '@shikijs/cli' import * as Diff from 'diff' import type { BundledLanguage, BundledTheme } from 'shiki' // AIDEV-NOTE: This file is a dense ANSI/terminal rendering module. Many inline // ternary expressions remain in tight rendering paths where if/else would harm // readability without improving correctness. New code should prefer if/else per // AGENTS.md. Remaining ternaries are tracked in taskwarrior (task C). // --------------------------------------------------------------------------- // Diff Theme System // --------------------------------------------------------------------------- interface DiffPreset { name: string description: string shikiTheme?: string bgAdd?: string bgDel?: string bgAddHighlight?: string bgDelHighlight?: string bgGutterAdd?: string bgGutterDel?: string bgEmpty?: string fgAdd?: string fgDel?: string fgDim?: string fgLnum?: string fgRule?: string fgStripe?: string fgSafeMuted?: string } interface DiffUserConfig { diffTheme?: string diffColors?: Record } const DIFF_PRESETS: Record = { default: { name: 'default', description: 'Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)', bgAdd: '#162620', bgDel: '#2d1919', bgAddHighlight: '#234b32', bgDelHighlight: '#502323', bgGutterAdd: '#12201a', bgGutterDel: '#261616', bgEmpty: '#121212', fgDim: '#505050', fgLnum: '#646464', fgRule: '#323232', fgStripe: '#282828', fgSafeMuted: '#8b949e', }, midnight: { name: 'midnight', description: 'Subtle tints for pure black (#000000) terminal backgrounds', bgAdd: '#0d1a12', bgDel: '#1a0d0d', bgAddHighlight: '#1a3825', bgDelHighlight: '#381a1a', bgGutterAdd: '#091208', bgGutterDel: '#120908', bgEmpty: '#080808', fgDim: '#404040', fgLnum: '#505050', fgRule: '#282828', fgStripe: '#1e1e1e', fgSafeMuted: '#8b949e', }, subtle: { name: 'subtle', description: 'Minimal backgrounds — barely-there tints for a clean look', bgAdd: '#081008', bgDel: '#100808', bgAddHighlight: '#122818', bgDelHighlight: '#281212', bgGutterAdd: '#060c06', bgGutterDel: '#0c0606', bgEmpty: '#060606', fgDim: '#383838', fgLnum: '#484848', fgRule: '#242424', fgStripe: '#181818', fgSafeMuted: '#8b949e', }, neon: { name: 'neon', description: 'Higher contrast backgrounds for better visibility', bgAdd: '#1a3320', bgDel: '#331a16', bgAddHighlight: '#2d5c3a', bgDelHighlight: '#5c2d2d', bgGutterAdd: '#142818', bgGutterDel: '#28120e', bgEmpty: '#141414', fgDim: '#606060', fgLnum: '#787878', fgRule: '#404040', fgStripe: '#303030', fgSafeMuted: '#9da5ae', }, } function parseAnsiRgb( ansi: string, ): { r: number; g: number; b: number } | null { const esc = '\u001b' const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`)) if (m) { return { r: +m[1], g: +m[2], b: +m[3] } } return null } function hexToBgAnsi(hex: string): string { if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return '' const r = Number.parseInt(hex.slice(1, 3), 16) const g = Number.parseInt(hex.slice(3, 5), 16) const b = Number.parseInt(hex.slice(5, 7), 16) return `\x1b[48;2;${r};${g};${b}m` } function hexToFgAnsi(hex: string): string { if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return '' const r = Number.parseInt(hex.slice(1, 3), 16) const g = Number.parseInt(hex.slice(3, 5), 16) const b = Number.parseInt(hex.slice(5, 7), 16) return `\x1b[38;2;${r};${g};${b}m` } function _deriveBgFromFg(fgAnsi: string, intensity: number): string { const rgb = parseAnsiRgb(fgAnsi) if (!rgb) return '' const r = Math.round(rgb.r * intensity) const g = Math.round(rgb.g * intensity) const b = Math.round(rgb.b * intensity) return `\x1b[48;2;${r};${g};${b}m` } function mixBg( base: { r: number; g: number; b: number }, accent: { r: number; g: number; b: number }, intensity: number, ): string { const r = Math.round(base.r + (accent.r - base.r) * intensity) const g = Math.round(base.g + (accent.g - base.g) * intensity) const b = Math.round(base.b + (accent.b - base.b) * intensity) return `\x1b[48;2;${r};${g};${b}m` } let _autoDerivePending = true let _hasExplicitBgConfig = false function autoDeriveBgFromTheme(theme: Theme | undefined): void { if (!theme?.getFgAnsi) return try { const fgAdd = theme.getFgAnsi('toolDiffAdded') const fgDel = theme.getFgAnsi('toolDiffRemoved') const addRgb = parseAnsiRgb(fgAdd) const delRgb = parseAnsiRgb(fgDel) if (!addRgb || !delRgb) return let base = { r: 0, g: 0, b: 0 } if (theme.getBgAnsi) { try { const bgAnsi = theme.getBgAnsi('toolSuccessBg') const parsed = parseAnsiRgb(bgAnsi) if (parsed) { base = parsed BG_BASE = bgAnsi } } catch { /* no toolSuccessBg — use black */ } } BG_ADD = mixBg(base, addRgb, 0.08) BG_DEL = mixBg(base, delRgb, 0.1) BG_ADD_W = mixBg(base, addRgb, 0.2) BG_DEL_W = mixBg(base, delRgb, 0.22) BG_GUTTER_ADD = mixBg(base, addRgb, 0.05) BG_GUTTER_DEL = mixBg(base, delRgb, 0.06) BG_EMPTY = BG_BASE RST = `\x1b[0m${BG_BASE}` DIVIDER = `${FG_RULE}│${RST}` } catch { // Fall back to defaults silently } } function loadDiffConfig(): DiffUserConfig { // AIDEV-NOTE: getAgentDir()/settings.json is the canonical pi config location; // ~/.pi/settings.json is legacy fallback, cwd/CONFIG_DIR_NAME/settings.json is project-level override. const paths = [ join(process.cwd(), CONFIG_DIR_NAME, 'settings.json'), join(getAgentDir(), 'settings.json'), join(getAgentDir(), '..', 'settings.json'), ] for (const p of paths) { try { if (existsSync(p)) { const raw = JSON.parse(readFileSync(p, 'utf-8')) if (raw.diffTheme || raw.diffColors) { return { diffTheme: raw.diffTheme, diffColors: raw.diffColors } } } } catch { // skip invalid files } } return {} } function applyDiffPalette(): void { const config = loadDiffConfig() let preset: DiffPreset | null if (config.diffTheme) { preset = DIFF_PRESETS[config.diffTheme] ?? null } else { preset = null } if (preset) _hasExplicitBgConfig = true const ov = config.diffColors ?? {} if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true const applyBg = ( envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void, ) => { if (envName && process.env[envName]) return const hex = ov[key] ?? presetVal if (hex) { const a = hexToBgAnsi(hex) if (a) set(a) } } const applyFg = ( envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void, ) => { if (envName && process.env[envName]) return const hex = ov[key] ?? presetVal if (hex) { const a = hexToFgAnsi(hex) if (a) set(a) } } applyBg('DIFF_BG_ADD', 'bgAdd', preset?.bgAdd, (v) => { BG_ADD = v }) applyBg('DIFF_BG_DEL', 'bgDel', preset?.bgDel, (v) => { BG_DEL = v }) applyBg('DIFF_BG_ADD_HL', 'bgAddHighlight', preset?.bgAddHighlight, (v) => { BG_ADD_W = v }) applyBg('DIFF_BG_DEL_HL', 'bgDelHighlight', preset?.bgDelHighlight, (v) => { BG_DEL_W = v }) applyBg('DIFF_BG_GUTTER_ADD', 'bgGutterAdd', preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v }) applyBg('DIFF_BG_GUTTER_DEL', 'bgGutterDel', preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v }) applyBg(null, 'bgEmpty', preset?.bgEmpty, (v) => { BG_EMPTY = v }) applyFg('DIFF_FG_ADD', 'fgAdd', preset?.fgAdd, (v) => { FG_ADD = v }) applyFg('DIFF_FG_DEL', 'fgDel', preset?.fgDel, (v) => { FG_DEL = v }) applyFg(null, 'fgDim', preset?.fgDim, (v) => { FG_DIM = v }) applyFg(null, 'fgLnum', preset?.fgLnum, (v) => { FG_LNUM = v }) applyFg(null, 'fgRule', preset?.fgRule, (v) => { FG_RULE = v }) applyFg(null, 'fgStripe', preset?.fgStripe, (v) => { FG_STRIPE = v }) applyFg(null, 'fgSafeMuted', preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v }) const shiki = ov.shikiTheme ?? preset?.shikiTheme if (shiki) THEME = shiki as BundledTheme DIVIDER = `${FG_RULE}│${RST}` DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM } _autoDerivePending = !_hasExplicitBgConfig } // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? 'tokyo-night' function envInt(name: string, fallback: number): number { const v = Number.parseInt(process.env[name] ?? '', 10) if (Number.isFinite(v) && v > 0) { return v } return fallback } function envFg(name: string, fallback: string): string { const hex = process.env[name] if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback const r = Number.parseInt(hex.slice(1, 3), 16) const g = Number.parseInt(hex.slice(3, 5), 16) const b = Number.parseInt(hex.slice(5, 7), 16) return `\x1b[38;2;${r};${g};${b}m` } function envBg(name: string, fallback: string): string { const hex = process.env[name] if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback const r = Number.parseInt(hex.slice(1, 3), 16) const g = Number.parseInt(hex.slice(3, 5), 16) const b = Number.parseInt(hex.slice(5, 7), 16) return `\x1b[48;2;${r};${g};${b}m` } const SPLIT_MIN_WIDTH = envInt('DIFF_SPLIT_MIN_WIDTH', 150) const SPLIT_MIN_CODE_WIDTH = envInt('DIFF_SPLIT_MIN_CODE_WIDTH', 60) const SPLIT_MAX_WRAP_RATIO = 0.2 const SPLIT_MAX_WRAP_LINES = 8 const MAX_TERM_WIDTH = 210 const DEFAULT_TERM_WIDTH = 200 const MAX_PREVIEW_LINES = 60 const MAX_RENDER_LINES = 150 const MAX_HL_CHARS = 80_000 const CACHE_LIMIT = 192 const WORD_DIFF_MIN_SIM = 0.15 const MAX_WRAP_ROWS_WIDE = 3 const MAX_WRAP_ROWS_MED = 2 const MAX_WRAP_ROWS_NARROW = 1 // --------------------------------------------------------------------------- // ANSI // --------------------------------------------------------------------------- let RST = '\x1b[0m' const BOLD = '\x1b[1m' const DIM = '\x1b[2m' let BG_ADD = envBg('DIFF_BG_ADD', '\x1b[48;2;22;38;32m') let BG_DEL = envBg('DIFF_BG_DEL', '\x1b[48;2;45;25;25m') let BG_ADD_W = envBg('DIFF_BG_ADD_HL', '\x1b[48;2;35;75;50m') let BG_DEL_W = envBg('DIFF_BG_DEL_HL', '\x1b[48;2;80;35;35m') let BG_GUTTER_ADD = envBg('DIFF_BG_GUTTER_ADD', '\x1b[48;2;18;32;26m') let BG_GUTTER_DEL = envBg('DIFF_BG_GUTTER_DEL', '\x1b[48;2;38;22;22m') const _BG_GUTTER_CTX = '' let BG_EMPTY = '\x1b[48;2;18;18;18m' let FG_ADD = envFg('DIFF_FG_ADD', '\x1b[38;2;100;180;120m') let FG_DEL = envFg('DIFF_FG_DEL', '\x1b[38;2;200;100;100m') let FG_DIM = '\x1b[38;2;80;80;80m' let FG_LNUM = '\x1b[38;2;100;100;100m' let FG_RULE = '\x1b[38;2;50;50;50m' let FG_SAFE_MUTED = '\x1b[38;2;139;148;158m' let FG_STRIPE = '\x1b[38;2;40;40;40m' const BORDER_BAR = '▌' function stripes(w: number, _rowOffset: number): string { return BG_BASE + FG_STRIPE + '╱'.repeat(w) + RST } let DIVIDER = `${FG_RULE}│${RST}` const ESC_RE = '\u001b' const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, 'g') const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, 'g') const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, 'g') const BG_DEFAULT = '\x1b[49m' let BG_BASE = BG_DEFAULT // --------------------------------------------------------------------------- // Theme-aware diff colors // --------------------------------------------------------------------------- interface DiffColors { fgAdd: string fgDel: string fgCtx: string } let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM, } function resolveDiffColors(theme?: Theme): DiffColors { if (theme?.getBgAnsi && BG_BASE === BG_DEFAULT) { try { const bgAnsi = theme.getBgAnsi('toolSuccessBg') const parsed = parseAnsiRgb(bgAnsi) if (parsed) { BG_BASE = bgAnsi RST = `\x1b[0m${BG_BASE}` } } catch { /* ignore */ } } if (_autoDerivePending && theme?.getFgAnsi) { autoDeriveBgFromTheme(theme) _autoDerivePending = false } if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS try { const fgAdd = theme.getFgAnsi('toolDiffAdded') || FG_ADD const fgDel = theme.getFgAnsi('toolDiffRemoved') || FG_DEL const fgCtx = theme.getFgAnsi('toolDiffContext') || FG_DIM return { fgAdd, fgDel, fgCtx } } catch { return DEFAULT_DIFF_COLORS } } // --------------------------------------------------------------------------- // Adaptive helpers // --------------------------------------------------------------------------- function adaptiveWrapRows(tw?: number): number { const w = tw ?? termW() if (w >= 180) return MAX_WRAP_ROWS_WIDE if (w >= 120) return MAX_WRAP_ROWS_MED return MAX_WRAP_ROWS_NARROW } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface DiffLine { type: 'add' | 'del' | 'ctx' | 'sep' oldNum: number | null newNum: number | null content: string } interface ParsedDiff { lines: DiffLine[] added: number removed: number chars: number } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- function strip(s: string): string { return s.replace(ANSI_RE, '') } function tabs(s: string): string { return s.replace(/\t/g, ' ') } function termW(): number { const raw = process.stdout.columns || (process.stderr as { columns?: number }).columns || Number.parseInt(process.env.COLUMNS ?? '', 10) || DEFAULT_TERM_WIDTH return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)) } function fit(s: string, w: number): string { if (w <= 0) return '' const plain = strip(s) if (plain.length <= w) return s + ' '.repeat(w - plain.length) const showW = w > 2 ? w - 1 : w let vis = 0, i = 0 while (i < s.length && vis < showW) { if (s[i] === '\x1b') { const e = s.indexOf('m', i) if (e !== -1) { i = e + 1 continue } } vis++ i++ } return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}` } function ansiState(s: string): string { let fg = '', bg = '' for (const match of s.matchAll(ANSI_CAPTURE_RE)) { const p = match[1] ?? '' const seq = match[0] ?? '' if (p === '0') { fg = '' bg = '' } else if (p === '39') { fg = '' } else if (p.startsWith('38;')) { fg = seq } else if (p.startsWith('48;')) { bg = seq } } return bg + fg } function isLowContrastShikiFg(params: string): boolean { if (params === '30' || params === '90') return true if (params === '38;5;0' || params === '38;5;8') return true if (!params.startsWith('38;2;')) return false const parts = params.split(';').map(Number) if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false const [, , r, g, b] = parts const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b return luminance < 72 } function normalizeShikiContrast(ansi: string): string { return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) => isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq, ) } function wrapAnsi( s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = '', ): string[] { if (w <= 0) return [''] const plain = strip(s) if (plain.length <= w) { const pad = w - plain.length return pad > 0 ? [s + fillBg + ' '.repeat(pad) + (fillBg ? RST : '')] : [s] } const rows: string[] = [] let row = '', vis = 0, i = 0 let onLastRow = false let effW = w while (i < s.length) { if (!onLastRow && rows.length >= maxRows - 1) { onLastRow = true effW = w > 2 ? w - 1 : w } if (s[i] === '\x1b') { const end = s.indexOf('m', i) if (end !== -1) { row += s.slice(i, end + 1) i = end + 1 continue } } if (vis >= effW) { if (onLastRow) { let hasMore = false for (let j = i; j < s.length; j++) { if (s[j] === '\x1b') { const e2 = s.indexOf('m', j) if (e2 !== -1) { j = e2 continue } } hasMore = true break } if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}` else row += fillBg + ' '.repeat(Math.max(0, w - vis)) + RST rows.push(row) return rows } const state = ansiState(row) rows.push(row + RST) row = state + fillBg vis = 0 if (rows.length >= maxRows - 1) { onLastRow = true effW = w > 2 ? w - 1 : w } } row += s[i] vis++ i++ } if (row.length > 0 || rows.length === 0) { rows.push(row + fillBg + ' '.repeat(Math.max(0, w - vis)) + RST) } return rows } function lnum(n: number | null, w: number, fg = FG_LNUM): string { if (n === null) return ' '.repeat(w) const v = String(n) return `${fg}${' '.repeat(Math.max(0, w - v.length))}${v}${RST}` } function shortPath(cwd: string, home: string, p: string): string { if (!p) return '' const r = relative(cwd, p) if (!r.startsWith('..') && !r.startsWith('/')) return r return p.replace(home, '~') } function summarize(a: number, d: number): string { const p: string[] = [] if (a > 0) p.push(`${FG_ADD}+${a}${RST}`) if (d > 0) p.push(`${FG_DEL}-${d}${RST}`) return p.length ? p.join(' ') : `${FG_DIM}no changes${RST}` } function rule(w: number): string { return `${BG_BASE}${FG_RULE}${'─'.repeat(w)}${RST}` } function shouldUseSplit( diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES, ): boolean { if (!diff.lines.length) return false if (tw < SPLIT_MIN_WIDTH) return false const nw = Math.max( 2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)) .length, ) const half = Math.floor((tw - 1) / 2) const gw = nw + 5 const cw = Math.max(12, half - gw) if (cw < SPLIT_MIN_CODE_WIDTH) return false const vis = diff.lines.slice(0, maxRows) let contentLines = 0 let wrapCandidates = 0 for (const l of vis) { if (l.type === 'sep') continue contentLines++ if (tabs(l.content).length > cw) wrapCandidates++ } if (contentLines === 0) return true const wrapRatio = wrapCandidates / contentLines if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false return true } // --------------------------------------------------------------------------- // Language detection // --------------------------------------------------------------------------- const EXT_LANG: Record = { ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', mjs: 'javascript', cjs: 'javascript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', swift: 'swift', kt: 'kotlin', html: 'html', css: 'css', scss: 'scss', json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', lua: 'lua', php: 'php', dart: 'dart', xml: 'xml', graphql: 'graphql', svelte: 'svelte', vue: 'vue', } function lang(fp: string): BundledLanguage | undefined { return EXT_LANG[extname(fp).slice(1).toLowerCase()] } // --------------------------------------------------------------------------- // Shiki ANSI cache + pre-warm // --------------------------------------------------------------------------- codeToANSI('', 'typescript', THEME).catch(() => {}) const _cache = new Map() function _touch(k: string, v: string[]): string[] { _cache.delete(k) _cache.set(k, v) while (_cache.size > CACHE_LIMIT) { const first = _cache.keys().next().value if (first === undefined) break _cache.delete(first) } return v } async function hlBlock( code: string, language: BundledLanguage | undefined, ): Promise { if (!code) return [''] if (!language || code.length > MAX_HL_CHARS) return code.split('\n') const k = `${THEME}\0${language}\0${code}` const hit = _cache.get(k) if (hit) return _touch(k, hit) try { const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME)) const out = (ansi.endsWith('\n') ? ansi.slice(0, -1) : ansi).split('\n') return _touch(k, out) } catch { return code.split('\n') } } // --------------------------------------------------------------------------- // Diff parsing // --------------------------------------------------------------------------- function parseDiff( oldContent: string, newContent: string, ctx = 3, ): ParsedDiff { const patch = Diff.structuredPatch('', '', oldContent, newContent, '', '', { context: ctx, }) const lines: DiffLine[] = [] let added = 0, removed = 0 for (let hi = 0; hi < patch.hunks.length; hi++) { if (hi > 0) { const prev = patch.hunks[hi - 1] const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines) lines.push({ type: 'sep', oldNum: null, newNum: gap > 0 ? gap : null, content: '', }) } const h = patch.hunks[hi] let oL = h.oldStart, nL = h.newStart for (const raw of h.lines) { if (raw === '\\ No newline at end of file') continue const ch = raw[0], text = raw.slice(1) if (ch === '+') { lines.push({ type: 'add', oldNum: null, newNum: nL++, content: text }) added++ } else if (ch === '-') { lines.push({ type: 'del', oldNum: oL++, newNum: null, content: text }) removed++ } else { lines.push({ type: 'ctx', oldNum: oL++, newNum: nL++, content: text }) } } } return { lines, added, removed, chars: oldContent.length + newContent.length } } // --------------------------------------------------------------------------- // Word diff + bg injection // --------------------------------------------------------------------------- function wordDiffAnalysis( a: string, b: string, ): { similarity: number oldRanges: Array<[number, number]> newRanges: Array<[number, number]> } { if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] } const parts = Diff.diffWords(a, b) const oldRanges: Array<[number, number]> = [] const newRanges: Array<[number, number]> = [] let oPos = 0, nPos = 0, same = 0 for (const p of parts) { if (p.removed) { oldRanges.push([oPos, oPos + p.value.length]) oPos += p.value.length } else if (p.added) { newRanges.push([nPos, nPos + p.value.length]) nPos += p.value.length } else { const len = p.value.length same += len oPos += len nPos += len } } const maxLen = Math.max(a.length, b.length) return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges } } function injectBg( ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string, ): string { if (!ranges.length) return baseBg + ansiLine + RST let out = baseBg let vis = 0 let inHL = false let ri = 0 let i = 0 while (i < ansiLine.length) { if (ansiLine[i] === '\x1b') { const m = ansiLine.indexOf('m', i) if (m !== -1) { const seq = ansiLine.slice(i, m + 1) out += seq if (seq === '\x1b[0m') out += inHL ? hlBg : baseBg i = m + 1 continue } } while (ri < ranges.length && vis >= ranges[ri][1]) ri++ const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1] if (want !== inHL) { inHL = want out += inHL ? hlBg : baseBg } out += ansiLine[i] vis++ i++ } return out + RST } function plainWordDiff( oldText: string, newText: string, ): { old: string; new: string } { const parts = Diff.diffWords(oldText, newText) let o = '', n = '' for (const p of parts) { if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}` else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}` else { o += p.value n += p.value } } return { old: o, new: n } } // --------------------------------------------------------------------------- // Unified view // --------------------------------------------------------------------------- async function renderUnified( diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_RENDER_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS, ): Promise { if (!diff.lines.length) return '' const vis = diff.lines.slice(0, max) const tw = termW() const nw = Math.max( 2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length, ) const gw = nw + 5 const cw = Math.max(20, tw - gw) const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES const oldSrc: string[] = [], newSrc: string[] = [] for (const l of vis) { if (l.type === 'ctx' || l.type === 'del') oldSrc.push(l.content) if (l.type === 'ctx' || l.type === 'add') newSrc.push(l.content) } const [oldHL, newHL] = canHL ? await Promise.all([ hlBlock(oldSrc.join('\n'), language), hlBlock(newSrc.join('\n'), language), ]) : [oldSrc, newSrc] let oI = 0, nI = 0, idx = 0 const out: string[] = [] out.push(rule(tw)) function emitRow( num: number | null, sign: string, gutterBg: string, signFg: string, body: string, bodyBg = '', ): void { const borderFg = sign === '-' ? dc.fgDel : sign === '+' ? dc.fgAdd : '' const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_BASE} ` const numFg = borderFg || FG_LNUM const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} ` const contGutter = `${border}${gutterBg}${' '.repeat(nw + 1)}${RST} ${DIVIDER} ` const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg) out.push(`${gutter}${rows[0]}${RST}`) for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${RST}`) } while (idx < vis.length) { const l = vis[idx] if (l.type === 'sep') { const gap = l.newNum const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : '···' const totalW = Math.min(tw, 72) const pad = Math.max(0, totalW - label.length - 2) const half1 = Math.floor(pad / 2), half2 = pad - half1 out.push( `${BG_BASE}${FG_DIM}${'─'.repeat(half1)}${label}${'─'.repeat(half2)}${RST}`, ) idx++ continue } if (l.type === 'ctx') { const hl = oldHL[oI] ?? l.content emitRow( l.newNum, ' ', BG_BASE, dc.fgCtx, `${BG_BASE}${DIM}${hl}`, BG_BASE, ) oI++ nI++ idx++ continue } const dels: Array<{ l: DiffLine; hl: string }> = [] while (idx < vis.length && vis[idx].type === 'del') { dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content }) oI++ idx++ } const adds: Array<{ l: DiffLine; hl: string }> = [] while (idx < vis.length && vis[idx].type === 'add') { adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content }) nI++ idx++ } const isPaired = dels.length === 1 && adds.length === 1 const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) { const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W) const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W) emitRow( dels[0].l.oldNum, '-', BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL, ) emitRow( adds[0].l.newNum, '+', BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD, ) continue } if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) { const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content) emitRow( dels[0].l.oldNum, '-', BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL, ) emitRow( adds[0].l.newNum, '+', BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD, ) continue } for (const d of dels) { const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}` emitRow( d.l.oldNum, '-', BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL, ) } for (const a of adds) { const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}` emitRow( a.l.newNum, '+', BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD, ) } } out.push(rule(tw)) if (diff.lines.length > vis.length) { out.push( `${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`, ) } return out.join('\n') } // --------------------------------------------------------------------------- // Split view // --------------------------------------------------------------------------- async function renderSplit( diff: ParsedDiff, language: BundledLanguage | undefined, max = MAX_PREVIEW_LINES, dc: DiffColors = DEFAULT_DIFF_COLORS, ): Promise { const tw = termW() if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc) if (!diff.lines.length) return '' type Row = { left: DiffLine | null; right: DiffLine | null } const rows: Row[] = [] let i = 0 while (i < diff.lines.length) { const l = diff.lines[i] if (l.type === 'sep' || l.type === 'ctx') { rows.push({ left: l, right: l }) i++ continue } const dels: DiffLine[] = [], adds: DiffLine[] = [] while (i < diff.lines.length && diff.lines[i].type === 'del') { dels.push(diff.lines[i]) i++ } while (i < diff.lines.length && diff.lines[i].type === 'add') { adds.push(diff.lines[i]) i++ } const n = Math.max(dels.length, adds.length) for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null }) } const vis = rows.slice(0, max) const half = Math.floor((tw - 1) / 2) const nw = Math.max( 2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)) .length, ) const gw = nw + 5 const cw = Math.max(12, half - gw) const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2 const leftSrc: string[] = [], rightSrc: string[] = [] for (const r of vis) { if (r.left && r.left.type !== 'sep') leftSrc.push(r.left.content) if (r.right && r.right.type !== 'sep') rightSrc.push(r.right.content) } const [leftHL, rightHL] = canHL ? await Promise.all([ hlBlock(leftSrc.join('\n'), language), hlBlock(rightSrc.join('\n'), language), ]) : [leftSrc, rightSrc] let lI = 0, rI = 0 let stripeRow = 0 type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] } function half_build( line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: 'left' | 'right', ): HalfResult { if (!line) { const gw2 = nw + 2 const gPat = FG_STRIPE + '╱'.repeat(gw2) + RST const g = ` ${gPat}${FG_RULE}│${RST} ` return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] } } if (line.type === 'sep') { const gap = line.newNum const label = gap && gap > 0 ? `··· ${gap} lines ···` : '···' const g = `${BG_BASE} ${FG_DIM}${fit('', nw + 2)}${RST}${FG_RULE}│${RST} ` return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`], } } const isDel = line.type === 'del', isAdd = line.type === 'add' const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx const sign = isDel ? '-' : isAdd ? '+' : ' ' const num = isDel ? line.oldNum : isAdd ? line.newNum : side === 'left' ? line.oldNum : line.newNum const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : '' const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_BASE}` const numFg = borderFg || FG_LNUM let body: string if (ranges && ranges.length > 0) { body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W) } else if (isDel || isAdd) { body = `${cBg}${hl}` } else { body = `${BG_BASE}${DIM}${hl}` } const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} ` const contGutter = `${border}${gBg}${' '.repeat(nw + 1)}${RST} ${FG_RULE}│${RST} ` const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg) return { gutter, contGutter, bodyRows } } const out: string[] = [] const hdrOld = `${BG_BASE}${' '.repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}` const hdrNew = `${BG_BASE}${' '.repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}` out.push( `${BG_BASE}${hdrOld}${' '.repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`, ) out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`) for (const r of vis) { const leftLine = r.left, rightLine = r.right const paired = leftLine && rightLine && leftLine.type === 'del' && rightLine.type === 'add' const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null let lResult: HalfResult, rResult: HalfResult if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) { const lhl = leftHL[lI++] ?? leftLine.content const rhl = rightHL[rI++] ?? rightLine.content lResult = half_build(leftLine, lhl, wd.oldRanges, 'left') rResult = half_build(rightLine, rhl, wd.newRanges, 'right') } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) { const pwd = plainWordDiff(leftLine.content, rightLine.content) lI++ rI++ lResult = half_build(leftLine, pwd.old, null, 'left') rResult = half_build(rightLine, pwd.new, null, 'right') } else { const lhl = leftLine && leftLine.type !== 'sep' ? (leftHL[lI++] ?? leftLine?.content ?? '') : '' const rhl = rightLine && rightLine.type !== 'sep' ? (rightHL[rI++] ?? rightLine?.content ?? '') : '' lResult = half_build(leftLine, lhl, null, 'left') rResult = half_build(rightLine, rhl, null, 'right') } const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length) const leftIsEmpty = !r.left const rightIsEmpty = !r.right for (let row = 0; row < maxRows; row++) { const lg = row === 0 ? lResult.gutter : lResult.contGutter const rg = row === 0 ? rResult.gutter : rResult.contGutter const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${' '.repeat(cw)}${RST}`) const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${' '.repeat(cw)}${RST}`) out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`) stripeRow++ } } out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`) if (rows.length > vis.length) { out.push( `${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`, ) } return out.join('\n') } // --------------------------------------------------------------------------- // Public API — register write/edit tools with diff rendering // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Render-hook structural types // --------------------------------------------------------------------------- // AIDEV-NOTE: Pi's ToolRenderContext isn't exported from the // main SDK entry, so we model the subset diff-renderer reads/writes locally. // `result.details` is mutated in place; the union below covers every shape we // stash there across write/edit execute + render paths. interface DiffState { _isNewFile?: boolean | null _previewKey?: string _previewText?: string _wdk?: string _wdt?: string _nfk?: string _nft?: string _pk?: string _pt?: string } interface RenderCtx { state: DiffState lastComponent?: Text expanded?: boolean isError?: boolean argsComplete?: boolean invalidate(): void } /** Execute ctx is ExtensionContext, but diff-renderer stashes render state on it. */ type ExecCtx = ExtensionContext & { state?: DiffState } type DiffDetails = | { _type: 'diff' summary: string diff: ParsedDiff language?: BundledLanguage } | { _type: 'new'; lines: number; content: string; filePath: string } | { _type: 'noChange' } | { _type: 'fallback' } | { _type: 'editInfo'; summary: string; editLine: number } | { _type: 'multiEditInfo' summary: string editCount: number diffLineCount: number } interface TextBlock { type: string text?: string } /** A tool result whose `details` field we mutate in place. */ type MutableResult = AgentToolResult & { details: DiffDetails | undefined } export function registerDiffTools(pi: ExtensionAPI): void { applyDiffPalette() let createWriteFn: ((cwd: string) => ToolDefinition) | undefined, createEditFn: ((cwd: string) => ToolDefinition) | undefined, TextComponent: typeof Text | undefined, pillFn: ((name: string, theme: Theme) => string) | undefined, keyHintFn: ((key: string, fallback: string) => string) | undefined try { const sdk = require('@earendil-works/pi-coding-agent') createWriteFn = sdk.createWriteTool createEditFn = sdk.createEditTool keyHintFn = sdk.keyHint TextComponent = require('@earendil-works/pi-tui').Text pillFn = require('./pill.js').pill } catch { return } if (!createWriteFn || !createEditFn || !TextComponent) return const cwd = process.cwd() const home = process.env.HOME ?? '' const sp = (p: string) => shortPath(cwd, home, p) /** Max diff lines shown collapsed vs expanded */ const COLLAPSED_DIFF_LINES = 30 const COLLAPSED_NEW_FILE_LINES = 12 /** Expand hint text */ function expandHint(theme: Theme): string { const hint = keyHintFn ? keyHintFn('app.tools.expand', 'to expand') : 'expand to see more' return theme.fg('dim', `… (${hint})`) } /** Get raw text from tool result (fallback rendering) */ function fallbackText( result: AgentToolResult, theme: Theme, ): string { const raw = result.content ?.filter((c: TextBlock) => c.type === 'text') .map((c: TextBlock) => c.text || '') .join('\n') ?? '' return raw ? theme.fg('dim', raw.slice(0, 200)) : '' } /** Render a pill header for write/create/edit */ function header(label: string, fp: string, theme: Theme): string { const p = pillFn ? pillFn(label, theme) : theme.fg('toolTitle', theme.bold(label)) return `${p} ${theme.fg('accent', sp(fp))}` } // ======================================================================= // write // ======================================================================= const origWrite = createWriteFn(cwd) pi.registerTool({ ...origWrite, parameters: { ...origWrite.parameters }, async execute( tid: string, params: Record, sig: AbortSignal | undefined, upd: AgentToolUpdateCallback | undefined, ctxr: ExecCtx, ) { const fp = (params.path as string | undefined) ?? (params.file_path as string | undefined) ?? '' let old: string | null = null try { if (fp && existsSync(fp)) old = readFileSync(fp, 'utf-8') } catch { old = null } // Store isNew decision in state so renderCall/renderResult stay consistent if (ctxr.state) ctxr.state._isNewFile = old === null const result = (await origWrite.execute( tid, params, sig, upd, ctxr as ExtensionContext, )) as MutableResult const content = (params.content as string | undefined) ?? '' try { if (old !== null && old !== content) { const diff = parseDiff(old, content) const lg = lang(fp) result.details = { _type: 'diff', summary: summarize(diff.added, diff.removed), diff, language: lg, } } else if (old === null) { const lineCount = content ? content.split('\n').length : 0 result.details = { _type: 'new', lines: lineCount, content: content ?? '', filePath: fp, } } else if (old === content) { result.details = { _type: 'noChange' } } } catch { // Fallback: if diff computation fails, just report success result.details = { _type: 'fallback' } } return result }, renderCall(args: unknown, theme: Theme, ctx: unknown) { const a = (args ?? {}) as { path?: string file_path?: string content?: string } const ctxr = ctx as RenderCtx const fp = a.path ?? a.file_path ?? '' // Use state from execute if available, otherwise check filesystem const isNew = ctxr.state?._isNewFile ?? (!fp || !existsSync(fp)) const label = isNew ? 'create' : 'write' const text = ctxr.lastComponent ?? new TextComponent('', 0, 0) const hdr = header(label, fp, theme) // Streaming: show line count progress if (a.content && !ctxr.argsComplete) { const n = String(a.content).split('\n').length text.setText(`${hdr} ${theme.fg('muted', `(${n} lines…)`)}`) return text } // Create preview: syntax-highlight new file content if (a.content && ctxr.argsComplete && isNew) { const contentStr = a.content const previewKey = `create:${fp}:${contentStr.length}:${ctxr.expanded}` if (ctxr.state._previewKey !== previewKey) { ctxr.state._previewKey = previewKey ctxr.state._previewText = hdr const lg = lang(fp) hlBlock(contentStr, lg) .then((lines: string[]) => { if (ctxr.state._previewKey !== previewKey) return const maxShow = ctxr.expanded ? lines.length : COLLAPSED_NEW_FILE_LINES const preview = lines.slice(0, maxShow).join('\n') const rem = lines.length - maxShow let out = `${hdr}\n\n${preview}` if (rem > 0) out += `\n${expandHint(theme)}` ctxr.state._previewText = out ctxr.invalidate() }) .catch(() => { // Fallback: show plain content const lines = contentStr.split('\n') const maxShow = ctxr.expanded ? lines.length : COLLAPSED_NEW_FILE_LINES const preview = lines.slice(0, maxShow).join('\n') const rem = lines.length - maxShow let out = `${hdr}\n\n${preview}` if (rem > 0) out += `\n${expandHint(theme)}` ctxr.state._previewText = out ctxr.invalidate() }) } text.setText(ctxr.state._previewText ?? hdr) return text } text.setText(hdr) return text }, renderResult(result: unknown, opts: unknown, theme: Theme, ctx: unknown) { const res = result as AgentToolResult const ctxr = ctx as RenderCtx const opt = opts as { expanded?: boolean } | undefined const text = ctxr.lastComponent ?? new TextComponent('', 0, 0) const expanded = opt?.expanded ?? ctxr.expanded ?? false if (ctxr.isError) { const e = res.content ?.filter((c: TextBlock) => c.type === 'text') .map((c: TextBlock) => c.text || '') .join('\n') ?? 'Error' text.setText(`\n${theme.fg('error', e)}`) return text } const d = res.details as DiffDetails | undefined // Diff result (overwrite of existing file) if (d?._type === 'diff') { const w = termW() const maxLines = expanded ? MAX_RENDER_LINES : COLLAPSED_DIFF_LINES const key = `wd:${w}:${maxLines}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ''}` if (ctxr.state._wdk !== key) { ctxr.state._wdk = key ctxr.state._wdt = ` ${d.summary}\n${theme.fg('muted', ' rendering diff…')}` try { const dc = resolveDiffColors(theme) renderSplit(d.diff, d.language, maxLines, dc) .then((rendered: string) => { if (ctxr.state._wdk !== key) return let out = ` ${d.summary}\n${rendered}` if (!expanded && d.diff.lines.length > COLLAPSED_DIFF_LINES) { out += `\n${expandHint(theme)}` } ctxr.state._wdt = out ctxr.invalidate() }) .catch(() => { if (ctxr.state._wdk !== key) return ctxr.state._wdt = ` ${d.summary}` ctxr.invalidate() }) } catch { ctxr.state._wdt = ` ${d.summary}` } } text.setText(ctxr.state._wdt ?? ` ${d.summary}`) return text } // No changes if (d?._type === 'noChange') { text.setText(` ${theme.fg('muted', '✓ no changes')}`) return text } // New file if (d?._type === 'new') { const { lines: lineCount, content: rawContent, filePath: fp } = d const maxShow = expanded ? Infinity : COLLAPSED_NEW_FILE_LINES const pk = `nf:${fp}:${lineCount}:${maxShow}` if (ctxr.state._nfk !== pk) { ctxr.state._nfk = pk ctxr.state._nft = ` ${theme.fg('success', `✓ new file (${lineCount} lines)`)}` const lg = lang(fp) if (rawContent) { hlBlock(rawContent, lg) .then((hlLines: string[]) => { if (ctxr.state._nfk !== pk) return const shown = hlLines.slice(0, maxShow) const preview = shown.join('\n') const rem = hlLines.length - shown.length let out = ` ${theme.fg('success', `✓ new file (${lineCount} lines)`)}\n${preview}` if (rem > 0) out += `\n${expandHint(theme)}` ctxr.state._nft = out ctxr.invalidate() }) .catch(() => { // Fallback: plain text preview const lines = rawContent.split('\n') const shown = lines.slice(0, maxShow) const rem = lines.length - shown.length let out = ` ${theme.fg('success', `✓ new file (${lineCount} lines)`)}\n${shown.join('\n')}` if (rem > 0) out += `\n${expandHint(theme)}` ctxr.state._nft = out ctxr.invalidate() }) } } text.setText( ctxr.state._nft ?? ` ${theme.fg('success', `✓ new file (${lineCount} lines)`)}`, ) return text } // Fallback: show raw result text const fb = fallbackText(res, theme) text.setText(fb ? ` ${fb}` : ` ${theme.fg('dim', 'written')}`) return text }, }) // ======================================================================= // edit // ======================================================================= const origEdit = createEditFn(cwd) function getEditOperations( input: unknown, ): Array<{ oldText: string; newText: string }> { const inp = (input ?? {}) as Record if (Array.isArray(inp?.edits)) { return inp.edits .map((edit: Record) => ({ oldText: typeof edit?.oldText === 'string' ? edit.oldText : typeof edit?.old_text === 'string' ? edit.old_text : '', newText: typeof edit?.newText === 'string' ? edit.newText : typeof edit?.new_text === 'string' ? edit.new_text : '', })) .filter( (edit: { oldText: string; newText: string }) => edit.oldText !== edit.newText, ) } const oldText = typeof inp?.oldText === 'string' ? inp.oldText : typeof inp?.old_text === 'string' ? inp.old_text : '' const newText = typeof inp?.newText === 'string' ? inp.newText : typeof inp?.new_text === 'string' ? inp.new_text : '' return oldText !== newText ? [{ oldText, newText }] : [] } function summarizeEditOperations( operations: Array<{ oldText: string; newText: string }>, ) { const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText), ) const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0) const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0) return { diffs, totalAdded, totalRemoved, summary: summarize(totalAdded, totalRemoved), } } pi.registerTool({ ...origEdit, parameters: { ...origEdit.parameters }, async execute( tid: string, params: Record, sig: AbortSignal | undefined, upd: AgentToolUpdateCallback | undefined, ctxr: ExecCtx, ) { const fp = (params.path as string | undefined) ?? (params.file_path as string | undefined) ?? '' const operations = getEditOperations(params) // Read old content BEFORE executing, to find correct line offset let preEditContent: string | null = null try { if (fp && existsSync(fp)) preEditContent = readFileSync(fp, 'utf-8') } catch { preEditContent = null } const result = (await origEdit.execute( tid, params, sig, upd, ctxr as ExtensionContext, )) as MutableResult if (operations.length === 0) return result try { const { diffs, summary } = summarizeEditOperations(operations) if (operations.length === 1) { let editLine = 0 // Use pre-edit content to find oldText position (not newText) if (preEditContent && operations[0].oldText) { const idx = preEditContent.indexOf(operations[0].oldText) if (idx >= 0) editLine = preEditContent.slice(0, idx).split('\n').length } result.details = { _type: 'editInfo', summary, editLine } return result } result.details = { _type: 'multiEditInfo', summary, editCount: operations.length, diffLineCount: diffs.reduce( (sum, diff) => sum + diff.lines.length, 0, ), } } catch { result.details = { _type: 'fallback' } } return result }, renderCall(args: unknown, theme: Theme, ctx: unknown) { const a = (args ?? {}) as { path?: string; file_path?: string } const ctxr = ctx as RenderCtx const fp = a.path ?? a.file_path ?? '' const operations = getEditOperations(args) const text = ctxr.lastComponent ?? new TextComponent('', 0, 0) const hdr = header('edit', fp, theme) if (!(ctxr.argsComplete && operations.length > 0)) { text.setText(hdr) return text } const expanded = ctxr.expanded ?? false const pk = JSON.stringify({ fp, operations, w: termW(), expanded }) if (ctxr.state._pk !== pk) { ctxr.state._pk = pk ctxr.state._pt = `${hdr} ${theme.fg('muted', '(rendering…)')}` const lg = lang(fp) const dc = resolveDiffColors(theme) const maxLines = expanded ? MAX_PREVIEW_LINES : COLLAPSED_DIFF_LINES if (operations.length === 1) { const diff = parseDiff(operations[0].oldText, operations[0].newText) renderSplit(diff, lg, maxLines, dc) .then((rendered) => { if (ctxr.state._pk !== pk) return let out = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}` if (!expanded && diff.lines.length > COLLAPSED_DIFF_LINES) { out += `\n${expandHint(theme)}` } ctxr.state._pt = out ctxr.invalidate() }) .catch(() => { if (ctxr.state._pk !== pk) return ctxr.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}` ctxr.invalidate() }) } else { const { diffs, summary } = summarizeEditOperations(operations) const maxShown = Math.min(operations.length, 3) const previewLines = Math.max(8, Math.floor(maxLines / maxShown)) Promise.all( diffs.slice(0, maxShown).map((diff, index) => renderSplit(diff, lg, previewLines, dc) .then( (rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`, ) .catch( () => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`, ), ), ) .then((sections) => { if (ctxr.state._pk !== pk) return const remainder = operations.length - maxShown const suffix = remainder > 0 ? `\n${theme.fg('muted', `… ${remainder} more edit blocks`)}` : '' ctxr.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join('\n\n')}${suffix}` ctxr.invalidate() }) .catch(() => { if (ctxr.state._pk !== pk) return ctxr.state._pt = `${hdr} ${operations.length} edits ${summary}` ctxr.invalidate() }) } } text.setText(ctxr.state._pt ?? hdr) return text }, renderResult(result: unknown, opts: unknown, theme: Theme, ctx: unknown) { const res = result as AgentToolResult const ctxr = ctx as RenderCtx const opt = opts as { expanded?: boolean } | undefined const text = ctxr.lastComponent ?? new TextComponent('', 0, 0) const _expanded = opt?.expanded ?? ctxr.expanded ?? false if (ctxr.isError) { const e = res.content ?.filter((c: TextBlock) => c.type === 'text') .map((c: TextBlock) => c.text || '') .join('\n') ?? 'Error' text.setText(`\n${theme.fg('error', e)}`) return text } const d = res.details as DiffDetails | undefined if (d?._type === 'editInfo') { const { summary: s, editLine } = d const loc = editLine > 0 ? ` ${theme.fg('muted', `at line ${editLine}`)}` : '' text.setText(` ${s}${loc}`) return text } if (d?._type === 'multiEditInfo') { const { summary: s, editCount, diffLineCount } = d const dlInfo = typeof diffLineCount === 'number' ? ` ${theme.fg('muted', `(${diffLineCount} diff lines)`)}` : '' text.setText(` ${editCount} edits ${s}${dlInfo}`) return text } // Fallback const fb = fallbackText(res, theme) text.setText(fb ? ` ${fb}` : ` ${theme.fg('dim', 'edited')}`) return text }, }) }