repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/lua/bugabinga/pivi/session.lua
Rawlocal rpc = require 'bugabinga.pivi.rpc'
local status = require 'bugabinga.pivi.status'
local publish = require 'bugabinga.pivi.publish'
local transcript = require 'bugabinga.pivi.transcript'
local assemble = require 'bugabinga.pivi.assemble'
local context = require 'bugabinga.pivi.context'
local dialog = require 'bugabinga.pivi.dialog'
local partial = require 'bugabinga.pivi.partial'
local activity = require 'bugabinga.pivi.activity'
local follow = require 'bugabinga.pivi.follow'
local edit = require 'bugabinga.pivi.edit'
local runtime = require 'bugabinga.pivi.runtime'
local compose = require 'bugabinga.pivi.compose'
local entry = require 'bugabinga.pivi.entry'
local render = require 'bugabinga.pivi.render'
local fold = require 'bugabinga.pivi.fold'
local tool = require 'bugabinga.pivi.tool'
local registry = {}
local counter = 0
local model = function( requested )
return requested or vim.g.pivi_model
end
local build_command = function( options )
options = options or {}
local command = { options.executable or runtime.executable() or 'pi', '--mode', 'rpc', }
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 = model( options.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
if options.session then
table.insert( command, '--session' )
table.insert( command, options.session )
end
if options.name then
table.insert( command, '--name' )
table.insert( command, options.name )
end
for _, argument in ipairs( options.arguments or {} ) do
table.insert( command, argument )
end
return command
end
local unique_name = function( cwd )
counter = counter + 1
local base = vim.fs.basename( cwd or vim.uv.cwd() or 'pi' )
local name = base
while registry[name] do
name = base .. '-' .. tostring( counter )
counter = counter + 1
end
return name
end
--- Ends the entry currently being written, if any.
local seal = function( self )
if not self.entry then return end
entry.close( self.buffer, self.entry )
render.decorate( self.buffer, self.entry )
fold.apply( self.buffer )
self.entry = nil
self.entry_kind = nil
end
local begin = function( self, kind, meta )
seal( self )
self.entry = entry.open( self.buffer, kind, meta )
self.entry_kind = kind
render.decorate( self.buffer, self.entry )
return self.entry
end
--- Records a submission as its own entry.
--- The submitted text is written exactly as typed; the quoting a reader sees
--- is decoration.
local render_submission = function( self, text )
begin( self, 'prompt', { text = text, } )
for _, piece in ipairs( vim.split( text, '\n', { plain = true, } ) ) do
entry.line( self.buffer, self.entry, piece )
end
seal( self )
end
local render_event = function( self, event )
local bufnr = self.buffer
if event.type == 'message_start' and event.message and event.message.role == 'assistant' then
seal( self )
elseif event.type == 'message_update' then
local delta = event.assistantMessageEvent or {}
if delta.type == 'text_delta' and ( delta.delta or '' ) ~= '' then
local text = delta.delta
-- Models often open with blank lines; the entry already separates turns.
if self.entry_kind ~= 'answer' then text = text:gsub( '^%s*\n', '' ) end
if text ~= '' then
if self.entry_kind ~= 'answer' then begin( self, 'answer', {} ) end
entry.write( bufnr, self.entry, text )
end
end
elseif event.type == 'tool_execution_start' then
self.streamed = nil
begin( self, 'tool', {
tool = event.toolName,
args = event.args,
call = event.toolCallId,
cwd = self.cwd,
} )
entry.line( bufnr, self.entry, tool.headline( entry.meta( bufnr, self.entry ) ) )
elseif event.type == 'tool_execution_update' then
-- Output arrives while the tool still runs, so the entry shows it now
-- rather than only once the tool ends.
if self.entry_kind == 'tool' and event.partialResult then
entry.annotate( bufnr, self.entry, { result = event.partialResult, } )
local shown = tool.body( entry.meta( bufnr, self.entry ) )
local joined = table.concat( shown, '\n' )
if joined ~= self.streamed then
self.streamed = joined
entry.rewrite( bufnr, self.entry, shown )
render.decorate( bufnr, self.entry, { head_only = true, } )
end
end
elseif event.type == 'tool_execution_end' then
if self.entry_kind == 'tool' then
entry.annotate( bufnr, self.entry, { result = event.result, failed = event.isError == true, } )
entry.rewrite( bufnr, self.entry, tool.body( entry.meta( bufnr, self.entry ) ) )
self.streamed = nil
seal( self )
end
elseif event.type == 'agent_settled' then
seal( self )
elseif event.type == 'message_end' and event.message and event.message.errorMessage then
begin( self, 'error', { error = event.message.errorMessage, } )
entry.line( bufnr, self.entry, tostring( event.message.errorMessage ) )
seal( self )
end
end
local publish_status = function( self )
publish.session_status( self.name, self.status )
end
local located = function( arguments )
if type( arguments ) ~= 'table' then return nil end
local path = arguments.path or arguments.file_path or arguments.filePath
if type( path ) ~= 'string' or path == '' then return nil end
return { path = path, line = arguments.line or arguments.lnum, }
end
--- Renders streaming edit intent as inert ghost text.
--- Nothing here mutates a buffer: complete arguments are required for that.
local ghost_intent = function( self, call )
local intent = partial.intent( call.name, call.arguments_text )
if not intent.path or not intent.text then return end
local bufnr = edit.buffer_for( intent.path )
if not bufnr then return end
self.ghost_buffer = bufnr
activity.ghost( bufnr, 0, intent.text )
end
local clear_ghost = function( self )
if self.ghost_buffer then
activity.ghost_clear( self.ghost_buffer )
self.ghost_buffer = nil
end
end
local handle_event = function( self, event )
if event.type == 'extension_ui_request' then
dialog.handle( event, function( response ) self.client.notify( response ) end )
return
end
self.status = status.reduce( self.status, event )
self.message = assemble.apply( self.message, event )
if event.type == 'message_update' then
local delta = event.assistantMessageEvent or {}
if delta.type == 'toolcall_delta' then
for _, call in ipairs( assemble.tool_calls( self.message ) ) do
if not call.complete then ghost_intent( self, call ) end
end
elseif delta.type == 'toolcall_end' then
clear_ghost( self )
end
elseif event.type == 'tool_execution_start' then
clear_ghost( self )
local location = located( event.args )
if location then follow.to( location ) end
elseif event.type == 'agent_settled' then
clear_ghost( self )
end
render_event( self, event )
for _, listener in ipairs( self.listeners ) do
pcall( listener, event, self )
end
publish_status( self )
end
--- Creates a session and starts its Pi process.
--- @param options table? name, cwd, model, session, arguments, spawn
local new = function( options )
options = options or {}
local cwd = options.cwd or vim.uv.cwd()
local name = options.name or unique_name( cwd )
local self = {
name = name,
cwd = cwd,
status = status.initial(),
message = assemble.new(),
listeners = {},
buffer = transcript.ensure( name ),
}
self.client = rpc.start {
command = build_command {
model = options.model,
session = options.session,
name = options.name,
arguments = options.arguments,
extension = options.extension,
skill = options.skill,
thinking = options.thinking,
executable = options.executable,
},
cwd = cwd,
env = options.env or runtime.bridge_environment(),
spawn = options.spawn,
on_event = function( event ) handle_event( self, event ) end,
on_stderr = function( data ) self.last_stderr = data end,
on_failure = function( failure )
vim.notify( 'pivi: ' .. failure, vim.log.levels.ERROR )
end,
on_exit = function( code )
self.status = status.stopped( self.status, code )
publish_status( self )
end,
}
self.status.connection = 'running'
publish_status( self )
registry[name] = self
return self
end
local get = function( name ) return registry[name] end
local list = function()
local names = {}
for key in pairs( registry ) do table.insert( names, key ) end
table.sort( names )
return names
end
local active = function()
local value = publish.read()
if value.active and registry[value.active] then return registry[value.active] end
local names = list()
if #names == 0 then return nil end
return registry[names[1]]
end
local set_active = function( name )
if not registry[name] then return false end
publish.set_active( name )
return true
end
--- Runs a shell command through Pi.
--- Pi keeps the output in the conversation unless asked not to, which is what
--- doubling the bang means.
local run_bash = function( self, command, exclude, callback )
vim.validate( 'command', command, 'string' )
if command == '' then return false, 'nothing to run' end
local id = begin( self, 'tool', {
tool = exclude and 'bang!' or 'bang',
args = { command = command, },
cwd = self.cwd,
} )
entry.line( self.buffer, id, tool.headline( entry.meta( self.buffer, id ) ) )
self.client.request(
{ type = 'bash', command = command, excludeFromContext = exclude == true, },
function( response )
local data = response.data or {}
entry.annotate( self.buffer, id, {
result = { content = { { type = 'text', text = data.output or '', }, }, },
failed = response.success ~= true or ( data.exitCode ~= nil and data.exitCode ~= 0 ),
exit = data.exitCode,
} )
entry.rewrite( self.buffer, id, tool.body( entry.meta( self.buffer, id ) ) )
seal( self )
if callback then callback( response ) end
end
)
return true, nil
end
--- Submits text to the session.
--- A submission during an active run must choose its delivery explicitly;
--- rejection is returned, never silently swallowed.
--- @return boolean accepted
--- @return string? rejection
local submit = function( self, text, options )
options = options or {}
vim.validate( 'text', text, 'string' )
if not self.client.is_running() then
return false, 'pi process is not running'
end
local busy = status.is_busy( self.status )
local delivery = options.delivery
if busy and not delivery then
return false, 'pi is running; use :Pi steer <text>, :Pi follow-up <text>, or :Pi abort'
end
if delivery and delivery ~= 'steer' and delivery ~= 'follow_up' then
return false, 'unknown delivery "' .. tostring( delivery ) .. '"; use steer or follow_up'
end
-- A written prompt may not be a prompt at all.
if options.compose ~= false then
local intent = compose.parse( text )
if intent.kind == 'bash' then
return run_bash( self, intent.command, intent.exclude )
end
end
local snapshot = options.snapshot
if snapshot == nil and options.capture ~= false then
snapshot = context.snapshot( options.source )
end
local expanded, images, missing = text, nil, {}
if options.compose ~= false then
expanded, images, missing = compose.expand( text, { cwd = self.cwd, } )
for _, reference in ipairs( missing ) do
vim.notify( 'pivi: cannot read @' .. reference, vim.log.levels.WARN )
end
end
local message = expanded
if snapshot then
message = context.render( snapshot ) .. expanded
end
render_submission( self, text )
local command
if delivery == 'steer' then
command = { type = 'steer', message = message, }
elseif delivery == 'follow_up' then
command = { type = 'follow_up', message = message, }
else
command = { type = 'prompt', message = message, }
end
if images and #images > 0 then command.images = images end
table.insert( self.submissions, { text = text, delivery = delivery, snapshot = snapshot, } )
self.client.request( command, function( response )
if response.success then return end
local rejected = begin( self, 'error', { error = response.error, } )
entry.line( self.buffer, rejected, tostring( response.error or 'submission rejected' ) )
seal( self )
self.status = vim.tbl_extend( 'force', self.status, { error = response.error, } )
publish_status( self )
end )
return true, nil
end
local request = function( self, command, callback )
return self.client.request( command, callback )
end
local abort = function( self, callback )
return self.client.request( { type = 'abort', }, callback )
end
local clear_queue = function( self, callback )
return self.client.request( { type = 'clear_queue', }, function( response )
if response.success and response.data then
self.status = status.reduce( self.status, {
type = 'queue_update',
steering = {},
followUp = {},
} )
publish_status( self )
end
if callback then callback( response ) end
end )
end
local refresh = function( self, callback )
return self.client.request( { type = 'get_state', }, function( response )
if response.success then
self.status = status.reduce_state( self.status, response.data )
publish_status( self )
end
if callback then callback( response ) end
end )
end
local stop = function( self )
self.client.stop()
registry[self.name] = nil
publish.remove( self.name )
end
local on = function( self, listener )
table.insert( self.listeners, listener )
return function()
for index, candidate in ipairs( self.listeners ) do
if candidate == listener then
table.remove( self.listeners, index )
return
end
end
end
end
local stop_all = function()
for _, name in ipairs( list() ) do
local live = registry[name]
if live then stop( live ) end
end
end
local wrap = function( self )
self.submissions = self.submissions or {}
self.submit = function( text, options ) return submit( self, text, options ) end
self.request = function( command, callback ) return request( self, command, callback ) end
self.bash = function( command, exclude, callback ) return run_bash( self, command, exclude, callback ) end
self.abort = function( callback ) return abort( self, callback ) end
self.clear_queue = function( callback ) return clear_queue( self, callback ) end
self.refresh = function( callback ) return refresh( self, callback ) end
self.stop = function() return stop( self ) end
self.on = function( listener ) return on( self, listener ) end
return self
end
local create = function( options ) return wrap( new( options ) ) end
return {
create = create,
get = get,
list = list,
active = active,
set_active = set_active,
stop_all = stop_all,
build_command = build_command,
}