Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/pivi/render.lua

Raw
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,
}