local entry = require 'bugabinga.pivi.entry' local transcript = require 'bugabinga.pivi.transcript' local tool = require 'bugabinga.pivi.tool' local glyph = require 'std.icon' require 'bugabinga.pivi.highlight' local NAMESPACE = 'pivi-render' --- How each entry kind is framed. --- Framing is decoration, never buffer text, so the transcript keeps the --- message exactly as it was authored and copying it stays faithful. --- A user message is the thing a reader scrolls back to find, so it is the --- only one carrying a background and a solid bar. An answer is the ordinary --- text of the buffer and takes no colour at all. Tool work recedes. local FRAMES = { prompt = { label = glyph.user .. ' you', label_hl = 'PiviUserLabel', prefix = '\u{2588} ', prefix_hl = 'PiviUserBar', line_hl = 'PiviUser', every_line = true, }, answer = { label = glyph.star .. ' pi', label_hl = 'PiviAssistantLabel', }, tool = { prefix = '\u{25b8} ', prefix_hl = 'PiviToolIcon', line_hl = 'PiviTool', }, error = { prefix = glyph.error .. ' ', prefix_hl = 'PiviErrorIcon', line_hl = 'PiviError', }, } local namespace = function() return vim.api.nvim_create_namespace( NAMESPACE ) end local clear_range = function( bufnr, first, last ) vim.api.nvim_buf_clear_namespace( bufnr, namespace(), first - 1, last ) end --- Presents a tool's output in the language it came from. --- The output is parsed as a standalone string and the result is applied as --- decoration, so the transcript text stays exactly what the tool produced. local speak = function( bufnr, item, lang ) if item.last <= item.first then return false end local text = table.concat( vim.api.nvim_buf_get_lines( bufnr, item.first, item.last, false ), '\n' ) if text == '' then return false end local parsed, parser = pcall( vim.treesitter.get_string_parser, text, lang ) if not parsed or not parser then return false end local trees = parser:parse( true ) if not trees or not trees[1] then return false end local query = vim.treesitter.query.get( lang, 'highlights' ) if not query then return false end local offset = item.first for id, node in query:iter_captures( trees[1]:root(), text ) do local first_row, first_column, last_row, last_column = node:range() pcall( vim.api.nvim_buf_set_extmark, bufnr, namespace(), offset + first_row, first_column, { end_row = offset + last_row, end_col = last_column, hl_group = '@' .. query.captures[id] .. '.' .. lang, priority = 90, } ) end return true end --- Decorates one entry. --- Only the entry's own lines are touched, so decoration cost stays --- proportional to the entry rather than to the whole transcript. --- @param options table? head_only limits decoration to the opening line local decorate = function( bufnr, id, options ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end local item = nil for _, candidate in ipairs( entry.list( bufnr ) ) do if candidate.id == id then item = candidate end end if not item then return end local frame = FRAMES[item.kind] if not frame then return end clear_range( bufnr, item.first, item.last ) if frame.label then pcall( vim.api.nvim_buf_set_extmark, bufnr, namespace(), item.first - 1, 0, { virt_lines = { { { frame.label, frame.label_hl, }, }, }, virt_lines_above = true, right_gravity = false, priority = 60, } ) end -- What a tool reported about its own work, beside what it acted on. if item.kind == 'tool' then local ended = tool.outcome( item.meta ) if ended then pcall( vim.api.nvim_buf_set_extmark, bufnr, namespace(), item.first - 1, 0, { virt_text = { { ' ' .. ended, item.meta.failed and 'PiviError' or 'PiviToolOutcome', }, }, virt_text_pos = 'eol', right_gravity = false, priority = 60, } ) end end if not frame.prefix and not frame.line_hl then return end -- A reported change reads as a change: added and removed lines stay distinct -- from the dimmed body around them. local change = item.kind == 'tool' and tool.diff( item.meta ) ~= nil -- While a tool still reports progress its body is rewritten repeatedly, so -- only its opening line is decorated until it finishes. local final_row = options and options.head_only and item.first - 1 or item.last - 1 -- A tool entry shows what kind of work it was rather than a generic marker. local opening = item.kind == 'tool' and ( tool.icon( item.meta ) .. ' ' ) or frame.prefix for row = item.first - 1, final_row do local heads = row == item.first - 1 local prefix = frame.prefix and ( heads or frame.every_line ) and frame.prefix or nil if heads and opening then prefix = opening end local line_hl = frame.line_hl if change and not heads then local text = vim.api.nvim_buf_get_lines( bufnr, row, row + 1, false )[1] line_hl = tool.diff_highlight( text ) or line_hl end pcall( vim.api.nvim_buf_set_extmark, bufnr, namespace(), row, 0, { virt_text = prefix and { { prefix, frame.prefix_hl, }, } or nil, virt_text_pos = prefix and 'inline' or nil, line_hl_group = line_hl, right_gravity = false, priority = 60, } ) end if item.kind == 'tool' and not ( options and options.head_only ) then local lang = tool.language( item.meta ) if lang then speak( bufnr, item, lang ) end end end --- Renders message formatting, and presents embedded code in its own language. --- Parsing is driven by redraw, never forced per streamed delta, because --- forcing it costs orders of magnitude more on a growing transcript. local highlight = function( bufnr, language ) if not vim.api.nvim_buf_is_valid( bufnr ) then return false end return ( pcall( vim.treesitter.start, bufnr, language or 'markdown' ) ) end --- Whether formatting is being rendered rather than shown as markup. local highlighted = function( bufnr ) local active = vim.treesitter.highlighter.active or {} return active[bufnr] ~= nil end local conceal = function( win ) vim.wo[win].conceallevel = 2 vim.wo[win].concealcursor = 'nc' 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(), 0, -1, { details = true, } ) end local clear = function( bufnr ) if not vim.api.nvim_buf_is_valid( bufnr ) then return end vim.api.nvim_buf_clear_namespace( bufnr, namespace(), 0, -1 ) end transcript.on_reset( clear ) transcript.on_buffer( highlight ) transcript.on_window( conceal ) return { namespace = namespace, frames = FRAMES, decorate = decorate, speak = speak, highlight = highlight, highlighted = highlighted, conceal = conceal, marks = marks, clear = clear, }