---@mod codetyper.ask Ask window for Codetyper.nvim (similar to avante.nvim) local M = {} local utils = require("codetyper.utils") ---@class AskState ---@field input_buf number|nil Input buffer ---@field input_win number|nil Input window ---@field output_buf number|nil Output buffer ---@field output_win number|nil Output window ---@field is_open boolean Whether the ask panel is open ---@field history table Chat history ---@field referenced_files table Files referenced with @ ---@type AskState local state = { input_buf = nil, input_win = nil, output_buf = nil, output_win = nil, is_open = false, history = {}, referenced_files = {}, target_width = nil, -- Store the target width to maintain it agent_mode = false, -- Whether agent mode is enabled (can make file changes) } --- Get the ask window configuration ---@return table Config local function get_config() local ok, codetyper = pcall(require, "codetyper") if ok and codetyper.is_initialized() then return codetyper.get_config() end return { window = { width = 0.4, border = "rounded" }, } end --- Create the output buffer (chat history) ---@return number Buffer number local function create_output_buffer() local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = "hide" vim.bo[buf].swapfile = false vim.bo[buf].filetype = "markdown" -- Set initial content local header = { "╔═════════════════════════════════╗", "║ [ASK MODE] Q&A Chat ║", "╠═════════════════════════════════╣", "║ Ask about code or concepts ║", "║ ║", "║ @ → attach file ║", "║ C-Enter → send ║", "║ C-n → new chat ║", "║ C-f → add current file ║", "║ :CoderType → switch mode ║", "║ q → close │ K/J → jump ║", "╚═════════════════════════════════╝", "", } vim.api.nvim_buf_set_lines(buf, 0, -1, false, header) return buf end --- Create the input buffer ---@return number Buffer number local function create_input_buffer() local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = "hide" vim.bo[buf].swapfile = false vim.bo[buf].filetype = "markdown" -- Set placeholder text local placeholder = { "┌───────────────────────────────────┐", "│ 💬 Type your question here... │", "│ │", "│ @ attach │ C-Enter send │ C-n new │", "└───────────────────────────────────┘", } vim.api.nvim_buf_set_lines(buf, 0, -1, false, placeholder) return buf end --- Setup keymaps for the input buffer ---@param buf number Buffer number local function setup_input_keymaps(buf) local opts = { buffer = buf, noremap = true, silent = true } -- Submit with Ctrl+Enter vim.keymap.set("i", "", function() M.submit() end, opts) vim.keymap.set("n", "", function() M.submit() end, opts) vim.keymap.set("n", "", function() M.submit() end, opts) -- Include current file context with Ctrl+F vim.keymap.set({ "n", "i" }, "", function() M.include_file_context() end, opts) -- File picker with @ vim.keymap.set("i", "@", function() M.show_file_picker() end, opts) -- Close with q in normal mode vim.keymap.set("n", "q", function() M.close() end, opts) -- Clear input with Ctrl+c vim.keymap.set("n", "", function() M.clear_input() end, opts) -- New chat with Ctrl+n (clears everything) vim.keymap.set({ "n", "i" }, "", function() M.new_chat() end, opts) -- Window navigation (works in both normal and insert mode) vim.keymap.set({ "n", "i" }, "", function() vim.cmd("wincmd h") end, opts) vim.keymap.set({ "n", "i" }, "", function() vim.cmd("wincmd j") end, opts) vim.keymap.set({ "n", "i" }, "", function() vim.cmd("wincmd k") end, opts) vim.keymap.set({ "n", "i" }, "", function() vim.cmd("wincmd l") end, opts) -- Jump to output window vim.keymap.set("n", "K", function() M.focus_output() end, opts) -- When entering insert mode, clear placeholder vim.api.nvim_create_autocmd("InsertEnter", { buffer = buf, callback = function() local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local content = table.concat(lines, "\n") if content:match("Type your question here") then vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "" }) end end, }) end --- Setup keymaps for the output buffer ---@param buf number Buffer number local function setup_output_keymaps(buf) local opts = { buffer = buf, noremap = true, silent = true } -- Close with q vim.keymap.set("n", "q", function() M.close() end, opts) -- Clear history with Ctrl+c vim.keymap.set("n", "", function() M.clear_history() end, opts) -- New chat with Ctrl+n (clears everything) vim.keymap.set("n", "", function() M.new_chat() end, opts) -- Copy last response with Y vim.keymap.set("n", "Y", function() M.copy_last_response() end, opts) -- Jump to input with i or J vim.keymap.set("n", "i", function() M.focus_input() end, opts) vim.keymap.set("n", "J", function() M.focus_input() end, opts) -- Window navigation vim.keymap.set("n", "", function() vim.cmd("wincmd h") end, opts) vim.keymap.set("n", "", function() vim.cmd("wincmd j") end, opts) vim.keymap.set("n", "", function() vim.cmd("wincmd k") end, opts) vim.keymap.set("n", "", function() vim.cmd("wincmd l") end, opts) end --- Calculate window dimensions (always 1/4 of screen) ---@return table Dimensions local function calculate_dimensions() -- Always use 1/4 of the screen width local width = math.floor(vim.o.columns * 0.25) return { width = math.max(width, 30), -- Minimum 30 columns total_height = vim.o.lines - 4, output_height = vim.o.lines - 14, input_height = 8, } end --- Autocmd group for maintaining width local ask_augroup = nil --- Setup autocmd to always maintain 1/4 window width local function setup_width_autocmd() -- Clear previous autocmd group if exists if ask_augroup then pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) end ask_augroup = vim.api.nvim_create_augroup("CodetypeAskWidth", { clear = true }) -- Always maintain 1/4 width on any window event vim.api.nvim_create_autocmd({ "WinResized", "WinNew", "WinClosed", "VimResized" }, { group = ask_augroup, callback = function() if not state.is_open or not state.output_win then return end if not vim.api.nvim_win_is_valid(state.output_win) then return end vim.schedule(function() if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then -- Always calculate 1/4 of current screen width local target_width = math.max(math.floor(vim.o.columns * 0.25), 30) state.target_width = target_width local current_width = vim.api.nvim_win_get_width(state.output_win) if current_width ~= target_width then pcall(vim.api.nvim_win_set_width, state.output_win, target_width) end end end) end, desc = "Maintain Ask panel at 1/4 window width", }) end --- Open the ask panel function M.open() -- Use the is_open() function which validates window state if M.is_open() then M.focus_input() return end local dims = calculate_dimensions() -- Store the target width state.target_width = dims.width -- Create buffers if they don't exist if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then state.output_buf = create_output_buffer() setup_output_keymaps(state.output_buf) end if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then state.input_buf = create_input_buffer() setup_input_keymaps(state.input_buf) end -- Save current window to return to it later local current_win = vim.api.nvim_get_current_win() -- Create output window (top-left) vim.cmd("topleft vsplit") state.output_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(state.output_win, state.output_buf) vim.api.nvim_win_set_width(state.output_win, dims.width) -- Window options for output vim.wo[state.output_win].number = false vim.wo[state.output_win].relativenumber = false vim.wo[state.output_win].signcolumn = "no" vim.wo[state.output_win].wrap = true vim.wo[state.output_win].linebreak = true vim.wo[state.output_win].cursorline = false vim.wo[state.output_win].winfixwidth = true -- Create input window (bottom of the left panel) vim.cmd("belowright split") state.input_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(state.input_win, state.input_buf) vim.api.nvim_win_set_height(state.input_win, dims.input_height) -- Window options for input vim.wo[state.input_win].number = false vim.wo[state.input_win].relativenumber = false vim.wo[state.input_win].signcolumn = "no" vim.wo[state.input_win].wrap = true vim.wo[state.input_win].linebreak = true vim.wo[state.input_win].winfixheight = true vim.wo[state.input_win].winfixwidth = true state.is_open = true -- Setup autocmd to maintain width setup_width_autocmd() -- Setup autocmd to close both windows when one is closed local close_group = vim.api.nvim_create_augroup("CodetypeAskClose", { clear = true }) vim.api.nvim_create_autocmd("WinClosed", { group = close_group, callback = function(args) local closed_win = tonumber(args.match) -- Check if one of our windows was closed if closed_win == state.output_win or closed_win == state.input_win then -- Defer to avoid issues during window close vim.schedule(function() -- Close the other window if it's still open if closed_win == state.output_win then if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then pcall(vim.api.nvim_win_close, state.input_win, true) end elseif closed_win == state.input_win then if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then pcall(vim.api.nvim_win_close, state.output_win, true) end end -- Reset state state.input_win = nil state.output_win = nil state.is_open = false state.target_width = nil -- Clean up autocmd groups pcall(vim.api.nvim_del_augroup_by_id, close_group) if ask_augroup then pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) ask_augroup = nil end end) end end, desc = "Close both Ask windows together", }) -- Focus the input window and start insert mode vim.api.nvim_set_current_win(state.input_win) vim.cmd("startinsert") end --- Show file picker for @ mentions function M.show_file_picker() -- Check if telescope is available local has_telescope, telescope = pcall(require, "telescope.builtin") if has_telescope then telescope.find_files({ prompt_title = "Select file to reference (@)", attach_mappings = function(prompt_bufnr, map) local actions = require("telescope.actions") local action_state = require("telescope.actions.state") actions.select_default:replace(function() actions.close(prompt_bufnr) local selection = action_state.get_selected_entry() if selection then local filepath = selection.path or selection[1] local filename = vim.fn.fnamemodify(filepath, ":t") M.add_file_reference(filepath, filename) end end) return true end, }) else -- Fallback: simple input vim.ui.input({ prompt = "Enter file path: " }, function(input) if input and input ~= "" then local filepath = vim.fn.fnamemodify(input, ":p") local filename = vim.fn.fnamemodify(filepath, ":t") M.add_file_reference(filepath, filename) end end) end end --- Add a file reference to the input ---@param filepath string Full path to the file ---@param filename string Display name function M.add_file_reference(filepath, filename) -- Normalize filepath filepath = vim.fn.fnamemodify(filepath, ":p") -- Store the reference with full path state.referenced_files[filename] = filepath -- Read and validate file exists local content = utils.read_file(filepath) if not content then utils.notify("Warning: Could not read file: " .. filename, vim.log.levels.WARN) end -- Add to input buffer if state.input_buf and vim.api.nvim_buf_is_valid(state.input_buf) then local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false) local text = table.concat(lines, "\n") -- Clear placeholder if present if text:match("Type your question here") then text = "" end -- Add file reference (with single @) local reference = "[📎 " .. filename .. "] " text = text .. reference vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, vim.split(text, "\n")) -- Move cursor to end if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then vim.api.nvim_set_current_win(state.input_win) local line_count = vim.api.nvim_buf_line_count(state.input_buf) local last_line = vim.api.nvim_buf_get_lines(state.input_buf, line_count - 1, line_count, false)[1] or "" vim.api.nvim_win_set_cursor(state.input_win, { line_count, #last_line }) vim.cmd("startinsert!") end end utils.notify("Added file: " .. filename .. " (" .. (content and #content or 0) .. " bytes)") end --- Close the ask panel function M.close() -- Remove the width maintenance autocmd first if ask_augroup then pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) ask_augroup = nil end -- Find a window to focus after closing (not the ask windows) local target_win = nil for _, win in ipairs(vim.api.nvim_list_wins()) do local buf = vim.api.nvim_win_get_buf(win) if win ~= state.input_win and win ~= state.output_win then local buftype = vim.bo[buf].buftype if buftype == "" or buftype == "acwrite" then target_win = win break end end end -- Close input window if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then pcall(vim.api.nvim_win_close, state.input_win, true) end -- Close output window if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then pcall(vim.api.nvim_win_close, state.output_win, true) end -- Reset state state.input_win = nil state.output_win = nil state.is_open = false state.target_width = nil -- Focus the target window if found, otherwise focus first available if target_win and vim.api.nvim_win_is_valid(target_win) then pcall(vim.api.nvim_set_current_win, target_win) else -- If no valid window, make sure we're not left with empty state local wins = vim.api.nvim_list_wins() if #wins > 0 then pcall(vim.api.nvim_set_current_win, wins[1]) end end end --- Toggle the ask panel function M.toggle() if M.is_open() then M.close() else M.open() end end --- Focus the input window function M.focus_input() if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then vim.api.nvim_set_current_win(state.input_win) vim.cmd("startinsert") end end --- Focus the output window function M.focus_output() if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then vim.api.nvim_set_current_win(state.output_win) end end --- Get input text ---@return string Input text local function get_input_text() if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then return "" end local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false) local content = table.concat(lines, "\n") -- Ignore placeholder if content:match("Type your question here") then return "" end return content end --- Clear input buffer function M.clear_input() if state.input_buf and vim.api.nvim_buf_is_valid(state.input_buf) then vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" }) end state.referenced_files = {} end --- Append text to output buffer ---@param text string Text to append ---@param is_user boolean Whether this is user message local function append_to_output(text, is_user) if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then return end vim.bo[state.output_buf].modifiable = true local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) local timestamp = os.date("%H:%M") local header = is_user and "┌─ 👤 You [" .. timestamp .. "] ────────" or "┌─ 🤖 AI [" .. timestamp .. "] ──────────" local new_lines = { "", header, "│" } -- Add text lines with border for _, line in ipairs(vim.split(text, "\n")) do table.insert(new_lines, "│ " .. line) end table.insert( new_lines, "└─────────────────────────────────" ) for _, line in ipairs(new_lines) do table.insert(lines, line) end vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) vim.bo[state.output_buf].modifiable = false -- Scroll to bottom if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then local line_count = vim.api.nvim_buf_line_count(state.output_buf) vim.api.nvim_win_set_cursor(state.output_win, { line_count, 0 }) end end --- Build context from referenced files ---@return string Context string, number File count local function build_file_context() local context = "" local file_count = 0 for filename, filepath in pairs(state.referenced_files) do local content = utils.read_file(filepath) if content and content ~= "" then -- Detect language from extension local ext = vim.fn.fnamemodify(filepath, ":e") local lang = ext or "text" context = context .. "\n\n=== FILE: " .. filename .. " ===\n" context = context .. "Path: " .. filepath .. "\n" context = context .. "```" .. lang .. "\n" .. content .. "\n```\n" file_count = file_count + 1 end end return context, file_count end --- Build context for the question ---@return table Context object local function build_context() local context = { project_root = utils.get_project_root(), current_file = nil, current_content = nil, language = nil, referenced_files = state.referenced_files, } -- Try to get current file context from the non-ask window local wins = vim.api.nvim_list_wins() for _, win in ipairs(wins) do if win ~= state.input_win and win ~= state.output_win then local buf = vim.api.nvim_win_get_buf(win) local filepath = vim.api.nvim_buf_get_name(buf) if filepath and filepath ~= "" and not filepath:match("%.coder%.") then context.current_file = filepath context.current_content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") context.language = vim.bo[buf].filetype break end end end return context end --- Submit the question to LLM function M.submit() local question = get_input_text() if not question or question:match("^%s*$") then utils.notify("Please enter a question", vim.log.levels.WARN) M.focus_input() return end -- Build context BEFORE clearing input (to preserve file references) local context = build_context() local file_context, file_count = build_file_context() -- Build display message (without full file contents) local display_question = question if file_count > 0 then display_question = question .. "\n📎 " .. file_count .. " file(s) attached" end -- Add user message to output append_to_output(display_question, true) -- Clear input and references AFTER building context M.clear_input() -- Build system prompt for ask mode using prompts module local prompts = require("codetyper.prompts") local system_prompt = prompts.system.ask if context.current_file then system_prompt = system_prompt .. "\n\nCurrent open file: " .. context.current_file system_prompt = system_prompt .. "\nLanguage: " .. (context.language or "unknown") end -- Add to history table.insert(state.history, { role = "user", content = question }) -- Show loading indicator append_to_output("⏳ Thinking...", false) -- Get LLM client and generate response local ok, llm = pcall(require, "codetyper.llm") if not ok then append_to_output("❌ Error: LLM module not loaded", false) return end local client = llm.get_client() -- Build full prompt WITH file contents local full_prompt = question if file_context ~= "" then full_prompt = "USER QUESTION: " .. question .. "\n\n" .. "ATTACHED FILE CONTENTS (please analyze these):" .. file_context end -- Also add current file if no files were explicitly attached if file_count == 0 and context.current_content and context.current_content ~= "" then full_prompt = "USER QUESTION: " .. question .. "\n\n" .. "CURRENT FILE (" .. (context.current_file or "unknown") .. "):\n```\n" .. context.current_content .. "\n```" end local request_context = { file_content = file_context ~= "" and file_context or context.current_content, language = context.language, prompt_type = "explain", file_path = context.current_file, } client.generate(full_prompt, request_context, function(response, err) -- Remove loading indicator if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then vim.bo[state.output_buf].modifiable = true local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) -- Remove last few lines (the thinking message) local to_remove = 0 for i = #lines, 1, -1 do if lines[i]:match("Thinking") or lines[i]:match("^[│└┌─]") then to_remove = to_remove + 1 if lines[i]:match("┌") then break end else break end end for _ = 1, math.min(to_remove, 5) do table.remove(lines) end vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) vim.bo[state.output_buf].modifiable = false end if err then append_to_output("❌ Error: " .. err, false) return end if response then -- Add to history table.insert(state.history, { role = "assistant", content = response }) -- Display response append_to_output(response, false) else append_to_output("❌ No response received", false) end -- Focus back to input M.focus_input() end) end --- Clear chat history function M.clear_history() state.history = {} state.referenced_files = {} if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then local header = { "╔═════════════════════════════════╗", "║ [ASK MODE] Q&A Chat ║", "╠═════════════════════════════════╣", "║ Ask about code or concepts ║", "║ ║", "║ @ → attach file ║", "║ C-Enter → send ║", "║ C-n → new chat ║", "║ C-f → add current file ║", "║ :CoderType → switch mode ║", "║ q → close │ K/J → jump ║", "╚═════════════════════════════════╝", "", } vim.bo[state.output_buf].modifiable = true vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, header) vim.bo[state.output_buf].modifiable = false end utils.notify("Chat history cleared") end --- Start a new chat (clears history and input) function M.new_chat() -- Clear the input M.clear_input() -- Clear the history M.clear_history() -- Focus the input M.focus_input() utils.notify("Started new chat") end --- Include current file context in input function M.include_file_context() local context = build_context() if not context.current_file then utils.notify("No file context available", vim.log.levels.WARN) return end local filename = vim.fn.fnamemodify(context.current_file, ":t") M.add_file_reference(context.current_file, filename) end --- Copy last assistant response to clipboard function M.copy_last_response() for i = #state.history, 1, -1 do if state.history[i].role == "assistant" then vim.fn.setreg("+", state.history[i].content) utils.notify("Response copied to clipboard") return end end utils.notify("No response to copy", vim.log.levels.WARN) end --- Show chat mode switcher modal function M.show_chat_switcher() local switcher = require("codetyper.chat_switcher") switcher.show() end --- Check if ask panel is open (validates window state) ---@return boolean function M.is_open() -- Verify windows are actually valid, not just the flag if state.is_open then local output_valid = state.output_win and vim.api.nvim_win_is_valid(state.output_win) local input_valid = state.input_win and vim.api.nvim_win_is_valid(state.input_win) -- If either window is invalid, reset the state if not output_valid or not input_valid then state.is_open = false state.output_win = nil state.input_win = nil state.target_width = nil -- Clean up autocmd if ask_augroup then pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) ask_augroup = nil end end end return state.is_open end --- Get chat history ---@return table History function M.get_history() return state.history end return M