local rpc = require 'bugabinga.pivi.rpc' local transcript = require 'bugabinga.pivi.transcript' local context = require 'bugabinga.pivi.context' local runtime = require 'bugabinga.pivi.runtime' local entry = require 'bugabinga.pivi.entry' local render = require 'bugabinga.pivi.render' local BUFFER_NAME = 'help' --- Read-only tools. A help run answers about the editor; it never changes it. local TOOLS = 'read,grep,find,ls,nvim_help,nvim_state,nvim_lua' local ORIENTATION = table.concat( { 'You answer one short question about Neovim, about pivi, or about Pi itself.', 'The question carries what the user currently sees in Neovim.', 'Use nvim_help for Neovim and plugin documentation instead of recalling it.', 'Answer directly and briefly, and change nothing.', }, ' ' ) local build_command = function( options ) options = options or {} local command = { options.executable or runtime.executable() or 'pi', '--mode', 'rpc', '--no-session', '--tools', TOOLS, '--append-system-prompt', ORIENTATION, } local extension = options.extension ~= false and runtime.extension_path() or nil if extension then table.insert( command, '-e' ) table.insert( command, extension ) end local skill = options.skill ~= false and runtime.skill_path() or nil if skill then table.insert( command, '--skill' ) table.insert( command, skill ) end local chosen = options.model or vim.g.pivi_help_model or vim.g.pivi_model if chosen then table.insert( command, '--model' ) table.insert( command, chosen ) end local thinking = options.thinking or vim.g.pivi_thinking if thinking then table.insert( command, '--thinking' ) table.insert( command, thinking ) end for _, argument in ipairs( options.arguments or {} ) do table.insert( command, argument ) end return command end --- Builds the question sent to the ephemeral run. --- The preamble describes the visible editor, not the whole project. local compose = function( question, options ) if options and options.view == false then return question end local ok, view = pcall( context.visible ) if not ok or not view then return question end return context.render_visible( view ) .. question end --- Answers a question in an independent ephemeral run. --- It owns its own process, buffer, and lifetime, so no working session's --- history, queue, or status is touched. --- @param question string --- @param options table? model, arguments, spawn, view, on_done local ask = function( question, options ) vim.validate( 'question', question, 'string' ) options = options or {} local bufnr = transcript.ensure( BUFFER_NAME ) transcript.clear( bufnr ) local run = { buffer = bufnr, done = false, answer = '', } --- Entries here follow the transcript contract: the buffer holds the text as --- authored, and everything a reader sees around it is decoration. local begin = function( kind, meta ) if run.entry then entry.close( bufnr, run.entry ) end run.entry = entry.open( bufnr, kind, meta ) run.kind = kind render.decorate( bufnr, run.entry ) return run.entry end local seal = function() if not run.entry then return end entry.close( bufnr, run.entry ) render.decorate( bufnr, run.entry ) run.entry = nil run.kind = nil end begin( 'prompt', { text = question, } ) for _, piece in ipairs( vim.split( question, '\n', { plain = true, } ) ) do entry.line( bufnr, run.entry, piece ) end seal() -- The window must exist before its state can be shown. transcript.show( bufnr ) local state = function( text ) transcript.set_state( bufnr, BUFFER_NAME, text ) end state 'thinking' run.client = rpc.start { command = build_command( options ), cwd = options.cwd or vim.uv.cwd(), env = options.env or runtime.bridge_environment(), spawn = options.spawn, on_event = function( event ) if event.type == 'message_update' then local delta = event.assistantMessageEvent or {} if delta.type == 'text_delta' and ( delta.delta or '' ) ~= '' then local text = delta.delta if run.kind ~= 'answer' then text = text:gsub( '^%s*\n', '' ) if text ~= '' then begin( 'answer', {} ) state 'answering' end end if text ~= '' then run.answer = run.answer .. text entry.write( bufnr, run.entry, text ) end end elseif event.type == 'tool_execution_start' then local subject = type( event.args ) == 'table' and ( event.args.tag or event.args.path or event.args.pattern ) or nil local described = tostring( event.toolName ) if type( subject ) == 'string' and subject ~= '' then described = described .. ' ' .. subject end state( described ) begin( 'tool', { tool = event.toolName, args = event.args, call = event.toolCallId, } ) entry.line( bufnr, run.entry, described ) seal() elseif event.type == 'message_end' and event.message and event.message.errorMessage then begin( 'error', { error = event.message.errorMessage, } ) entry.line( bufnr, run.entry, tostring( event.message.errorMessage ) ) seal() elseif event.type == 'agent_settled' then run.done = true seal() state( nil ) run.client.stop() if options.on_done then options.on_done( run ) end end end, on_exit = function() run.done = true seal() state( nil ) end, } run.client.request( { type = 'prompt', message = compose( question, options ), }, function( response ) if response.success then return end begin( 'error', { error = response.error, } ) entry.line( bufnr, run.entry, tostring( response.error or 'help run rejected' ) ) seal() run.done = true state( nil ) run.client.stop() if options.on_done then options.on_done( run ) end end ) return run end return { buffer_name = BUFFER_NAME, tools = TOOLS, orientation = ORIENTATION, build_command = build_command, compose = compose, ask = ask, }