local publish = require 'bugabinga.pivi.publish' local TRAIL_NAMESPACE = 'pivi-trail' local GHOST_NAMESPACE = 'pivi-ghost' local DIAGNOSTIC_NAMESPACE = 'pivi-diagnostic' local SIGN_GROUP = 'pivi' local namespaces = {} local namespace = function( name ) if not namespaces[name] then namespaces[name] = vim.api.nvim_create_namespace( name ) end return namespaces[name] end local diagnostic_namespace = function() return namespace( DIAGNOSTIC_NAMESPACE ) end local trail = {} local revisions = {} local groups = {} local clear_trail local unwatch = function( bufnr ) revisions[bufnr] = nil if groups[bufnr] then pcall( vim.api.nvim_del_augroup_by_id, groups[bufnr] ) groups[bufnr] = nil end end --- Records the current revision as agent-owned. --- Whatever the edit triggers in the same loop iteration, such as a formatter --- on write, still belongs to the agent's operation rather than to the user. local touch = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end revisions[bufnr] = vim.api.nvim_buf_get_changedtick( bufnr ) vim.schedule( function() if revisions[bufnr] and vim.api.nvim_buf_is_valid( bufnr ) then revisions[bufnr] = vim.api.nvim_buf_get_changedtick( bufnr ) end end ) end --- Clears a buffer's trail as soon as the user changes that buffer themselves. --- Agent edits record their own revision, so only a foreign change clears it. local watch = function( bufnr ) touch( bufnr ) if groups[bufnr] then return end groups[bufnr] = vim.api.nvim_create_augroup( 'pivi_trail_' .. tostring( bufnr ), { clear = true, } ) vim.api.nvim_create_autocmd( { 'TextChanged', 'TextChangedI', }, { group = groups[bufnr], buffer = bufnr, desc = 'Clear the pivi edit trail once the user changes the buffer.', callback = function() if not vim.api.nvim_buf_is_valid( bufnr ) then unwatch( bufnr ) return true end if vim.api.nvim_buf_get_changedtick( bufnr ) == revisions[bufnr] then return end clear_trail( bufnr ) return true end, } ) end --- Marks changed lines so the agent's work stays navigable. --- Marks live in pivi's own namespace and never touch diagnostic or --- language server namespaces. local mark_edit = function( bufnr, ranges ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end local ns = namespace( TRAIL_NAMESPACE ) for _, range in ipairs( ranges or {} ) do local first = math.max( 0, range.first ) local last = math.max( first + 1, range.last ) local count = vim.api.nvim_buf_line_count( bufnr ) for line = first, math.min( last, count ) - 1 do pcall( vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { line_hl_group = 'DiffChange', sign_text = '│', sign_hl_group = 'DiffChange', priority = 90, } ) end table.insert( trail, { buffer = bufnr, first = first + 1, last = math.min( last, count ), } ) end watch( bufnr ) publish.activity { kind = 'edit', buffer = bufnr, ranges = ranges, } end local marks = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end return vim.api.nvim_buf_get_extmarks( bufnr, namespace( TRAIL_NAMESPACE ), 0, -1, {} ) end clear_trail = function( bufnr ) if bufnr and vim.api.nvim_buf_is_valid( bufnr ) then vim.api.nvim_buf_clear_namespace( bufnr, namespace( TRAIL_NAMESPACE ), 0, -1 ) end if bufnr then unwatch( bufnr ) else for buffer in pairs( groups ) do unwatch( buffer ) end end trail = {} end local trail_entries = function() return vim.deepcopy( trail ) end --- Shows streaming edit intent without touching buffer contents. local ghost = function( bufnr, line, text ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end if type( text ) ~= 'string' or text == '' then return end local ns = namespace( GHOST_NAMESPACE ) local count = vim.api.nvim_buf_line_count( bufnr ) local target = math.min( math.max( 0, line ), math.max( 0, count - 1 ) ) vim.api.nvim_buf_clear_namespace( bufnr, ns, 0, -1 ) local chunks = {} for _, piece in ipairs( vim.split( text, '\n', { plain = true, } ) ) do table.insert( chunks, { { piece, 'Comment', }, } ) end pcall( vim.api.nvim_buf_set_extmark, bufnr, ns, target, 0, { virt_lines = chunks, virt_lines_above = false, priority = 100, } ) end local ghost_marks = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end return vim.api.nvim_buf_get_extmarks( bufnr, namespace( GHOST_NAMESPACE ), 0, -1, {} ) end local ghost_clear = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end vim.api.nvim_buf_clear_namespace( bufnr, namespace( GHOST_NAMESPACE ), 0, -1 ) end local to_quickfix_item = function( item ) return { filename = item.path, bufnr = item.path and nil or item.buffer, lnum = item.line or 1, col = ( item.column or 0 ) + 1, text = item.text or '', type = item.severity == 'error' and 'E' or item.severity == 'warning' and 'W' or 'I', } end --- Cross-file findings use the quickfix list. local findings = function( items, title ) local entries = vim.tbl_map( to_quickfix_item, items or {} ) vim.fn.setqflist( {}, ' ', { title = title or 'pivi', items = entries, } ) publish.activity { kind = 'findings', count = #entries, } return entries end --- Window associated findings use the location list. local window_findings = function( win, items, title ) local entries = vim.tbl_map( to_quickfix_item, items or {} ) vim.fn.setloclist( win or 0, {}, ' ', { title = title or 'pivi', items = entries, } ) return entries end local severity_of = function( value ) if value == 'error' then return vim.diagnostic.severity.ERROR end if value == 'warning' then return vim.diagnostic.severity.WARN end if value == 'hint' then return vim.diagnostic.severity.HINT end return vim.diagnostic.severity.INFO end --- Agent diagnostics stay distinguishable from compiler and language server output. local diagnose = function( bufnr, items ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end local diagnostics = {} for _, item in ipairs( items or {} ) do table.insert( diagnostics, { lnum = math.max( 0, ( item.line or 1 ) - 1 ), col = item.column or 0, message = item.text or '', severity = severity_of( item.severity ), source = 'pi', } ) end vim.diagnostic.set( diagnostic_namespace(), bufnr, diagnostics ) end local diagnostics = function( bufnr ) return vim.diagnostic.get( bufnr, { namespace = diagnostic_namespace(), } ) end local clear_diagnostics = function( bufnr ) vim.diagnostic.reset( diagnostic_namespace(), bufnr ) end local clear_all = function( bufnr ) ghost_clear( bufnr ) clear_trail( bufnr ) clear_diagnostics( bufnr ) end return { sign_group = SIGN_GROUP, namespace = namespace, touch = touch, trail_namespace = TRAIL_NAMESPACE, ghost_namespace = GHOST_NAMESPACE, diagnostic_namespace = diagnostic_namespace, mark_edit = mark_edit, marks = marks, trail = trail_entries, clear_trail = clear_trail, ghost = ghost, ghost_marks = ghost_marks, ghost_clear = ghost_clear, findings = findings, window_findings = window_findings, diagnose = diagnose, diagnostics = diagnostics, clear_diagnostics = clear_diagnostics, clear_all = clear_all, }