repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/lua/bugabinga/pivi/command.lua
Rawlocal session = require 'bugabinga.pivi.session'
local help = require 'bugabinga.pivi.help'
local transcript = require 'bugabinga.pivi.transcript'
local context = require 'bugabinga.pivi.context'
local follow = require 'bugabinga.pivi.follow'
local publish = require 'bugabinga.pivi.publish'
local runtime = require 'bugabinga.pivi.runtime'
local comparison = require 'bugabinga.pivi.diff'
local guide = require 'bugabinga.pivi.guide'
local layout = require 'bugabinga.pivi.layout'
local compose = require 'bugabinga.pivi.compose'
local INPUT_FILETYPE = 'pivi-input'
local notify = function( message, level )
vim.notify( 'pivi: ' .. message, level or vim.log.levels.INFO )
end
local fetch_commands = function( current, callback )
current.request( { type = 'get_commands', }, function( response )
local commands = {}
if response.success and response.data then
for _, entry in ipairs( response.data.commands or {} ) do
table.insert( commands, entry )
end
end
current.commands = commands
if callback then callback( commands ) end
end )
end
--- Returns the active session, starting one when none exists.
--- A missing or unusable Pi is reported, never raised.
local require_session = function()
local current = session.active()
if current then return current end
local available, failure = runtime.executable()
if not available then
notify( failure, vim.log.levels.ERROR )
return nil
end
local ok, created = pcall( session.create, {} )
if not ok then
notify( 'cannot start pi: ' .. tostring( created ), vim.log.levels.ERROR )
return nil
end
local started = created.client.failure()
if started then
notify( 'cannot start pi: ' .. started, vim.log.levels.ERROR )
created.stop()
return nil
end
-- Warm the command list so completion has something true to offer.
fetch_commands( created )
return created
end
--- Decides whether `/` opens slash-command entry.
--- Only a pivi input buffer with the cursor on the first character qualifies.
--- @param bufnr number
--- @param col number zero based cursor column
local slash_opens_commands = function( bufnr, col )
if not vim.api.nvim_buf_is_valid( bufnr ) then return false end
if vim.bo[bufnr].filetype ~= INPUT_FILETYPE then return false end
return col == 0
end
local command_names = function( current )
local found = {}
for _, entry in ipairs( current and current.commands or {} ) do
table.insert( found, entry.name )
end
table.sort( found )
return found
end
local knows = function( current, name )
for _, entry in ipairs( current and current.commands or {} ) do
if entry.name == name then return true end
end
return false
end
--- Runs a Pi command.
--- Pi dispatches a command it owns and treats anything else as something the
--- user said, so an unknown name would quietly become a message to the model.
local run_slash_command = function( current, name, arguments )
if not knows( current, name ) then
notify(
( 'pi has no command "%s" here; terminal-only commands are not offered over this channel' ):format( name ),
vim.log.levels.WARN
)
return false
end
local message = '/' .. name
if arguments and arguments ~= '' then message = message .. ' ' .. arguments end
local accepted, rejection = current.submit( message, { capture = false, } )
if not accepted then notify( rejection, vim.log.levels.WARN ) end
return accepted
end
local pick_slash_command = function( current, on_pick )
fetch_commands( current, function( commands )
if #commands == 0 then
notify( 'no pi commands available', vim.log.levels.WARN )
return
end
-- A picker filters the text it is shown, so showing a description here
-- means typing a command name competes with every description that
-- happens to contain the same letters.
vim.ui.select( commands, {
prompt = 'pi command',
format_item = function( entry ) return '/' .. entry.name end,
}, function( choice )
if not choice then return end
on_pick( choice )
end )
end )
end
--- Opens a modifiable prompt buffer bound to a session.
local prompts = {}
--- The window showing a session's transcript, when there is one.
--- A session is not always a live one: a caller may hold a stand-in, so the
--- buffer is only trusted once it looks like a buffer.
local transcript_window = function( current )
if type( current.buffer ) ~= 'number' then return nil end
if not vim.api.nvim_buf_is_valid( current.buffer ) then return nil end
local win = vim.fn.bufwinid( current.buffer )
if win == -1 then return nil end
return win
end
--- Shows a session's prompt, reusing the one it already has.
--- A draft survives, because reopening a prompt is asking to continue writing,
--- not to start over.
--- What `@` can refer to: what is open first, then what is nearby.
--- A buffer is what the user is actually looking at, so it outranks a path
--- that merely exists.
local references = function( cwd )
local found, seen = {}, {}
for _, bufnr in ipairs( vim.api.nvim_list_bufs() ) do
if vim.api.nvim_buf_is_loaded( bufnr ) and vim.bo[bufnr].buftype == '' then
local name = vim.api.nvim_buf_get_name( bufnr )
if name ~= '' then
local shown = vim.fn.fnamemodify( name, ':.' )
if not seen[shown] then
seen[shown] = true
table.insert( found, { path = shown, open = true, modified = vim.bo[bufnr].modified, } )
end
end
end
end
local ok, listed = pcall( vim.fn.systemlist, { 'git', '-C', cwd, 'ls-files', } )
if ok and vim.v.shell_error == 0 then
for _, path in ipairs( listed ) do
if not seen[path] then
seen[path] = true
table.insert( found, { path = path, open = false, } )
end
end
end
return found
end
local pick_reference = function( current, on_pick )
local choices = references( current.cwd or vim.uv.cwd() )
if #choices == 0 then
notify( 'nothing to refer to', vim.log.levels.WARN )
return
end
vim.ui.select( choices, {
prompt = 'refer to',
format_item = function( choice ) return choice.path end,
}, function( choice )
if choice then on_pick( choice.path ) end
end )
end
local open_prompt = function( current )
local existing = prompts[current.name]
if existing and vim.api.nvim_buf_is_valid( existing ) then
local win = vim.fn.bufwinid( existing )
if win == -1 then
win = layout.open_prompt( existing, transcript_window( current ) ) or -1
end
if win ~= -1 then
vim.api.nvim_set_current_win( win )
vim.cmd.startinsert()
end
return existing
end
local bufnr = vim.api.nvim_create_buf( false, true )
vim.bo[bufnr].buftype = 'nofile'
vim.bo[bufnr].bufhidden = 'hide'
vim.bo[bufnr].filetype = INPUT_FILETYPE
vim.b[bufnr].pivi_session = current.name
prompts[current.name] = bufnr
pcall( vim.api.nvim_buf_set_name, bufnr, 'pivi://' .. current.name .. '/prompt' )
vim.keymap.set( { 'n', 'i', }, '/', function()
local col = vim.api.nvim_win_get_cursor( 0 )[2]
if not slash_opens_commands( bufnr, col ) then return '/' end
vim.schedule( function()
pick_slash_command( current, function( choice )
run_slash_command( current, choice.name, nil )
transcript.show( current.buffer )
end )
end )
return ''
end, { buffer = bufnr, expr = true, desc = 'pivi slash command entry', } )
-- `@` names a file, as it does in pi itself. It only takes over when it
-- starts a word, so an address or a decorator can still be typed.
vim.keymap.set( 'i', '@', function()
local line = vim.api.nvim_get_current_line()
local column = vim.api.nvim_win_get_cursor( 0 )[2]
local before = column > 0 and line:sub( column, column ) or ''
if before ~= '' and not before:match '%s' then return '@' end
vim.schedule( function()
pick_reference( current, function( path )
vim.api.nvim_put( { '@' .. path .. ' ', }, 'c', true, true )
vim.cmd.startinsert { bang = true, }
end )
end )
return ''
end, { buffer = bufnr, expr = true, desc = 'pivi refer to a file', } )
guide.bind( bufnr )
local send = function()
local lines = vim.api.nvim_buf_get_lines( bufnr, 0, -1, false )
local text = vim.trim( table.concat( lines, '\n' ) )
if text == '' then return end
local accepted, rejection = current.submit( text, { capture = false, } )
if not accepted then
notify( rejection, vim.log.levels.WARN )
return
end
vim.api.nvim_buf_set_lines( bufnr, 0, -1, false, { '', } )
-- A submission the user cannot read the answer to is a dead end.
transcript.show( current.buffer )
end
vim.keymap.set( { 'n', 'i', }, '<Plug>(pivi-submit)', send,
{ buffer = bufnr, desc = 'pivi submit the prompt', } )
-- Accepting a completion and accepting a prompt are the same intent, so a
-- key bound to this yields to the popup whenever one is open.
vim.keymap.set( 'i', '<Plug>(pivi-accept)', function()
if vim.fn.pumvisible() == 1 then return '<C-y>' end
vim.schedule( send )
return ''
end, { buffer = bufnr, expr = true, desc = 'pivi accept the completion, else the prompt', } )
local win = layout.open_prompt( bufnr, transcript_window( current ) )
if win then
transcript.decorate( win, current.name )
vim.wo[win].winbar = '%#Title#π ' .. current.name ..
'%* %#Comment#' .. ( vim.g.pivi_submit_hint or 'send' ) .. ' / commands%*'
if vim.g.pivi_start_insert ~= false then vim.cmd.startinsert() end
end
return bufnr
end
local submit_text = function( text, options )
local current = require_session()
if not current then return false end
local accepted, rejection = current.submit( text, options )
if not accepted then
notify( rejection, vim.log.levels.WARN )
return false
end
transcript.show( current.buffer )
return true
end
local subcommands = {}
subcommands.ask = function( arguments, modifiers )
if arguments == '' then
notify( 'ask needs a question', vim.log.levels.WARN )
return
end
local options = {}
if modifiers and modifiers.range and modifiers.range > 0 then
options.snapshot = context.snapshot { range = {
kind = 'range',
first = { modifiers.line1, 0, },
last = { modifiers.line2, 0, },
}, }
end
submit_text( arguments, options )
end
subcommands.steer = function( arguments )
submit_text( arguments, { delivery = 'steer', } )
end
subcommands['follow-up'] = function( arguments )
submit_text( arguments, { delivery = 'follow_up', } )
end
subcommands.help = function( arguments )
if arguments == '' then
notify( 'help needs a question', vim.log.levels.WARN )
return
end
help.ask( arguments )
end
subcommands.command = function( arguments )
local current = require_session()
if not current then return end
local name, rest = arguments:match '^(%S+)%s*(.*)$'
if name then
-- The list may not have arrived yet, so it is fetched before judging a name.
fetch_commands( current, function()
if run_slash_command( current, name, rest ) then transcript.show( current.buffer ) end
end )
return
end
pick_slash_command( current, function( choice )
run_slash_command( current, choice.name, nil )
transcript.show( current.buffer )
end )
end
subcommands.start = function( arguments )
local available, failure = runtime.executable()
if not available then
notify( failure, vim.log.levels.ERROR )
return
end
local created = session.create { name = arguments ~= '' and arguments or nil, }
fetch_commands( created )
publish.set_active( created.name )
transcript.show( created.buffer )
notify( 'session ' .. created.name )
end
subcommands.stop = function( arguments )
local target = arguments ~= '' and session.get( arguments ) or session.active()
if not target then
notify( 'no session', vim.log.levels.WARN )
return
end
target.stop()
notify( 'stopped ' .. target.name )
end
subcommands.sessions = function()
local names = session.list()
if #names == 0 then
notify( 'no sessions', vim.log.levels.WARN )
return
end
vim.ui.select( names, { prompt = 'pi session', }, function( choice )
if not choice then return end
session.set_active( choice )
local chosen = session.get( choice )
if chosen then transcript.show( chosen.buffer ) end
end )
end
subcommands.show = function()
local current = session.active()
if not current then
notify( 'no session', vim.log.levels.WARN )
return
end
transcript.show( current.buffer )
end
subcommands.prompt = function()
local current = require_session()
if not current then return end
open_prompt( current )
end
subcommands.abort = function()
local current = session.active()
if not current then
notify( 'no session', vim.log.levels.WARN )
return
end
current.abort()
end
subcommands.queue = function()
local current = session.active()
if not current then
notify( 'no session', vim.log.levels.WARN )
return
end
local queue = current.status.queue or {}
local entries = {}
for _, item in ipairs( queue.steering or {} ) do table.insert( entries, 'steer: ' .. item ) end
for _, item in ipairs( queue.follow_up or {} ) do table.insert( entries, 'follow-up: ' .. item ) end
if #entries == 0 then
notify 'queue is empty'
return
end
notify( table.concat( entries, '\n' ) )
end
subcommands.clear = function()
local current = session.active()
if not current then
notify( 'no session', vim.log.levels.WARN )
return
end
current.clear_queue( function() notify 'queue cleared' end )
end
subcommands.diff = function()
local bufnr = vim.api.nvim_get_current_buf()
if vim.bo[bufnr].filetype ~= transcript.filetype then
notify( 'move the cursor into a transcript first', vim.log.levels.WARN )
return
end
local changed, _, faithful = comparison.open( bufnr )
if not changed then
notify( 'this entry reports no change to compare', vim.log.levels.WARN )
return
end
if faithful == false then
notify( 'the file has moved on since this change', vim.log.levels.WARN )
end
end
--- Switches model.
--- `/model` is a terminal-only command, but the capability itself is on this
--- channel, so pivi offers it natively instead of pretending the command works.
subcommands.model = function( arguments )
local current = require_session()
if not current then return end
current.request( { type = 'get_available_models', }, function( response )
if not response.success or not response.data then
notify( 'pi did not offer a model list', vim.log.levels.WARN )
return
end
local models = response.data.models or {}
if #models == 0 then
notify( 'pi has no models configured', vim.log.levels.WARN )
return
end
local choose = function( model )
current.request(
{ type = 'set_model', provider = model.provider, modelId = model.id, },
function( answer )
if answer.success then
notify( model.provider .. '/' .. model.id )
else
notify( answer.error or 'pi refused the model', vim.log.levels.ERROR )
end
end
)
end
if arguments ~= '' then
for _, model in ipairs( models ) do
if ( model.provider .. '/' .. model.id ) == arguments or model.id == arguments then
choose( model )
return
end
end
notify( 'no model matches ' .. arguments, vim.log.levels.WARN )
return
end
vim.ui.select( models, {
prompt = 'pi model',
format_item = function( model ) return model.provider .. '/' .. model.id end,
}, function( model )
if model then choose( model ) end
end )
end )
end
--- Runs a shell command through pi, so its output joins the conversation.
subcommands.bang = function( arguments )
local current = require_session()
if not current then return end
if arguments == '' then
notify( 'bang needs a command', vim.log.levels.WARN )
return
end
local accepted, rejection = current.bash( arguments, false )
if not accepted then
notify( rejection, vim.log.levels.WARN )
return
end
transcript.show( current.buffer )
end
--- The same, kept out of the conversation.
subcommands['bang!'] = function( arguments )
local current = require_session()
if not current then return end
if arguments == '' then
notify( 'bang needs a command', vim.log.levels.WARN )
return
end
local accepted, rejection = current.bash( arguments, true )
if not accepted then
notify( rejection, vim.log.levels.WARN )
return
end
transcript.show( current.buffer )
end
--- Switches thinking level, the other capability `/thinking` cannot reach here.
subcommands.thinking = function( arguments )
local current = require_session()
if not current then return end
current.request( { type = 'get_available_thinking_levels', }, function( response )
local levels = response.success and response.data and response.data.levels or {}
if #levels == 0 then
notify( 'this model offers no thinking levels', vim.log.levels.WARN )
return
end
local choose = function( level )
current.request( { type = 'set_thinking_level', level = level, }, function( answer )
if answer.success then
notify( 'thinking ' .. level )
else
notify( answer.error or 'pi refused the level', vim.log.levels.ERROR )
end
end )
end
if arguments ~= '' then
if vim.list_contains( levels, arguments ) then
choose( arguments )
else
notify( 'no such level: ' .. arguments, vim.log.levels.WARN )
end
return
end
vim.ui.select( levels, { prompt = 'thinking', }, function( level )
if level then choose( level ) end
end )
end )
end
--- Compacts the conversation, optionally saying what to keep.
subcommands.compact = function( arguments )
local current = require_session()
if not current then return end
local request = { type = 'compact', }
if arguments ~= '' then request.customInstructions = arguments end
notify 'compacting'
current.request( request, function( response )
if not response.success then
notify( response.error or 'compaction failed', vim.log.levels.ERROR )
return
end
local data = response.data or {}
notify( ( 'compacted %s to about %s tokens' ):format(
tostring( data.tokensBefore or '?' ), tostring( data.estimatedTokensAfter or '?' )
) )
end )
end
--- Turns automatic compaction on or off.
subcommands.autocompact = function( arguments )
local current = require_session()
if not current then return end
local wanted = arguments ~= 'off'
current.request( { type = 'set_auto_compaction', enabled = wanted, }, function( response )
if response.success then
notify( 'automatic compaction ' .. ( wanted and 'on' or 'off' ) )
else
notify( response.error or 'pi refused', vim.log.levels.ERROR )
end
end )
end
--- What this session has cost so far.
subcommands.session = function()
local current = require_session()
if not current then return end
current.request( { type = 'get_session_stats', }, function( response )
if not response.success or not response.data then
notify( 'pi did not report session statistics', vim.log.levels.WARN )
return
end
local data = response.data
local tokens = data.tokens or {}
local usage = data.contextUsage or {}
local told = {
'session ' .. tostring( data.sessionId or current.name ),
( 'messages %s, tool calls %s' ):format(
tostring( data.totalMessages or '?' ), tostring( data.toolCalls or '?' )
),
( 'tokens %s in, %s out, %s total' ):format(
tostring( tokens.input or 0 ), tostring( tokens.output or 0 ), tostring( tokens.total or 0 )
),
( 'cost %s' ):format( tostring( data.cost or 0 ) ),
}
if usage.percent then
table.insert( told, ( 'context %s%% of %s' ):format(
tostring( usage.percent ), tostring( usage.contextWindow )
) )
end
notify( table.concat( told, '\n' ) )
end )
end
--- Names the session, so it can be recognised when resumed.
subcommands.name = function( arguments )
local current = require_session()
if not current then return end
if arguments == '' then
notify( 'name needs a name', vim.log.levels.WARN )
return
end
current.request( { type = 'set_session_name', name = arguments, }, function( response )
if response.success then
notify( 'named ' .. arguments )
else
notify( response.error or 'pi refused the name', vim.log.levels.ERROR )
end
end )
end
--- Starts a fresh conversation in the process that is already running.
--- `start` spawns another pi; this one does not.
subcommands.new = function()
local current = require_session()
if not current then return end
current.request( { type = 'new_session', }, function( response )
if not response.success then
notify( response.error or 'pi refused a new session', vim.log.levels.ERROR )
return
end
if response.data and response.data.cancelled then
notify( 'an extension cancelled the new session', vim.log.levels.WARN )
return
end
transcript.clear( current.buffer )
notify 'new conversation'
end )
end
--- Writes the conversation out as it is.
subcommands.export = function( arguments )
local current = require_session()
if not current then return end
local request = { type = 'export_html', }
if arguments ~= '' then request.outputPath = vim.fn.expand( arguments ) end
current.request( request, function( response )
if response.success and response.data then
notify( 'exported to ' .. tostring( response.data.path ) )
else
notify( response.error or 'export failed', vim.log.levels.ERROR )
end
end )
end
subcommands.guide = function()
guide.show( vim.api.nvim_get_current_buf() )
end
subcommands.follow = function( arguments )
follow.toggle( arguments ~= '' and arguments or nil )
notify( 'follow ' .. ( follow.mode() or 'off' ) )
end
local names = function()
local list = {}
for key in pairs( subcommands ) do table.insert( list, key ) end
table.sort( list )
return list
end
local dispatch = function( options )
local input = options.args or ''
local name, rest = input:match '^(%S+)%s*(.*)$'
-- Bare `:Pi` is the way in: it starts a session when there is none and puts
-- the cursor where the user can type.
if not name then
subcommands.prompt()
return
end
local handler = subcommands[name]
if not handler then
notify( 'unknown subcommand: ' .. name, vim.log.levels.ERROR )
return
end
handler( rest or '', options )
end
local setup = function()
vim.api.nvim_create_user_command( 'Pi', dispatch, {
nargs = '*',
range = true,
desc = 'pivi native Pi integration',
complete = function( lead, line )
local matching = function( candidates )
return vim.tbl_filter( function( candidate )
return candidate:sub( 1, #lead ) == lead
end, candidates )
end
-- Completing an argument needs the session's own knowledge, so the list
-- is kept warm and offered as it is, rather than blocking on a request.
local subcommand = line:match '^%s*Pi%s+(%S+)%s+%S*$'
if subcommand == 'command' then return matching( command_names( session.active() ) ) end
if subcommand == 'stop' or subcommand == 'sessions' then return matching( session.list() ) end
if subcommand == 'follow' then return matching { 'window', 'tab', 'current', } end
if subcommand == 'autocompact' then return matching { 'on', 'off', } end
if line:match '^%s*Pi%s+%S*$' == nil then return {} end
return matching( names() )
end,
} )
vim.keymap.set( 'n', '<Plug>(pivi-show)', function() subcommands.show() end, { desc = 'pivi show transcript', } )
vim.keymap.set( 'n', '<Plug>(pivi-prompt)', function() subcommands.prompt() end, { desc = 'pivi open prompt', } )
vim.keymap.set( 'n', '<Plug>(pivi-abort)', function() subcommands.abort() end, { desc = 'pivi abort run', } )
vim.keymap.set( 'n', '<Plug>(pivi-follow)', function() subcommands.follow '' end, { desc = 'pivi toggle follow', } )
vim.keymap.set( 'n', '<Plug>(pivi-guide)', function() subcommands.guide() end, { desc = 'pivi show the guide', } )
end
return {
input_filetype = INPUT_FILETYPE,
setup = setup,
dispatch = dispatch,
subcommands = subcommands,
names = names,
slash_opens_commands = slash_opens_commands,
open_prompt = open_prompt,
}