From ae113b84222b0f9c9332e6c3af4a5d450687d15e Mon Sep 17 00:00:00 2001 From: Konfekt Date: Thu, 26 Feb 2026 19:56:41 +0100 Subject: [PATCH 1/4] feat: add tab-local AIChat toggle and per-tab chat buffer reuse --- README.md | 11 +++-- autoload/vim_ai.vim | 110 ++++++++++++++++++++++++++------------------ doc/vim-ai.txt | 9 ++-- ftplugin/aichat.vim | 2 + plugin/vim-ai.vim | 2 +- 5 files changed, 81 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 5a29311..ac62b59 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ To use an AI command, type the command followed by an instruction prompt. You ca :AI complete text :AIEdit edit text -:AIChat continue or open new chat +:AIChat toggle chat window (tab-local) :AIStopChat stop the generation of the AI response for the AIChat :AIImage generate image @@ -208,13 +208,14 @@ In the documentation below, `` denotes a visual selection or any oth ### `:AIChat` -`:AIChat` - continue or start a new conversation. +`:AIChat` - open chat window on current tab, jump to it if already open, reopen it if hidden. +Each tab keeps its own chat buffer. -`? :AIChat {instruction}?` - start a new conversation given the selection, the instruction or both +`? :AIChat {instruction}?` - open or focus the tab-local chat and optionally initialize it with selection/instruction -`? :AIChat /{role} {instruction}?` - use role to complete +`? :AIChat /{role} {instruction}?` - open/focus chat and apply role to chat initialization -When the AI finishes answering, you can continue the conversation by entering insert mode, adding your prompt, and then using the command `:AIChat` once again. +Use `:AIChat` inside an `aichat` buffer to continue the conversation. [Pre-defined](./roles-default.ini) chat roles: `/right`, `/below`, `/tab` diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 1b0bf5f..0c7d260 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -21,19 +21,37 @@ function! s:ImportPythonModules() endfor endfunction -function! s:StartsWith(longer, shorter) abort - return a:longer[0:len(a:shorter)-1] ==# a:shorter +function! vim_ai#ImportPythonModules() abort + call s:ImportPythonModules() endfunction -function! s:GetLastScratchBufferName() - let l:all_buffer_names = map(map(filter(copy(getbufinfo()), 'v:val.listed'), 'v:val.bufnr'), 'bufname(v:val)') - let l:buffer_name = -1 - for l:name in l:all_buffer_names - if s:StartsWith(l:name, s:scratch_buffer_name) - let l:buffer_name = l:name +function! s:GetBufferWinIdInTab(bufnr, tabnr) abort + for l:win_id in win_findbuf(a:bufnr) + if win_id2tabwin(l:win_id)[0] == a:tabnr + return l:win_id endif endfor - return l:buffer_name + return -1 +endfunction + +function! s:GetTabChatBuffer(tabnr) abort + let l:chat_bufnr = gettabvar(a:tabnr, 'vim_ai_chat_bufnr', -1) + if l:chat_bufnr == -1 || !bufexists(l:chat_bufnr) + return -1 + endif + if getbufvar(l:chat_bufnr, "&filetype") !=# "aichat" + return -1 + endif + return l:chat_bufnr +endfunction + +function! s:IsBufferVisibleOutsideTab(bufnr, tabnr) abort + for l:win_id in win_findbuf(a:bufnr) + if win_id2tabwin(l:win_id)[0] != a:tabnr + return 1 + endif + endfor + return 0 endfunction " Configures ai-chat scratch window. @@ -41,7 +59,7 @@ endfunction " - opens new ai-chat every time " - excludes buffer from buffer list " - scratch_buffer_keep_open = 1 -" - opens last ai-chat buffer (unless force_new = 1) +" - keeps hidden ai-chat buffer for later reuse in the same tab " - keeps the buffer in the buffer list function! s:OpenChatWindow(open_conf, force_new) abort " open new buffer that will be used as a chat @@ -50,17 +68,7 @@ function! s:OpenChatWindow(open_conf, force_new) abort \ : a:open_conf execute l:open_cmd - " reuse chat in keep-open mode let l:keep_open = g:vim_ai_chat['ui']['scratch_buffer_keep_open'] == '1' - let l:last_scratch_buffer_name = s:GetLastScratchBufferName() - if l:keep_open && bufexists(l:last_scratch_buffer_name) && !a:force_new - let l:current_buffer = bufnr('%') - " reuse chat buffer - execute "buffer " . l:last_scratch_buffer_name - " close new buffer that was created by l:open_cmd - execute "bd " . l:current_buffer - return - endif setlocal buftype=nofile setlocal noswapfile @@ -243,41 +251,54 @@ endfunction function! s:ReuseOrCreateChatWindow(config) let l:open_conf = a:config['ui']['open_chat_command'] - - if a:config['ui']['force_new_chat'] == '1' - call s:OpenChatWindow(l:open_conf, 1) - return - endif + let l:tabnr = tabpagenr() + let l:chat_bufnr = s:GetTabChatBuffer(l:tabnr) + let l:force_new = a:config['ui']['force_new_chat'] == '1' if &filetype != 'aichat' - " reuse chat in active window or tab - let l:chat_win_ids = win_findbuf(bufnr(s:scratch_buffer_name)) - if !empty(l:chat_win_ids) - call win_gotoid(l:chat_win_ids[0]) - return + if l:chat_bufnr != -1 + if s:IsBufferVisibleOutsideTab(l:chat_bufnr, l:tabnr) + let l:chat_bufnr = -1 + endif endif - " reuse .aichat file on the same tab - let buffer_list_tab = tabpagebuflist(tabpagenr()) - let buffer_list_tab = filter(buffer_list_tab, 'getbufvar(v:val, "&filetype") ==# "aichat"') - if len(buffer_list_tab) > 0 - call win_gotoid(win_findbuf(buffer_list_tab[0])[0]) + if l:force_new + call s:OpenChatWindow(l:open_conf, 1) + let l:new_tabnr = tabpagenr() + let l:new_chat_bufnr = bufnr('%') + call settabvar(l:new_tabnr, 'vim_ai_chat_bufnr', l:new_chat_bufnr) + if l:new_tabnr == l:tabnr + \ && l:chat_bufnr != -1 + \ && l:chat_bufnr != l:new_chat_bufnr + \ && !s:IsBufferVisibleOutsideTab(l:chat_bufnr, l:tabnr) + execute "bwipeout " . l:chat_bufnr + endif return endif - " reuse any .aichat buffer in the session - let buffer_list = [] - for i in range(tabpagenr('$')) - call extend(buffer_list, tabpagebuflist(i + 1)) - endfor - let buffer_list = filter(buffer_list, 'getbufvar(v:val, "&filetype") ==# "aichat"') - if len(buffer_list) > 0 - call win_gotoid(win_findbuf(buffer_list[0])[0]) + if l:chat_bufnr != -1 + let l:chat_win_id = s:GetBufferWinIdInTab(l:chat_bufnr, l:tabnr) + if l:chat_win_id != -1 + call win_gotoid(l:chat_win_id) + return + endif + call s:OpenChatWindow(l:open_conf, 1) + let l:new_buffer = bufnr('%') + execute "buffer " . l:chat_bufnr + execute "bd " . l:new_buffer return endif - " open new chat window if no active buffer found call s:OpenChatWindow(l:open_conf, 0) + call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) + endif +endfunction + +function! vim_ai#AIChatToggleRun(uses_range, config, ...) range abort + if a:0 > 0 + execute a:firstline . "," . a:lastline . "call vim_ai#AIChatRun(" . a:uses_range . ", " . string(a:config) . ", " . string(a:1) . ")" + else + execute a:firstline . "," . a:lastline . "call vim_ai#AIChatRun(" . a:uses_range . ", " . string(a:config) . ")" endif endfunction @@ -337,6 +358,7 @@ function! vim_ai#AIChatRun(uses_range, config, ...) range abort try call s:set_paste(l:config) call s:ReuseOrCreateChatWindow(l:config) + call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) let l:context['bufnr'] = bufnr() let l:bufnr = bufnr() diff --git a/doc/vim-ai.txt b/doc/vim-ai.txt index b5c82d5..d6d088d 100644 --- a/doc/vim-ai.txt +++ b/doc/vim-ai.txt @@ -18,6 +18,7 @@ https://github.com/madox2/vim-ai :AI {prompt} complete the prompt :AI complete the selection :AI {instruction} complete the selection using the instruction +In aichat buffers: :AI continue chat completion Options: > let s:initial_complete_prompt =<< trim END @@ -117,9 +118,11 @@ https://platform.openai.com/docs/api-reference/completions *:AIChat* -:AIChat continue or start a new conversation. -? :AIChat {instruction}? start a new conversation given the selection, - the instruction or both +:AIChat open/focus tab-local chat window + Each tab keeps its own chat buffer. + In aichat buffers this continues the chat. +? :AIChat {instruction}? open/focus tab-local chat and optionally + initialize it using selection/instruction Options: > let s:initial_chat_prompt =<< trim END diff --git a/ftplugin/aichat.vim b/ftplugin/aichat.vim index bafba07..f78b9ed 100644 --- a/ftplugin/aichat.vim +++ b/ftplugin/aichat.vim @@ -133,3 +133,5 @@ augroup Aichat autocmd InsertEnter,InsertLeave call s:MarkdownRefreshSyntax(0) autocmd CursorHold,CursorHoldI call s:MarkdownRefreshSyntax(0) augroup END + +command! -buffer -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AI ,call vim_ai#AIChatRun(, {}, ) diff --git a/plugin/vim-ai.vim b/plugin/vim-ai.vim index 0b0bee4..5373d9d 100644 --- a/plugin/vim-ai.vim +++ b/plugin/vim-ai.vim @@ -13,7 +13,7 @@ cal vim_ai_provider#Register('openai', { command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionComplete AI ,call vim_ai#AIRun(, {}, ) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionEdit AIEdit ,call vim_ai#AIEditRun(, {}, ) -command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AIChat ,call vim_ai#AIChatRun(, {}, ) +command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AIChat ,call vim_ai#AIChatToggleRun(, {}, ) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionImage AIImage ,call vim_ai#AIImageRun(, {}, ) command! -nargs=? AINewChat call vim_ai#AINewChatDeprecatedRun() command! AIRedo call vim_ai#AIRedoRun() From 60c6e2af651fdc5448ca929ee8f1533eceed5b9f Mon Sep 17 00:00:00 2001 From: Konfekt Date: Sat, 7 Mar 2026 12:44:07 +0100 Subject: [PATCH 2/4] fix(chat): simplify chat window reuse and command handling Avoid toggling chat buffers through a separate entry point and fix tab-local chat reuse when buffers are hidden or visible elsewhere. Reuse or create chat windows in one code path, track tab chat buffers consistently, and open escaped scratch buffer names safely. - Removed the unused `force_new` parameter from `s:OpenChatWindow()`. - Removed the forwarding-only `vim_ai#AIChatToggleRun()`. - Removed the duplicate `settabvar()` from `vim_ai#AIChatRun()`. - Added `abort` to `s:ReuseOrCreateChatWindow()`. - Kept tab ownership based on `bufnr`, not scratch names. - Escaped scratch names with `fnameescape()` when calling `:file`. - Inlined trivial one-use window visibility logic into `s:ReuseOrCreateChatWindow()`. --- autoload/vim_ai.vim | 157 ++++++++++++++++++-------------------------- plugin/vim-ai.vim | 2 +- 2 files changed, 65 insertions(+), 94 deletions(-) diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 0c7d260..76624e0 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -25,35 +25,17 @@ function! vim_ai#ImportPythonModules() abort call s:ImportPythonModules() endfunction -function! s:GetBufferWinIdInTab(bufnr, tabnr) abort - for l:win_id in win_findbuf(a:bufnr) - if win_id2tabwin(l:win_id)[0] == a:tabnr - return l:win_id - endif - endfor - return -1 -endfunction - function! s:GetTabChatBuffer(tabnr) abort let l:chat_bufnr = gettabvar(a:tabnr, 'vim_ai_chat_bufnr', -1) if l:chat_bufnr == -1 || !bufexists(l:chat_bufnr) return -1 endif - if getbufvar(l:chat_bufnr, "&filetype") !=# "aichat" + if getbufvar(l:chat_bufnr, '&filetype') !=# 'aichat' return -1 endif return l:chat_bufnr endfunction -function! s:IsBufferVisibleOutsideTab(bufnr, tabnr) abort - for l:win_id in win_findbuf(a:bufnr) - if win_id2tabwin(l:win_id)[0] != a:tabnr - return 1 - endif - endfor - return 0 -endfunction - " Configures ai-chat scratch window. " - scratch_buffer_keep_open = 0 " - opens new ai-chat every time @@ -61,33 +43,24 @@ endfunction " - scratch_buffer_keep_open = 1 " - keeps hidden ai-chat buffer for later reuse in the same tab " - keeps the buffer in the buffer list -function! s:OpenChatWindow(open_conf, force_new) abort - " open new buffer that will be used as a chat - let l:open_cmd = has_key(g:vim_ai_open_chat_presets, a:open_conf) - \ ? g:vim_ai_open_chat_presets[a:open_conf] - \ : a:open_conf +function! s:OpenChatWindow(open_conf) abort + let l:open_cmd = get(g:vim_ai_open_chat_presets, a:open_conf, a:open_conf) execute l:open_cmd - let l:keep_open = g:vim_ai_chat['ui']['scratch_buffer_keep_open'] == '1' + let l:keep_open = g:vim_ai_chat['ui']['scratch_buffer_keep_open'] ==# '1' setlocal buftype=nofile setlocal noswapfile - setlocal ft=aichat - if l:keep_open - setlocal bufhidden=hide - else - setlocal bufhidden=wipe - endif - if bufexists(s:scratch_buffer_name) - " spawn another window if chat already exist - let l:index = 2 - while bufexists(s:scratch_buffer_name . " " . l:index) - let l:index += 1 - endwhile - execute "file " . s:scratch_buffer_name . " " . l:index - else - execute "file " . s:scratch_buffer_name - endif + setlocal filetype=aichat + execute 'setlocal bufhidden=' . (l:keep_open ? 'hide' : 'wipe') + + let l:buffer_name = s:scratch_buffer_name + let l:index = 2 + while bufexists(l:buffer_name) + let l:buffer_name = s:scratch_buffer_name . ' ' . l:index + let l:index += 1 + endwhile + execute 'file ' . fnameescape(l:buffer_name) endfunction let s:is_handling_paste_mode = 0 @@ -249,56 +222,59 @@ function! vim_ai#AIImageRun(uses_range, config, ...) range abort py3 run_ai_image(unwrap('l:context')) endfunction -function! s:ReuseOrCreateChatWindow(config) - let l:open_conf = a:config['ui']['open_chat_command'] +function! s:ReuseOrCreateChatWindow(config) abort let l:tabnr = tabpagenr() - let l:chat_bufnr = s:GetTabChatBuffer(l:tabnr) - let l:force_new = a:config['ui']['force_new_chat'] == '1' + let l:force_new = a:config['ui']['force_new_chat'] ==# '1' - if &filetype != 'aichat' - if l:chat_bufnr != -1 - if s:IsBufferVisibleOutsideTab(l:chat_bufnr, l:tabnr) - let l:chat_bufnr = -1 - endif - endif + if &filetype ==# 'aichat' + call settabvar(l:tabnr, 'vim_ai_chat_bufnr', bufnr('%')) + return + endif - if l:force_new - call s:OpenChatWindow(l:open_conf, 1) - let l:new_tabnr = tabpagenr() - let l:new_chat_bufnr = bufnr('%') - call settabvar(l:new_tabnr, 'vim_ai_chat_bufnr', l:new_chat_bufnr) - if l:new_tabnr == l:tabnr - \ && l:chat_bufnr != -1 - \ && l:chat_bufnr != l:new_chat_bufnr - \ && !s:IsBufferVisibleOutsideTab(l:chat_bufnr, l:tabnr) - execute "bwipeout " . l:chat_bufnr + let l:chat_bufnr = s:GetTabChatBuffer(l:tabnr) + let l:chat_win_id = -1 + let l:is_visible_outside_tab = 0 + + if l:chat_bufnr != -1 + for l:win_id in win_findbuf(l:chat_bufnr) + let l:win_tabnr = win_id2tabwin(l:win_id)[0] + if l:win_tabnr == l:tabnr + let l:chat_win_id = l:win_id + else + let l:is_visible_outside_tab = 1 endif - return - endif + endfor - if l:chat_bufnr != -1 - let l:chat_win_id = s:GetBufferWinIdInTab(l:chat_bufnr, l:tabnr) - if l:chat_win_id != -1 - call win_gotoid(l:chat_win_id) - return - endif - call s:OpenChatWindow(l:open_conf, 1) - let l:new_buffer = bufnr('%') - execute "buffer " . l:chat_bufnr - execute "bd " . l:new_buffer - return + if l:is_visible_outside_tab + let l:chat_bufnr = -1 + let l:chat_win_id = -1 endif + endif - call s:OpenChatWindow(l:open_conf, 0) + if !l:force_new && l:chat_win_id != -1 + call win_gotoid(l:chat_win_id) call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) + return endif -endfunction -function! vim_ai#AIChatToggleRun(uses_range, config, ...) range abort - if a:0 > 0 - execute a:firstline . "," . a:lastline . "call vim_ai#AIChatRun(" . a:uses_range . ", " . string(a:config) . ", " . string(a:1) . ")" - else - execute a:firstline . "," . a:lastline . "call vim_ai#AIChatRun(" . a:uses_range . ", " . string(a:config) . ")" + call s:OpenChatWindow(a:config['ui']['open_chat_command']) + let l:new_tabnr = tabpagenr() + let l:new_chat_bufnr = bufnr('%') + + if !l:force_new && l:chat_bufnr != -1 + execute 'buffer ' . l:chat_bufnr + execute 'bwipeout ' . l:new_chat_bufnr + call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) + return + endif + + call settabvar(l:new_tabnr, 'vim_ai_chat_bufnr', l:new_chat_bufnr) + + if l:force_new + \ && l:new_tabnr == l:tabnr + \ && l:chat_bufnr != -1 + \ && l:chat_bufnr != l:new_chat_bufnr + execute 'bwipeout ' . l:chat_bufnr endif endfunction @@ -343,12 +319,12 @@ function! vim_ai#AIChatRun(uses_range, config, ...) range abort let l:started_from_chat = &filetype == 'aichat' let l:config_input = { - \ "config_default": g:vim_ai_chat, - \ "config_extension": a:config, - \ "user_instruction": l:instruction, - \ "user_selection": l:selection, - \ "is_selection": l:is_selection, - \ "command_type": 'chat', + \ 'config_default': g:vim_ai_chat, + \ 'config_extension': a:config, + \ 'user_instruction': l:instruction, + \ 'user_selection': l:selection, + \ 'is_selection': l:is_selection, + \ 'command_type': 'chat', \} let l:context = py3eval("make_ai_context(unwrap('l:config_input'))") let l:config = l:context['config'] @@ -358,7 +334,6 @@ function! vim_ai#AIChatRun(uses_range, config, ...) range abort try call s:set_paste(l:config) call s:ReuseOrCreateChatWindow(l:config) - call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) let l:context['bufnr'] = bufnr() let l:bufnr = bufnr() @@ -368,15 +343,12 @@ function! vim_ai#AIChatRun(uses_range, config, ...) range abort return endif - let s:last_command = "chat" + let s:last_command = 'chat' let s:last_config = a:config if py3eval("run_ai_chat(unwrap('l:context'))") if g:vim_ai_async_chat == 1 - call setbufvar(l:bufnr, 'vim_ai_chat_start_last_line', line('$')) - " if user switches to a different buffer, setup autocommand that - " will clean undo history after returning back augroup AichatUndo au! autocmd BufEnter call s:AIChatUndoCleanup() @@ -402,7 +374,6 @@ function! vim_ai#AIChatStopRun() abort call s:AIChatUndoCleanup() endfunction - " Function called in a timer that check if there are new lines from AI and " appned them in a buffer. It ends when AI thread is finished (or when " stopped). diff --git a/plugin/vim-ai.vim b/plugin/vim-ai.vim index 5373d9d..0b0bee4 100644 --- a/plugin/vim-ai.vim +++ b/plugin/vim-ai.vim @@ -13,7 +13,7 @@ cal vim_ai_provider#Register('openai', { command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionComplete AI ,call vim_ai#AIRun(, {}, ) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionEdit AIEdit ,call vim_ai#AIEditRun(, {}, ) -command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AIChat ,call vim_ai#AIChatToggleRun(, {}, ) +command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionChat AIChat ,call vim_ai#AIChatRun(, {}, ) command! -range -nargs=? -complete=customlist,vim_ai#RoleCompletionImage AIImage ,call vim_ai#AIImageRun(, {}, ) command! -nargs=? AINewChat call vim_ai#AINewChatDeprecatedRun() command! AIRedo call vim_ai#AIRedoRun() From 563c4e9d17c59271fd2802cff17e15cc712a848a Mon Sep 17 00:00:00 2001 From: Enno Date: Sat, 7 Mar 2026 14:23:58 +0100 Subject: [PATCH 3/4] Update autoload/vim_ai.vim Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- autoload/vim_ai.vim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autoload/vim_ai.vim b/autoload/vim_ai.vim index 76624e0..8bdb247 100644 --- a/autoload/vim_ai.vim +++ b/autoload/vim_ai.vim @@ -261,10 +261,10 @@ function! s:ReuseOrCreateChatWindow(config) abort let l:new_tabnr = tabpagenr() let l:new_chat_bufnr = bufnr('%') - if !l:force_new && l:chat_bufnr != -1 + if !l:force_new && l:chat_bufnr != -1 && l:new_tabnr == l:tabnr execute 'buffer ' . l:chat_bufnr execute 'bwipeout ' . l:new_chat_bufnr - call settabvar(tabpagenr(), 'vim_ai_chat_bufnr', bufnr('%')) + call settabvar(l:new_tabnr, 'vim_ai_chat_bufnr', bufnr('%')) return endif From 3d45931f61476696184dd4f86266724ee49c160e Mon Sep 17 00:00:00 2001 From: Enno Date: Sat, 7 Mar 2026 14:24:04 +0100 Subject: [PATCH 4/4] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac62b59..57e7a91 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ To use an AI command, type the command followed by an instruction prompt. You ca :AI complete text :AIEdit edit text -:AIChat toggle chat window (tab-local) +:AIChat open/focus chat window (tab-local) :AIStopChat stop the generation of the AI response for the AIChat :AIImage generate image