🐼

Vim/Neovimを通してLLMと対話する ─ prompt-bufferという存在

2024/01/08に公開

LLMとの対話、Vimとの対話

昨年(2023年)はLLMがプログラミング界隈の様々なシーンで使われ始めた一年でしたね。
OpenAI ChatGPTしかり、GitHub Copilotしかり、LLMと語り合うことでプログラミングの小さな課題はいともたやすく解決されるようになりました。

とくにVSCodeはGitHub Copilotとの融合が著しく、Chat用のUIが備えられており、
文脈を読んだAIと気軽に対話できる体験は、なかなかに代えがたいものがあります。

参考:GitHub Copilot Chat beta now available for every organization(GitHub ブログ)

しかし、Vimにおいてそのような「対話的UI」をゼロから構築するのは、なかなかに複雑な実装を要求されることでしょう。
Terminalしかり、各種のFuzzy Finderプラグインの入力画面しかり、その実装はとても大きなものになっていることからもうかがえます。

習作にとOllamaのクライアントをVim/Neovimに組み込もうと実装を進めているなか、
この大きな実装を避けるにはどうすれば良いか、とさまようことになりました。

もくもく会はいいぞ

そんな昨年末、vim-jpコミュニティ内で行われた"新宿もくもく会"に、
作業テーマとしてこの習作をもって参加させていただきました。
そして、もくもく会の空気に集中力を底上げしてもらたおかげで、このテーマにぴったりのちょっと面白い機能を見つけることができました。

やはりもくもく会は良いぞ。

ここではその機能を紹介しようと思います。

"prompt-buffer"

Vim/Neovimのバッファには、buftype="prompt"という特殊なバッファが備えられています。
このオプションについては、ヘルプに詳しい説明が書かれているので、引用してみましょう。

Vim日本語ドキュメント - "prompt-buffer" より抜粋

プロンプトバッファは、'buftype' を "prompt" に設定することによって作成されま
す。通常は、新規作成バッファでのみおこないます。

ユーザーは、バッファの最後の行に1行のテキストを編集して入力することができます。
プロンプト行でEnterキーを押すと、prompt_setcallback()で設定されたコールバッ
クが呼び出されます。通常は、その行をジョブに送信します。別のコールバックは、
ジョブからの出力を受け取り、バッファ内のプロンプトの下 (次のプロンプトの上) に
表示します。

プロンプトの後の最後の行のテキストのみが編集可能です。残りのバッファはノーマル
モードのコマンドでは変更できません。append() などの関数を呼び出すことで変更
できます。他のコマンドを使用すると、バッファを壊す可能性があります。

(中略)

"a", "i", "A" や "I" などの挿入モードを開始するコマンドは、最終行にカーソルを
移動します。"A" は行末に移動し、"I" は行頭に移動します。

説明だけを読んでもパッと想像しにくいかもしれませんが、要点はこういうことのようです。

  • バッファの最後の1行だけテキストを編集して入力できる
  • Enterキーを押すと、設定されたコールバックが呼ばれる
  • 呼び出した結果の出力は、append()などを使ってバッファに書き込む
  • ユーザーが入力を開始すると、最終行にカーソルが移動する

:terminalでシェルを使ったことがある人なら、同じような物を想像できるかもしれません。
対話系の機能をVim/Neovimで作りたい場合には、ドンピシャで欲しい物なのではないでしょうか。

私なりの実装

参考として、私が実装した大きな枠組みを紹介します。

まず、prompt-bufferを準備するには、ヘルプにも記載のあるとおり、新しいバッファを作るのが丸いでしょう。
バッファを作り、'buftype'"prompt"を設定します。

let l:bufnr = bufadd('foo://chat') " 他で使わないような適当な名前。ファイル名と重複すると事故るので気をつける
call setbufvar(l:bufnr, '&buftype', 'prompt')
call bufload(l:bufnr)

作ったバッファには、入力を受け取るコールバック関数を設定しておきます。

function s:input_callback(...)
  " ... 後述
endfunction

call prompt_setcallback(l:bufnr, {text -> s:input_callback(l:bufnr, text)})

コールバックでは、ユーザーの入力を引数で受け取ります。
今回は例のために、入力をダブルクォーテーションでくくったものをオウム返しするようにしましょう。

function s:input_callback(bufnr, text)
  let l:info = getbufinfo(a:bufnr)[0]
  let l:output = 'You said "' .. a:text .. '"'
  call appendbufline(a:bufnr, l:info.linecount, l:output)
endfunction

こうして作ったバッファを開けば、無能BOTくらいの動きはしてくれます。

edit foo://chat
startinsert

実際に呼び出してみると、きちんと応答してくれています。

処理全体のスクリプト

BOT()関数を呼び出すと、対話が始まります。

function! s:input_callback(bufnr, text)
  let l:info = getbufinfo(a:bufnr)[0]
  let l:output = 'You said "' .. a:text .. '"'
  call appendbufline(a:bufnr, l:info.linecount, l:output)
endfunction

function! BOT()
  let l:bufnr = bufadd('foo://chat') " 他で使わないような適当な名前。ファイル名と重複すると事故るので気をつける
  call bufload(l:bufnr)
  call setbufvar(l:bufnr, '&buftype', 'prompt')

  call prompt_setcallback(l:bufnr, {text -> s:input_callback(l:bufnr, text)})

  edit foo://chat
  startinsert
endfunction

実際の活用

実際の処理はここまで単純なモノではなく、外部プログラムをjobで呼び出したり、APIを呼び出したりと、多様でしょう。
しかし、肝となる構成はこれだけです。
対話UIを作る上で一番大変な部分がVim/Neovimの機能として提供されている事に気づけたのは、個人的にはかなり大きな収穫でした。
1つ大きな制約としては「複数行の入力を受け付ける口がない」という点があるので、複数行をガンガン入力するような想定で使う物ではなさそうですが…。

開発中のdenops-ollama.vimでももっと使いこなしたいところです。

個人的には、(特にNeovimではよくあることですが)こういう機能が妙に複雑な他のプラグインを以て「不要」とされて廃止されないか心配ですが、
今回作ったような対話形式のプラグインを書く上では大変便利な物だと思います。

何か面白いアイディアがありましたら、是非みなさんも対話形式のプラグイン、作ってみましょう!

Discussion