repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/lua/bugabinga/pivi/render.lua
Rawlocal 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,
}