local SURROUNDING_LINES = 40 local VISIBLE_LINES = 120 local MESSAGE_LINES = 20 local freeze = function( value ) if type( value ) ~= 'table' then return value end return setmetatable( {}, { __index = value, __newindex = function() error 'pivi context snapshots are immutable' end, __len = function() return #value end, __pairs = function() return pairs( value ) end, } ) end local function deep_freeze( value ) if type( value ) ~= 'table' then return value end local frozen = {} for key, item in pairs( value ) do frozen[key] = deep_freeze( item ) end return freeze( frozen ) end --- Resolves the current or last visual selection into a line and column range. local selection_range = function() local mode = vim.api.nvim_get_mode().mode local visual = mode:sub( 1, 1 ) == 'v' or mode:sub( 1, 1 ) == 'V' or mode:sub( 1, 1 ) == '\22' local start_position = vim.fn.getpos( visual and 'v' or "'<" ) local end_position = vim.fn.getpos( visual and '.' or "'>" ) if start_position[2] == 0 or end_position[2] == 0 then return nil end local first = { start_position[2], start_position[3] - 1, } local last = { end_position[2], end_position[3] - 1, } if first[1] > last[1] or ( first[1] == last[1] and first[2] > last[2] ) then first, last = last, first end return { kind = visual and mode or 'mark', first = first, last = last, } end local text_between = function( bufnr, range ) local lines = vim.api.nvim_buf_get_lines( bufnr, range.first[1] - 1, range.last[1], false ) return table.concat( lines, '\n' ) end --- Captures an immutable snapshot of the submission's originating editor state. --- @param source table? bufnr and window override local snapshot = function( source ) source = source or {} local bufnr = source.bufnr or vim.api.nvim_get_current_buf() if not vim.api.nvim_buf_is_valid( bufnr ) then return nil end if vim.bo[bufnr].buftype ~= '' and vim.bo[bufnr].buftype ~= 'acwrite' then return nil end local win = source.win or vim.api.nvim_get_current_win() local cursor = { 1, 0, } if vim.api.nvim_win_is_valid( win ) and vim.api.nvim_win_get_buf( win ) == bufnr then cursor = vim.api.nvim_win_get_cursor( win ) end local total = vim.api.nvim_buf_line_count( bufnr ) local first = math.max( 1, cursor[1] - math.floor( SURROUNDING_LINES / 2 ) ) local last = math.min( total, first + SURROUNDING_LINES - 1 ) local surrounding = table.concat( vim.api.nvim_buf_get_lines( bufnr, first - 1, last, false ), '\n' ) local range = source.range local selected if range then selected = text_between( bufnr, range ) end return deep_freeze { buffer = bufnr, path = vim.api.nvim_buf_get_name( bufnr ), filetype = vim.bo[bufnr].filetype, revision = vim.api.nvim_buf_get_changedtick( bufnr ), modified = vim.bo[bufnr].modified, line_count = total, cursor = { cursor[1], cursor[2], }, range = range, selection_kind = range and range.kind or nil, selected_text = selected, surrounding = surrounding, surrounding_first_line = first, surrounding_last_line = last, } end --- Renders a snapshot as a stable prompt preamble. local render = function( shot ) if not shot then return '' end local lines = { '', } table.insert( lines, 'path: ' .. ( shot.path ~= '' and shot.path or '[No Name]' ) ) table.insert( lines, 'revision: ' .. tostring( shot.revision ) ) table.insert( lines, 'cursor: ' .. tostring( shot.cursor[1] ) .. ':' .. tostring( shot.cursor[2] ) ) if shot.modified then table.insert( lines, 'unsaved: true' ) end if shot.selected_text then table.insert( lines, 'selection (' .. tostring( shot.selection_kind ) .. '):' ) table.insert( lines, shot.selected_text ) else table.insert( lines, 'around cursor (lines ' .. tostring( shot.surrounding_first_line ) .. '-' .. tostring( shot.surrounding_last_line ) .. '):' ) table.insert( lines, shot.surrounding ) end table.insert( lines, '' ) table.insert( lines, '' ) return table.concat( lines, '\n' ) end --- Ambient core editor state, fetched on demand rather than attached to every prompt. local editor = function() local buffers = {} for _, bufnr in ipairs( vim.api.nvim_list_bufs() ) do if vim.api.nvim_buf_is_loaded( bufnr ) then table.insert( buffers, { buffer = bufnr, path = vim.api.nvim_buf_get_name( bufnr ), filetype = vim.bo[bufnr].filetype, modified = vim.bo[bufnr].modified, line_count = vim.api.nvim_buf_line_count( bufnr ), revision = vim.api.nvim_buf_get_changedtick( bufnr ), } ) end end local windows = {} for _, win in ipairs( vim.api.nvim_list_wins() ) do table.insert( windows, { window = win, buffer = vim.api.nvim_win_get_buf( win ), cursor = vim.api.nvim_win_get_cursor( win ), tabpage = vim.api.nvim_win_get_tabpage( win ), } ) end return { cwd = vim.uv.cwd(), mode = vim.api.nvim_get_mode().mode, current_buffer = vim.api.nvim_get_current_buf(), current_window = vim.api.nvim_get_current_win(), buffers = buffers, windows = windows, } end local messages = function() local ok, text = pcall( vim.api.nvim_exec2, 'messages', { output = true, } ) if not ok or type( text.output ) ~= 'string' then return {} end local lines = vim.split( text.output, '\n', { plain = true, } ) local first = math.max( 1, #lines - MESSAGE_LINES + 1 ) return vim.list_slice( lines, first, #lines ) end local window_entry = function( win, current ) local bufnr = vim.api.nvim_win_get_buf( win ) local name = vim.api.nvim_buf_get_name( bufnr ) return { window = win, buffer = bufnr, path = name ~= '' and name or nil, filetype = vim.bo[bufnr].filetype, current = win == current, } end --- Captures what the user is actually looking at right now. --- Only the visible region of the current window is read, never whole buffers, --- so an ephemeral question stays small. local visible = function() local win = vim.api.nvim_get_current_win() local bufnr = vim.api.nvim_win_get_buf( win ) local view = vim.api.nvim_win_call( win, function() return { vim.fn.line 'w0', vim.fn.line 'w$', } end ) local count = vim.api.nvim_buf_line_count( bufnr ) local first, last = view[1], view[2] first = math.max( 1, first or 1 ) last = math.min( count, math.max( first, last or first ) ) if last - first + 1 > VISIBLE_LINES then last = first + VISIBLE_LINES - 1 end local windows = {} for _, other in ipairs( vim.api.nvim_tabpage_list_wins( 0 ) ) do table.insert( windows, window_entry( other, win ) ) end local range = selection_range() local selected if range and vim.api.nvim_buf_is_valid( bufnr ) then local ok, text = pcall( text_between, bufnr, range ) if ok then selected = text end end local name = vim.api.nvim_buf_get_name( bufnr ) return { cwd = vim.uv.cwd(), mode = vim.api.nvim_get_mode().mode, buffer = bufnr, path = name ~= '' and name or nil, filetype = vim.bo[bufnr].filetype, modified = vim.bo[bufnr].modified, cursor = vim.api.nvim_win_get_cursor( win ), first_line = first, last_line = last, line_count = count, lines = vim.api.nvim_buf_get_lines( bufnr, first - 1, last, false ), selection_kind = range and range.kind or nil, selected_text = selected, windows = windows, tabpages = #vim.api.nvim_list_tabpages(), messages = messages(), error = vim.v.errmsg ~= '' and vim.v.errmsg or nil, } end --- Renders the visible state as a bounded preamble for an ephemeral question. local render_visible = function( state ) if not state then return '' end local lines = { '', } table.insert( lines, 'cwd: ' .. tostring( state.cwd ) ) table.insert( lines, 'mode: ' .. tostring( state.mode ) ) table.insert( lines, 'buffer: ' .. ( state.path or '[No Name]' ) .. ' (' .. ( state.filetype ~= '' and state.filetype or 'no filetype' ) .. ')' .. ( state.modified and ' unsaved' or '' ) ) table.insert( lines, 'cursor: ' .. tostring( state.cursor[1] ) .. ':' .. tostring( state.cursor[2] ) ) if #state.windows > 1 or state.tabpages > 1 then local described = {} for _, entry in ipairs( state.windows ) do local label = entry.path and vim.fs.basename( entry.path ) or ( entry.filetype ~= '' and entry.filetype or 'scratch' ) table.insert( described, entry.current and ( '[' .. label .. ']' ) or label ) end table.insert( lines, 'windows: ' .. table.concat( described, ' ' ) .. ( state.tabpages > 1 and ( ' tabs: ' .. tostring( state.tabpages ) ) or '' ) ) end if state.selected_text then table.insert( lines, 'selection (' .. tostring( state.selection_kind ) .. '):' ) table.insert( lines, state.selected_text ) end table.insert( lines, 'on screen (lines ' .. tostring( state.first_line ) .. '-' .. tostring( state.last_line ) .. ' of ' .. tostring( state.line_count ) .. '):' ) for offset, line in ipairs( state.lines ) do table.insert( lines, string.format( '%d\t%s', state.first_line + offset - 1, line ) ) end if state.error then table.insert( lines, 'last error: ' .. state.error ) end local recent = {} for _, message in ipairs( state.messages ) do if message ~= '' then table.insert( recent, message ) end end if #recent > 0 then table.insert( lines, 'recent messages:' ) for _, message in ipairs( recent ) do table.insert( lines, ' ' .. message ) end end table.insert( lines, '' ) table.insert( lines, '' ) return table.concat( lines, '\n' ) end return { visible_lines = VISIBLE_LINES, snapshot = snapshot, render = render, visible = visible, render_visible = render_visible, editor = editor, selection_range = selection_range, }