local transcript = require 'bugabinga.pivi.transcript' local NAMESPACE = 'pivi-entry' local KINDS = { prompt = true, answer = true, tool = true, error = true, } local records = {} local versions = {} --- Bumped whenever the entries of a buffer change shape. --- Buffer text alone is not enough to know that: re-anchoring an entry moves --- its bounds without touching a single character. local version = function( bufnr ) return versions[bufnr] or 0 end local touch = function( bufnr ) versions[bufnr] = version( bufnr ) + 1 end local namespace = function() return vim.api.nvim_create_namespace( NAMESPACE ) end local of_buffer = function( bufnr ) records[bufnr] = records[bufnr] or {} return records[bufnr] end local last_row = function( bufnr ) return vim.api.nvim_buf_line_count( bufnr ) - 1 end local text_of = function( bufnr, row ) return vim.api.nvim_buf_get_lines( bufnr, row, row + 1, false )[1] or '' end --- Anchors an entry's range to the buffer itself. --- Fixed line numbers would drift as later entries are appended, so bounds --- are extmark-backed and re-anchored whenever the entry grows. local anchor = function( bufnr, id, first, last ) touch( bufnr ) vim.api.nvim_buf_set_extmark( bufnr, namespace(), first, 0, { id = id, end_row = last, end_col = #text_of( bufnr, last ), right_gravity = false, end_right_gravity = true, } ) end --- Starts an entry at the end of the transcript. --- An entry always begins on its own line, so its bounds never share a line --- with the entry before it. --- @param bufnr number --- @param kind string prompt, answer, tool, or error --- @param meta table? entry metadata such as tool, path, line, args --- @return number? id local open = function( bufnr, kind, meta ) vim.validate( 'bufnr', bufnr, 'number' ) vim.validate( 'kind', kind, 'string' ) if not KINDS[kind] then error( 'unknown entry kind: ' .. kind ) end if not vim.api.nvim_buf_is_valid( bufnr ) then return nil end -- Virtual lines above the very first buffer line are not shown once that -- line is at the top of a window, so an entry never starts on line one. if last_row( bufnr ) == 0 or text_of( bufnr, last_row( bufnr ) ) ~= '' then transcript.line( bufnr, '' ) end local row = last_row( bufnr ) local id = vim.api.nvim_buf_set_extmark( bufnr, namespace(), row, 0, { end_row = row, end_col = 0, right_gravity = false, end_right_gravity = true, } ) of_buffer( bufnr )[id] = { id = id, kind = kind, meta = meta or {}, } touch( bufnr ) return id end local start_row = function( bufnr, id ) local mark = vim.api.nvim_buf_get_extmark_by_id( bufnr, namespace(), id, {} ) if not mark or #mark == 0 then return nil end return mark[1] end --- The last row an entry actually shows. --- Appending a complete line leaves an empty row behind it, and that row is --- where the next entry starts rather than part of this one. local content_row = function( bufnr, first ) local row = last_row( bufnr ) while row > first and text_of( bufnr, row ) == '' do row = row - 1 end return row end --- Re-anchors an entry to everything written since it opened. local extend = function( bufnr, id ) if not id or not vim.api.nvim_buf_is_valid( bufnr ) then return end if not of_buffer( bufnr )[id] then return end local first = start_row( bufnr, id ) if not first then return end anchor( bufnr, id, first, content_row( bufnr, first ) ) end --- Appends text to an open entry, continuing its final line. local write = function( bufnr, id, text ) if not id then return end transcript.append( bufnr, text ) extend( bufnr, id ) end --- Appends one complete line to an open entry. local line = function( bufnr, id, text ) if not id then return end transcript.line( bufnr, text ) extend( bufnr, id ) end --- Replaces everything an entry shows below its opening line. --- A tool that reports progress rewrites its own tail, so the entry keeps its --- identity and its bounds while its content changes. local rewrite = function( bufnr, id, text ) if not id or not vim.api.nvim_buf_is_valid( bufnr ) then return end if not of_buffer( bufnr )[id] then return end local first = start_row( bufnr, id ) if not first then return end transcript.replace( bufnr, first + 1, content_row( bufnr, first ) + 1, text ) extend( bufnr, id ) end local close = function( bufnr, id ) extend( bufnr, id ) return nil end --- @return number? first 1-based first line --- @return number? last 1-based last line local range = function( bufnr, id ) if not vim.api.nvim_buf_is_valid( bufnr ) then return nil end local mark = vim.api.nvim_buf_get_extmark_by_id( bufnr, namespace(), id, { details = true, } ) if not mark or #mark == 0 then return nil end local details = mark[3] or {} return mark[1] + 1, ( details.end_row or mark[1] ) + 1 end local describe = function( bufnr, record ) local first, last = range( bufnr, record.id ) if not first then return nil end return { id = record.id, kind = record.kind, meta = record.meta, first = first, last = last, } end --- Every entry of a transcript, in buffer order. local list = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return {} end local found = {} for _, record in pairs( of_buffer( bufnr ) ) do local described = describe( bufnr, record ) if described then table.insert( found, described ) end end table.sort( found, function( left, right ) return left.first < right.first end ) return found end --- The entry covering a 1-based line, if any. local at = function( bufnr, line_number ) local found for _, entry in ipairs( list( bufnr ) ) do if entry.first <= line_number and line_number <= entry.last then found = entry end end return found end local meta = function( bufnr, id ) local record = of_buffer( bufnr )[id] return record and record.meta or nil end --- Replaces an entry's metadata fields, leaving its text untouched. local annotate = function( bufnr, id, fields ) local record = of_buffer( bufnr )[id] if not record then return end record.meta = vim.tbl_extend( 'force', record.meta, fields or {} ) end local clear = function( bufnr ) records[bufnr] = nil versions[bufnr] = nil if vim.api.nvim_buf_is_valid( bufnr ) then vim.api.nvim_buf_clear_namespace( bufnr, namespace(), 0, -1 ) end end transcript.on_reset( clear ) return { namespace = namespace, version = version, kinds = KINDS, open = open, write = write, line = line, rewrite = rewrite, extend = extend, close = close, range = range, list = list, at = at, meta = meta, annotate = annotate, clear = clear, }